diff --git a/go-backend/internal/http/handler/handler.go b/go-backend/internal/http/handler/handler.go index 3bd58ae..00d1cac 100644 --- a/go-backend/internal/http/handler/handler.go +++ b/go-backend/internal/http/handler/handler.go @@ -135,6 +135,10 @@ func (h *Handler) Register(mux *http.ServeMux) { mux.HandleFunc("/api/v1/tunnel/get", h.tunnelGet) mux.HandleFunc("/api/v1/tunnel/update", h.tunnelUpdate) mux.HandleFunc("/api/v1/tunnel/delete", h.tunnelDelete) + mux.HandleFunc("/api/v1/tunnel/delete-preview", h.tunnelDeletePreview) + mux.HandleFunc("/api/v1/tunnel/delete-with-forwards", h.tunnelDeleteWithForwards) + mux.HandleFunc("/api/v1/tunnel/batch-delete-preview", h.tunnelBatchDeletePreview) + mux.HandleFunc("/api/v1/tunnel/batch-delete-with-forwards", h.tunnelBatchDeleteWithForwards) mux.HandleFunc("/api/v1/tunnel/diagnose", h.tunnelDiagnose) mux.HandleFunc("/api/v1/tunnel/diagnose/stream", h.tunnelDiagnoseStream) mux.HandleFunc("/api/v1/tunnel/update-order", h.tunnelUpdateOrder) diff --git a/go-backend/internal/http/handler/tunnel_delete_resolution.go b/go-backend/internal/http/handler/tunnel_delete_resolution.go new file mode 100644 index 0000000..94fb22c --- /dev/null +++ b/go-backend/internal/http/handler/tunnel_delete_resolution.go @@ -0,0 +1,655 @@ +package handler + +import ( + "errors" + "fmt" + "net/http" + "strings" + "time" + + "go-backend/internal/http/response" +) + +const tunnelDeletePreviewSampleLimit = 5 + +const ( + tunnelDeleteActionReplace = "replace" + tunnelDeleteActionDeleteForwards = "delete_forwards" +) + +var ( + errInvalidTunnelDeleteTarget = errors.New("invalid tunnel delete target") +) + +type tunnelDeleteForwardPreviewItem struct { + ID int64 `json:"id"` + Name string `json:"name"` + UserID int64 `json:"userId"` + UserName string `json:"userName"` + InPort int `json:"inPort"` +} + +type tunnelDeletePreviewData struct { + TunnelID int64 `json:"tunnelId"` + TunnelName string `json:"tunnelName"` + ForwardCount int `json:"forwardCount"` + SampleForwards []tunnelDeleteForwardPreviewItem `json:"sampleForwards"` +} + +type tunnelBatchDeletePreviewData struct { + TunnelCount int `json:"tunnelCount"` + TotalForwardCount int `json:"totalForwardCount"` + Items []tunnelDeletePreviewData `json:"items"` +} + +type tunnelDeleteWithForwardsRequest struct { + ID int64 `json:"id"` + Action string `json:"action"` + TargetTunnelID int64 `json:"targetTunnelId"` +} + +type tunnelBatchDeleteWithForwardsRequest struct { + IDs []int64 `json:"ids"` + Action string `json:"action"` + TargetTunnelID int64 `json:"targetTunnelId"` +} + +type tunnelDeleteWithForwardsResult struct { + ForwardCount int `json:"forwardCount"` + MigratedCount int `json:"migratedCount"` + DeletedForwardCount int `json:"deletedForwardCount"` + PortAdjustedCount int `json:"portAdjustedCount"` + Warnings []string `json:"warnings,omitempty"` +} + +type tunnelBatchDeleteWithForwardsResult struct { + SuccessCount int `json:"successCount"` + FailCount int `json:"failCount"` + Failures []batchFailureDetail `json:"failures,omitempty"` + DeletedForwardCount int `json:"deletedForwardCount"` + MigratedCount int `json:"migratedCount"` + PortAdjustedCount int `json:"portAdjustedCount"` + Warnings []string `json:"warnings,omitempty"` +} + +type tunnelForwardMigrationPlan struct { + forward *forwardRecord + oldPorts []forwardPortRecord + targetTunnelID int64 + targetPort int + keptNodeIDs []int64 + removedNodeIDs []int64 + portAdjusted bool +} + +func (h *Handler) tunnelDeletePreview(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + + id := idFromBody(r, w) + if id <= 0 { + return + } + + preview, err := h.buildTunnelDeletePreview(id) + if err != nil { + if strings.Contains(err.Error(), "不存在") { + response.WriteJSON(w, response.ErrDefault(err.Error())) + return + } + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + + response.WriteJSON(w, response.OK(preview)) +} + +func (h *Handler) tunnelBatchDeletePreview(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + + var req struct { + IDs []int64 `json:"ids"` + } + if err := decodeJSON(r.Body, &req); err != nil || len(req.IDs) == 0 { + response.WriteJSON(w, response.ErrDefault("请求参数错误")) + return + } + + preview, err := h.buildTunnelBatchDeletePreview(req.IDs) + if err != nil { + if strings.Contains(err.Error(), "不存在") { + response.WriteJSON(w, response.ErrDefault(err.Error())) + return + } + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + + response.WriteJSON(w, response.OK(preview)) +} + +func (h *Handler) tunnelDeleteWithForwards(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + + var req tunnelDeleteWithForwardsRequest + if err := decodeJSON(r.Body, &req); err != nil || req.ID <= 0 { + response.WriteJSON(w, response.ErrDefault("请求参数错误")) + return + } + + action, err := normalizeTunnelDeleteAction(req.Action) + if err != nil { + response.WriteJSON(w, response.ErrDefault("请求参数错误")) + return + } + if action == tunnelDeleteActionReplace { + if _, _, authErr := userRoleFromRequest(r); authErr != nil { + response.WriteJSON(w, response.Err(401, "无效的token或token已过期")) + return + } + } + + result, failures, err := h.processTunnelDeleteWithForwards(req.ID, action, req.TargetTunnelID) + if err != nil { + if err == errInvalidTunnelDeleteTarget { + response.WriteJSON(w, response.ErrDefault("目标隧道不能为空")) + return + } + if strings.Contains(err.Error(), "目标隧道不能与当前隧道相同") || strings.Contains(err.Error(), "目标隧道不存在") || strings.Contains(err.Error(), "目标隧道已禁用") || strings.Contains(err.Error(), "隧道不存在") { + response.WriteJSON(w, response.ErrDefault(err.Error())) + return + } + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + if len(failures) > 0 { + response.WriteJSON(w, response.R{ + Code: -2, + Msg: "部分规则迁移失败", + TS: time.Now().UnixMilli(), + Data: batchOperationResult{SuccessCount: 0, FailCount: len(failures), Failures: failures}, + }) + return + } + + response.WriteJSON(w, response.OK(result)) +} + +func (h *Handler) tunnelBatchDeleteWithForwards(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + + var req tunnelBatchDeleteWithForwardsRequest + if err := decodeJSON(r.Body, &req); err != nil || len(req.IDs) == 0 { + response.WriteJSON(w, response.ErrDefault("请求参数错误")) + return + } + + action, err := normalizeTunnelDeleteAction(req.Action) + if err != nil { + response.WriteJSON(w, response.ErrDefault("请求参数错误")) + return + } + if action == tunnelDeleteActionReplace { + if _, _, authErr := userRoleFromRequest(r); authErr != nil { + response.WriteJSON(w, response.Err(401, "无效的token或token已过期")) + return + } + } + + normalizedIDs := normalizeTunnelIDs(req.IDs) + if len(normalizedIDs) == 0 { + response.WriteJSON(w, response.ErrDefault("请求参数错误")) + return + } + + if action == tunnelDeleteActionReplace { + if req.TargetTunnelID <= 0 { + response.WriteJSON(w, response.ErrDefault("目标隧道不能为空")) + return + } + for _, id := range normalizedIDs { + if id == req.TargetTunnelID { + response.WriteJSON(w, response.ErrDefault("目标隧道不能包含在删除列表中")) + return + } + } + } + + result := tunnelBatchDeleteWithForwardsResult{} + for _, tunnelID := range normalizedIDs { + tunnelName, _ := h.repo.GetTunnelName(tunnelID) + singleResult, failures, processErr := h.processTunnelDeleteWithForwards(tunnelID, action, req.TargetTunnelID) + if processErr != nil { + result.FailCount++ + result.Failures = appendBatchFailure(result.Failures, tunnelID, tunnelName, processErr) + continue + } + if len(failures) > 0 { + result.FailCount++ + result.Failures = appendBatchFailureReason( + result.Failures, + tunnelID, + tunnelName, + summarizeTunnelDeleteRuleFailures(failures), + ) + continue + } + + result.SuccessCount++ + result.DeletedForwardCount += singleResult.DeletedForwardCount + result.MigratedCount += singleResult.MigratedCount + result.PortAdjustedCount += singleResult.PortAdjustedCount + if len(singleResult.Warnings) > 0 { + result.Warnings = append(result.Warnings, singleResult.Warnings...) + } + } + + response.WriteJSON(w, response.OK(result)) +} + +func (h *Handler) buildTunnelDeletePreview(tunnelID int64) (*tunnelDeletePreviewData, error) { + if _, err := h.getTunnelRecord(tunnelID); err != nil { + return nil, err + } + + tunnelName, err := h.repo.GetTunnelName(tunnelID) + if err != nil { + return nil, err + } + + forwards, err := h.listForwardsByTunnel(tunnelID) + if err != nil { + return nil, err + } + + samples := make([]tunnelDeleteForwardPreviewItem, 0, minInt(len(forwards), tunnelDeletePreviewSampleLimit)) + for i, forward := range forwards { + if i >= tunnelDeletePreviewSampleLimit { + break + } + ports, portsErr := h.listForwardPorts(forward.ID) + if portsErr != nil { + return nil, portsErr + } + inPort := 0 + if len(ports) > 0 { + inPort = ports[0].Port + } + samples = append(samples, tunnelDeleteForwardPreviewItem{ + ID: forward.ID, + Name: forward.Name, + UserID: forward.UserID, + UserName: forward.UserName, + InPort: inPort, + }) + } + + return &tunnelDeletePreviewData{ + TunnelID: tunnelID, + TunnelName: tunnelName, + ForwardCount: len(forwards), + SampleForwards: samples, + }, nil +} + +func (h *Handler) buildTunnelBatchDeletePreview(ids []int64) (*tunnelBatchDeletePreviewData, error) { + normalizedIDs := normalizeTunnelIDs(ids) + items := make([]tunnelDeletePreviewData, 0, len(normalizedIDs)) + totalForwardCount := 0 + for _, id := range normalizedIDs { + preview, err := h.buildTunnelDeletePreview(id) + if err != nil { + return nil, err + } + items = append(items, *preview) + totalForwardCount += preview.ForwardCount + } + return &tunnelBatchDeletePreviewData{ + TunnelCount: len(items), + TotalForwardCount: totalForwardCount, + Items: items, + }, nil +} + +func normalizeTunnelDeleteAction(action string) (string, error) { + normalized := strings.TrimSpace(action) + if normalized == "" { + return tunnelDeleteActionDeleteForwards, nil + } + if normalized != tunnelDeleteActionReplace && normalized != tunnelDeleteActionDeleteForwards { + return "", errors.New("invalid tunnel delete action") + } + return normalized, nil +} + +func normalizeTunnelIDs(ids []int64) []int64 { + seen := make(map[int64]struct{}, len(ids)) + out := make([]int64, 0, len(ids)) + for _, id := range ids { + if id <= 0 { + continue + } + if _, exists := seen[id]; exists { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +func summarizeTunnelDeleteRuleFailures(failures []batchFailureDetail) string { + if len(failures) == 0 { + return "未知错误" + } + parts := make([]string, 0, minInt(len(failures), 3)) + for i, failure := range failures { + if i >= 3 { + break + } + name := strings.TrimSpace(failure.Name) + if name == "" { + name = fmt.Sprintf("规则 #%d", failure.ID) + } + parts = append(parts, fmt.Sprintf("%s: %s", name, strings.TrimSpace(failure.Reason))) + } + if len(failures) > 3 { + parts = append(parts, fmt.Sprintf("另有 %d 条规则失败", len(failures)-3)) + } + return strings.Join(parts, ";") +} + +func (h *Handler) processTunnelDeleteWithForwards(tunnelID int64, action string, targetTunnelID int64) (tunnelDeleteWithForwardsResult, []batchFailureDetail, error) { + preview, err := h.buildTunnelDeletePreview(tunnelID) + if err != nil { + return tunnelDeleteWithForwardsResult{}, nil, err + } + + result := tunnelDeleteWithForwardsResult{ForwardCount: preview.ForwardCount} + if preview.ForwardCount == 0 { + if err := h.deleteTunnelAndCleanup(tunnelID); err != nil { + return tunnelDeleteWithForwardsResult{}, nil, err + } + return result, nil, nil + } + + if action == tunnelDeleteActionDeleteForwards { + result.DeletedForwardCount = preview.ForwardCount + if err := h.deleteTunnelAndCleanup(tunnelID); err != nil { + return tunnelDeleteWithForwardsResult{}, nil, err + } + return result, nil, nil + } + + if targetTunnelID <= 0 { + return tunnelDeleteWithForwardsResult{}, nil, errInvalidTunnelDeleteTarget + } + if targetTunnelID == tunnelID { + return tunnelDeleteWithForwardsResult{}, nil, errors.New("目标隧道不能与当前隧道相同") + } + return h.processTunnelDeleteReplaceAction(tunnelID, targetTunnelID, result) +} + +func (h *Handler) processTunnelDeleteReplaceAction(tunnelID, targetTunnelID int64, result tunnelDeleteWithForwardsResult) (tunnelDeleteWithForwardsResult, []batchFailureDetail, error) { + targetTunnel, err := h.getTunnelRecord(targetTunnelID) + if err != nil { + return tunnelDeleteWithForwardsResult{}, nil, errors.New("目标隧道不存在") + } + if targetTunnel.Status != 1 { + return tunnelDeleteWithForwardsResult{}, nil, errors.New("目标隧道已禁用") + } + + plans, failures, err := h.planTunnelDeleteForwardMigrations(tunnelID, targetTunnelID) + if err != nil { + return tunnelDeleteWithForwardsResult{}, nil, err + } + if len(failures) > 0 { + return tunnelDeleteWithForwardsResult{}, failures, nil + } + + portAdjustedCount := 0 + warnings, execErr, execFailure := h.executeTunnelDeleteForwardMigrations(plans) + for _, plan := range plans { + if plan.portAdjusted { + portAdjustedCount++ + } + } + if execErr != nil { + failures = append(failures, execFailure) + return tunnelDeleteWithForwardsResult{}, failures, nil + } + + if err := h.deleteTunnelAndCleanup(tunnelID); err != nil { + h.rollbackTunnelForwardMigrationPlans(plans) + _ = h.redeployTunnelAndForwards(tunnelID) + return tunnelDeleteWithForwardsResult{}, nil, err + } + + result.MigratedCount = len(plans) + result.PortAdjustedCount = portAdjustedCount + if len(warnings) > 0 { + result.Warnings = warnings + } + return result, nil, nil +} + +func (h *Handler) planTunnelDeleteForwardMigrations(sourceTunnelID, targetTunnelID int64) ([]tunnelForwardMigrationPlan, []batchFailureDetail, error) { + forwards, err := h.listForwardsByTunnel(sourceTunnelID) + if err != nil { + return nil, nil, err + } + + entryNodes, err := h.tunnelEntryNodeIDs(targetTunnelID) + if err != nil { + return nil, nil, err + } + if len(entryNodes) == 0 { + return nil, nil, errors.New("目标隧道缺少入口节点") + } + + plans := make([]tunnelForwardMigrationPlan, 0, len(forwards)) + failures := make([]batchFailureDetail, 0) + reservedPorts := make(map[int64]map[int]bool) + + for _, forward := range forwards { + plan, planErr := h.planSingleTunnelDeleteForwardMigration(&forward, targetTunnelID, entryNodes, reservedPorts) + if planErr != nil { + failures = appendBatchFailure(failures, forward.ID, forward.Name, planErr) + continue + } + plans = append(plans, plan) + } + + return plans, failures, nil +} + +func (h *Handler) planSingleTunnelDeleteForwardMigration(forward *forwardRecord, targetTunnelID int64, targetEntryNodes []int64, reservedPorts map[int64]map[int]bool) (tunnelForwardMigrationPlan, error) { + if forward == nil { + return tunnelForwardMigrationPlan{}, errors.New("转发不存在") + } + + oldPorts, err := h.listForwardPorts(forward.ID) + if err != nil { + return tunnelForwardMigrationPlan{}, err + } + if len(oldPorts) == 0 { + return tunnelForwardMigrationPlan{}, errors.New("转发入口端口不存在") + } + + minPort := h.repo.GetMinForwardPort(forward.ID) + targetPort := 0 + if minPort.Valid { + targetPort = int(minPort.Int64) + } + if targetPort <= 0 { + targetPort = h.pickTunnelPort(targetTunnelID) + } + if targetPort <= 0 { + targetPort = 10000 + } + + hasCustomInIP := false + for _, oldPort := range oldPorts { + if strings.TrimSpace(oldPort.InIP) != "" { + hasCustomInIP = true + break + } + } + if hasCustomInIP && len(targetEntryNodes) > 1 { + return tunnelForwardMigrationPlan{}, errors.New("多入口隧道的转发不支持保留自定义监听IP,请先手动调整该规则") + } + + for _, nodeID := range targetEntryNodes { + node, nodeErr := h.getNodeRecord(nodeID) + if nodeErr != nil { + return tunnelForwardMigrationPlan{}, nodeErr + } + if err := validateRemoteNodePort(node, targetPort); err != nil { + return tunnelForwardMigrationPlan{}, err + } + if err := validateLocalNodePort(node, targetPort); err != nil { + return tunnelForwardMigrationPlan{}, err + } + if err := h.validateForwardPortAvailability(node, targetPort, forward.ID); err != nil { + return tunnelForwardMigrationPlan{}, err + } + if reservedOnNode, ok := reservedPorts[nodeID]; ok && reservedOnNode[targetPort] { + return tunnelForwardMigrationPlan{}, fmt.Errorf("目标隧道入口节点端口 %d 已被本次迁移中的其他规则占用", targetPort) + } + } + + for _, nodeID := range targetEntryNodes { + reservedOnNode := reservedPorts[nodeID] + if reservedOnNode == nil { + reservedOnNode = make(map[int]bool) + reservedPorts[nodeID] = reservedOnNode + } + reservedOnNode[targetPort] = true + } + + oldNodeIDs := forwardPortNodeIDs(oldPorts) + newNodeIDs := uniqueInt64s(targetEntryNodes) + removedNodeIDs := diffInt64s(oldNodeIDs, newNodeIDs) + keptNodeIDs := diffInt64s(oldNodeIDs, removedNodeIDs) + + previousPort := 0 + if len(oldPorts) > 0 { + previousPort = oldPorts[0].Port + } + + return tunnelForwardMigrationPlan{ + forward: forward, + oldPorts: oldPorts, + targetTunnelID: targetTunnelID, + targetPort: targetPort, + keptNodeIDs: keptNodeIDs, + removedNodeIDs: removedNodeIDs, + portAdjusted: previousPort > 0 && previousPort != targetPort, + }, nil +} + +func (h *Handler) executeTunnelDeleteForwardMigrations(plans []tunnelForwardMigrationPlan) ([]string, error, batchFailureDetail) { + warnings := make([]string, 0) + completed := make([]tunnelForwardMigrationPlan, 0, len(plans)) + + for _, plan := range plans { + migrationWarnings, err := h.applyTunnelDeleteForwardMigration(plan) + if err != nil { + h.rollbackTunnelForwardMigrationPlans(completed) + return warnings, err, batchFailureDetail{ID: plan.forward.ID, Name: plan.forward.Name, Reason: normalizeBatchFailureReason(errString(err))} + } + warnings = append(warnings, migrationWarnings...) + completed = append(completed, plan) + } + + return warnings, nil, batchFailureDetail{} +} + +func (h *Handler) applyTunnelDeleteForwardMigration(plan tunnelForwardMigrationPlan) ([]string, error) { + if plan.forward == nil { + return nil, errors.New("转发不存在") + } + + if err := h.repo.UpdateForwardTunnel(plan.forward.ID, plan.targetTunnelID, time.Now().UnixMilli()); err != nil { + return nil, err + } + if err := h.replaceForwardPorts(plan.forward.ID, plan.targetTunnelID, plan.targetPort, ""); err != nil { + h.rollbackForwardMutation(plan.forward, plan.oldPorts) + return nil, err + } + + updatedForward, err := h.getForwardRecord(plan.forward.ID) + if err != nil { + h.rollbackForwardMutation(plan.forward, plan.oldPorts) + return nil, err + } + + warnings := make([]string, 0) + if len(plan.keptNodeIDs) > 0 { + for _, nodeID := range plan.keptNodeIDs { + if delErr := h.deleteForwardServicesOnNodeBatch(plan.forward, nodeID); delErr != nil { + nodeLabel := fmt.Sprintf("%d", nodeID) + if n, nErr := h.getNodeRecord(nodeID); nErr == nil && n != nil && strings.TrimSpace(n.Name) != "" { + nodeLabel = strings.TrimSpace(n.Name) + } + warnings = append(warnings, fmt.Sprintf("节点 %s 清理旧转发监听失败: %v", nodeLabel, delErr)) + } + } + time.Sleep(tunnelServiceBindRetryDelay) + } + + syncWarnings, err := h.syncForwardServicesWithWarnings(updatedForward, "UpdateService", true) + if err != nil { + h.rollbackForwardMutation(plan.forward, plan.oldPorts) + return nil, err + } + warnings = append(warnings, syncWarnings...) + + if len(plan.removedNodeIDs) > 0 { + for _, nodeID := range plan.removedNodeIDs { + if delErr := h.deleteForwardServicesOnNodeBatch(plan.forward, nodeID); delErr != nil { + nodeLabel := fmt.Sprintf("%d", nodeID) + if n, nErr := h.getNodeRecord(nodeID); nErr == nil && n != nil && strings.TrimSpace(n.Name) != "" { + nodeLabel = strings.TrimSpace(n.Name) + } + warnings = append(warnings, fmt.Sprintf("节点 %s 清理旧隧道残留服务失败: %v", nodeLabel, delErr)) + } + } + } + + return warnings, nil +} + +func (h *Handler) rollbackTunnelForwardMigrationPlans(plans []tunnelForwardMigrationPlan) { + for i := len(plans) - 1; i >= 0; i-- { + plan := plans[i] + h.rollbackForwardMutation(plan.forward, plan.oldPorts) + } +} + +func (h *Handler) deleteTunnelAndCleanup(tunnelID int64) error { + h.cleanupTunnelRuntime(tunnelID) + h.cleanupFederationRuntime(tunnelID) + if err := h.deleteTunnelByID(tunnelID); err != nil { + return err + } + return nil +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/go-backend/tests/contract/tunnel_delete_resolution_contract_test.go b/go-backend/tests/contract/tunnel_delete_resolution_contract_test.go new file mode 100644 index 0000000..2d086fa --- /dev/null +++ b/go-backend/tests/contract/tunnel_delete_resolution_contract_test.go @@ -0,0 +1,235 @@ +package contract_test + +import ( + "testing" + "time" + + "go-backend/internal/http/response" + storeRepo "go-backend/internal/store/repo" +) + +func TestTunnelDeletePreviewIncludesDependentRulesContract(t *testing.T) { + secret := "contract-jwt-secret" + router, repo := setupContractRouter(t, secret) + adminToken := mustAdminToken(t, secret) + now := time.Now().UnixMilli() + + sourceTunnelID, sourceNodeID := seedTunnelDeleteTunnelWithNode(t, repo, now, "preview-source-tunnel", "preview-source-node", "21000-21010") + seedTunnelDeleteForward(t, repo, now, sourceTunnelID, sourceNodeID, "preview-forward", 21001) + + out := requestContractEnvelope(t, router, adminToken, "/api/v1/tunnel/delete-preview", map[string]interface{}{"id": sourceTunnelID}) + if out.Code != 0 { + t.Fatalf("expected success, got code=%d msg=%q", out.Code, out.Msg) + } + + data, ok := out.Data.(map[string]interface{}) + if !ok { + t.Fatalf("expected preview data object, got %T", out.Data) + } + if contractValueAsInt64(data["tunnelId"]) != sourceTunnelID { + t.Fatalf("unexpected tunnelId: %#v", data["tunnelId"]) + } + if contractValueAsInt64(data["forwardCount"]) != 1 { + t.Fatalf("expected forwardCount=1, got %#v", data["forwardCount"]) + } + + samples, ok := data["sampleForwards"].([]interface{}) + if !ok || len(samples) != 1 { + t.Fatalf("expected one sample forward, got %#v", data["sampleForwards"]) + } + first, ok := samples[0].(map[string]interface{}) + if !ok { + t.Fatalf("expected sample object, got %T", samples[0]) + } + if first["name"] != "preview-forward" { + t.Fatalf("unexpected sample name: %#v", first["name"]) + } + if contractValueAsInt64(first["inPort"]) != 21001 { + t.Fatalf("unexpected sample inPort: %#v", first["inPort"]) + } +} + +func TestTunnelDeleteWithForwardsDeleteActionRemovesTunnelAndRulesContract(t *testing.T) { + secret := "contract-jwt-secret" + router, repo := setupContractRouter(t, secret) + adminToken := mustAdminToken(t, secret) + now := time.Now().UnixMilli() + + sourceTunnelID, sourceNodeID := seedTunnelDeleteTunnelWithNode(t, repo, now, "delete-source-tunnel", "delete-source-node", "22000-22010") + forwardID := seedTunnelDeleteForward(t, repo, now, sourceTunnelID, sourceNodeID, "delete-forward", 22001) + + out := requestContractEnvelope(t, router, adminToken, "/api/v1/tunnel/delete-with-forwards", map[string]interface{}{ + "id": sourceTunnelID, + "action": "delete_forwards", + }) + if out.Code != 0 { + t.Fatalf("expected success, got code=%d msg=%q", out.Code, out.Msg) + } + + if count := mustQueryInt(t, repo, `SELECT COUNT(1) FROM tunnel WHERE id = ?`, sourceTunnelID); count != 0 { + t.Fatalf("expected tunnel deleted, got count=%d", count) + } + if count := mustQueryInt(t, repo, `SELECT COUNT(1) FROM forward WHERE id = ?`, forwardID); count != 0 { + t.Fatalf("expected forward deleted, got count=%d", count) + } + if count := mustQueryInt(t, repo, `SELECT COUNT(1) FROM forward_port WHERE forward_id = ?`, forwardID); count != 0 { + t.Fatalf("expected forward ports deleted, got count=%d", count) + } +} + +func TestTunnelDeleteWithForwardsReplaceReturnsFailureDetailsContract(t *testing.T) { + secret := "contract-jwt-secret" + router, repo := setupContractRouter(t, secret) + adminToken := mustAdminToken(t, secret) + now := time.Now().UnixMilli() + + sourceTunnelID, sourceNodeID := seedTunnelDeleteTunnelWithNode(t, repo, now, "replace-source-tunnel", "replace-source-node", "23000-23010") + forwardID := seedTunnelDeleteForward(t, repo, now, sourceTunnelID, sourceNodeID, "replace-forward", 23001) + targetTunnelID, targetNodeID := seedTunnelDeleteTunnelWithNode(t, repo, now, "replace-target-tunnel", "replace-target-node", "23000-23010") + seedTunnelDeleteForward(t, repo, now, targetTunnelID, targetNodeID, "occupied-forward", 23001) + + out := requestContractEnvelope(t, router, adminToken, "/api/v1/tunnel/delete-with-forwards", map[string]interface{}{ + "id": sourceTunnelID, + "action": "replace", + "targetTunnelId": targetTunnelID, + }) + if out.Code != -2 { + t.Fatalf("expected failure code -2, got code=%d msg=%q", out.Code, out.Msg) + } + + result := mustTunnelDeleteFailureResult(t, out) + if contractValueAsInt64(result["failCount"]) != 1 { + t.Fatalf("expected failCount=1, got %#v", result["failCount"]) + } + assertBatchFailureNameAndReason(t, result, "replace-forward", "节点 replace-target-node 端口 23001 已被其他转发占用") + + if count := mustQueryInt(t, repo, `SELECT COUNT(1) FROM tunnel WHERE id = ?`, sourceTunnelID); count != 1 { + t.Fatalf("expected source tunnel kept, got count=%d", count) + } + if tunnelAfter := mustQueryInt64(t, repo, `SELECT tunnel_id FROM forward WHERE id = ?`, forwardID); tunnelAfter != sourceTunnelID { + t.Fatalf("expected forward tunnel unchanged, got %d", tunnelAfter) + } +} + +func TestTunnelBatchDeletePreviewIncludesTotalsContract(t *testing.T) { + secret := "contract-jwt-secret" + router, repo := setupContractRouter(t, secret) + adminToken := mustAdminToken(t, secret) + now := time.Now().UnixMilli() + + tunnelA, nodeA := seedTunnelDeleteTunnelWithNode(t, repo, now, "batch-preview-a", "batch-preview-node-a", "24000-24010") + tunnelB, _ := seedTunnelDeleteTunnelWithNode(t, repo, now, "batch-preview-b", "batch-preview-node-b", "24100-24110") + seedTunnelDeleteForward(t, repo, now, tunnelA, nodeA, "batch-preview-forward", 24001) + + out := requestContractEnvelope(t, router, adminToken, "/api/v1/tunnel/batch-delete-preview", map[string]interface{}{ + "ids": []int64{tunnelA, tunnelB}, + }) + if out.Code != 0 { + t.Fatalf("expected success, got code=%d msg=%q", out.Code, out.Msg) + } + data, ok := out.Data.(map[string]interface{}) + if !ok { + t.Fatalf("expected preview object, got %T", out.Data) + } + if contractValueAsInt64(data["tunnelCount"]) != 2 { + t.Fatalf("expected tunnelCount=2, got %#v", data["tunnelCount"]) + } + if contractValueAsInt64(data["totalForwardCount"]) != 1 { + t.Fatalf("expected totalForwardCount=1, got %#v", data["totalForwardCount"]) + } +} + +func TestTunnelBatchDeleteWithForwardsReturnsTunnelLevelFailuresContract(t *testing.T) { + secret := "contract-jwt-secret" + router, repo := setupContractRouter(t, secret) + adminToken := mustAdminToken(t, secret) + now := time.Now().UnixMilli() + + sourceTunnelA, _ := seedTunnelDeleteTunnelWithNode(t, repo, now, "batch-replace-source-a", "batch-replace-source-node-a", "25000-25010") + sourceTunnelB, sourceNodeB := seedTunnelDeleteTunnelWithNode(t, repo, now, "batch-replace-source-b", "batch-replace-source-node-b", "25100-25110") + targetTunnelID, targetNodeID := seedTunnelDeleteTunnelWithNode(t, repo, now, "batch-replace-target", "batch-replace-target-node", "25000-25010") + + seedTunnelDeleteForward(t, repo, now, sourceTunnelB, sourceNodeB, "batch-replace-forward-b", 25002) + seedTunnelDeleteForward(t, repo, now, targetTunnelID, targetNodeID, "batch-replace-occupied", 25002) + + out := requestContractEnvelope(t, router, adminToken, "/api/v1/tunnel/batch-delete-with-forwards", map[string]interface{}{ + "ids": []int64{sourceTunnelA, sourceTunnelB}, + "action": "replace", + "targetTunnelId": targetTunnelID, + }) + if out.Code != 0 { + t.Fatalf("expected success envelope, got code=%d msg=%q", out.Code, out.Msg) + } + + result := mustTunnelDeleteFailureResult(t, out) + if contractValueAsInt64(result["successCount"]) != 1 { + t.Fatalf("expected successCount=1, got %#v", result["successCount"]) + } + if contractValueAsInt64(result["failCount"]) != 1 { + t.Fatalf("expected failCount=1, got %#v", result["failCount"]) + } + assertBatchFailureNameAndReason(t, result, "batch-replace-source-b", "batch-replace-forward-b: 节点 batch-replace-target-node 端口 25002 已被其他转发占用") + + if count := mustQueryInt(t, repo, `SELECT COUNT(1) FROM tunnel WHERE id = ?`, sourceTunnelA); count != 0 { + t.Fatalf("expected source tunnel A deleted, got count=%d", count) + } + if count := mustQueryInt(t, repo, `SELECT COUNT(1) FROM tunnel WHERE id = ?`, sourceTunnelB); count != 1 { + t.Fatalf("expected source tunnel B kept, got count=%d", count) + } +} + +func seedTunnelDeleteTunnelWithNode(t *testing.T, repo *storeRepo.Repository, now int64, tunnelName, nodeName, portRange string) (int64, int64) { + t.Helper() + + if err := repo.DB().Exec(` + INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, status, created_time, updated_time, in_ip, inx, ip_preference) + VALUES(?, 1.0, 1, 'tls', 1, 1, ?, ?, NULL, 0, '') + `, tunnelName, now, now).Error; err != nil { + t.Fatalf("insert tunnel %s: %v", tunnelName, err) + } + tunnelID := mustLastInsertID(t, repo, tunnelName) + + 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(?, ?, '10.0.0.1', '10.0.0.1', '', ?, '', 'v1', 1, 1, 1, ?, ?, 1, '[::]', '[::]', 0) + `, nodeName, nodeName+"-secret", portRange, now, now).Error; err != nil { + t.Fatalf("insert node %s: %v", nodeName, err) + } + nodeID := mustLastInsertID(t, repo, nodeName) + + if err := repo.DB().Exec(` + INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) + VALUES(?, 1, ?, 0, 'round', 1, 'tls') + `, tunnelID, nodeID).Error; err != nil { + t.Fatalf("insert chain_tunnel for %s: %v", tunnelName, err) + } + + return tunnelID, nodeID +} + +func seedTunnelDeleteForward(t *testing.T, repo *storeRepo.Repository, now int64, tunnelID, nodeID int64, forwardName string, port int) int64 { + t.Helper() + + if err := repo.DB().Exec(` + INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx) + VALUES(2, 'contract-user', ?, ?, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0) + `, forwardName, tunnelID, now, now).Error; err != nil { + t.Fatalf("insert forward %s: %v", forwardName, err) + } + forwardID := mustLastInsertID(t, repo, forwardName) + + if err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, port).Error; err != nil { + t.Fatalf("insert forward_port for %s: %v", forwardName, err) + } + + return forwardID +} + +func mustTunnelDeleteFailureResult(t *testing.T, out response.R) map[string]interface{} { + t.Helper() + result, ok := out.Data.(map[string]interface{}) + if !ok { + t.Fatalf("expected result object, got %T", out.Data) + } + return result +} diff --git a/plans/044-tunnel-delete-rule-resolution.md b/plans/044-tunnel-delete-rule-resolution.md new file mode 100644 index 0000000..68aa47b --- /dev/null +++ b/plans/044-tunnel-delete-rule-resolution.md @@ -0,0 +1,183 @@ +# 044 - 隧道删除时的规则依赖处理设计 + +## Goal +- 删除隧道前识别关联规则,避免默认级联误删。 +- 在隧道页提供两种处理方式:迁移规则到其他隧道,或连同规则一起删除。 +- 第一阶段只覆盖单条隧道删除,先把核心交互和执行链路做稳。 + +## Checklist +- [x] 复核当前 `tunnel.tsx` 删除交互与 `DeleteTunnelCascade` 行为。 +- [x] 梳理可复用的规则换隧道能力(`forward/batch-change-tunnel`)。 +- [x] 产出前端交互、接口与执行链路设计。 +- [x] 明确第一阶段范围、异常处理与回滚要求。 + +## Implementation Checklist +- [x] 新增后端删除预检与带规则处理的删除接口。 +- [x] 在后端补齐规则迁移/失败明细/回滚链路。 +- [x] 接入前端隧道删除预检、替换/删除规则弹窗与结果展示。 +- [x] 运行定向测试并记录结果。 + +## Current State +- `vite-frontend/src/pages/tunnel.tsx` 的单条删除当前直接调用 `/tunnel/delete`,没有前置依赖检查。 +- `go-backend/internal/store/repo/repository_mutations.go` 里的 `DeleteTunnelCascade` 会直接删除该隧道下的 `forward`、`forward_port`、`user_tunnel`、`chain_tunnel` 等关联数据。 +- `vite-frontend/src/pages/forward.tsx` 已经有“批量换隧道”交互和 `/forward/batch-change-tunnel`,但如果前端直接串联“先换规则、再删隧道”,中间任一步失败都会留下半完成状态,不适合作为删除流程的最终方案。 + +## Proposed UX +- 点击隧道删除按钮后,前端先请求“删除预检”接口,不直接进入最终确认。 +- 若关联规则数为 `0`,继续沿用当前简单确认弹框。 +- 若关联规则数大于 `0`,改为展示“处理关联规则”弹框: + - 顶部 `Alert` 提示:`隧道 "A" 正被 12 条规则使用`。 + - 展示前 5 条规则摘要:规则名、所属用户、入口端口;剩余规则用“还有 N 条未展开”提示即可。 + - 提供 `RadioGroup` 两个动作: + - `替换到其他隧道`(推荐,默认) + - `删除这些规则` + - 选择“替换到其他隧道”时显示 `Select`: + - 数据源直接复用当前页已经加载的 `tunnels`。 + - 仅显示 `status === 1` 且 `id !== 当前隧道` 的选项。 + - 若没有可选隧道,则禁用该选项并自动回退到“删除这些规则”。 + - 确认按钮文案动态变化: + - `替换规则并删除隧道` + - `删除规则并删除隧道` +- 提交时展示 loading 状态;成功后关闭弹框、刷新隧道列表并 toast 成功。 +- 如果后端返回规则级失败明细,前端复用现有 `BatchActionResultModal` 展示失败项,而不是只给一个通用 toast。 + +## API Design +### 1. 删除预检 +`POST /api/v1/tunnel/delete-preview` + +Request: +```json +{ "id": 12 } +``` + +Response: +```json +{ + "code": 0, + "msg": "", + "data": { + "tunnelId": 12, + "tunnelName": "HK-Entry", + "forwardCount": 12, + "sampleForwards": [ + { + "id": 101, + "name": "web-1", + "userId": 9, + "userName": "alice", + "inPort": 443 + } + ] + } +} +``` + +- `sampleForwards` 只需要返回前 5 条,前端用 `forwardCount` 决定是否显示“更多”提示。 +- 预检只描述现状,不负责最终授权或一致性保证;真正提交删除时,后端必须再校验一次。 + +### 2. 带规则处理的删除 +`POST /api/v1/tunnel/delete-with-forwards` + +Request: +```json +{ + "id": 12, + "action": "replace", + "targetTunnelId": 18 +} +``` + +或: + +```json +{ + "id": 12, + "action": "delete_forwards" +} +``` + +Success response: +```json +{ + "code": 0, + "msg": "", + "data": { + "forwardCount": 12, + "migratedCount": 12, + "deletedForwardCount": 0, + "portAdjustedCount": 2 + } +} +``` + +Failure response: +```json +{ + "code": -2, + "msg": "部分规则迁移失败", + "data": { + "successCount": 9, + "failCount": 3, + "failures": [ + { + "id": 101, + "name": "web-1", + "reason": "目标隧道入口节点端口 443 已占用" + } + ] + } +} +``` + +- 保持现有 `/tunnel/delete` 不动,兼容旧调用和当前批量删除逻辑。 +- `vite-frontend/src/pages/tunnel.tsx` 的单条删除新流程全部走新接口。 + +## Backend Execution Strategy +- `delete-preview` + - 校验 tunnel 是否存在。 + - 查询 `forward.tunnel_id = 当前隧道` 的数量和前 5 条摘要。 +- `delete-with-forwards` + - 再次查询依赖规则,避免 preview 与提交之间状态变化导致误判。 + - `action = delete_forwards` + - 直接复用当前级联删除逻辑。 + - `action = replace` + - 校验 `targetTunnelId`:存在、启用、且不等于当前隧道。 + - 先对全部关联规则做一次前置校验:目标隧道入口节点、端口范围、端口冲突、监听 IP 约束。 + - 全部校验通过后,再逐条迁移规则并同步运行时。 + - 任一规则迁移失败时,返回失败明细并中止删除;对已迁移规则做回滚,保证用户感知为“要么都成功,要么都不删”。 + - 规则处理完成后,再删除隧道本身及非 `forward` 关联数据。 + +## Frontend Implementation Notes +- `vite-frontend/src/pages/tunnel.tsx` + - 新增删除预检 loading、依赖摘要、处理动作等 state。 + - 复用现有 `Modal`,根据 preview 结果切换普通确认视图和依赖处理视图。 + - 复用 `Select`、`RadioGroup`、`Alert`。 + - 成功后继续沿用当前 `setTunnels`、`setTunnelOrder`、`setSelectedIds` 清理逻辑。 +- `vite-frontend/src/api/index.ts` + - 新增 `previewTunnelDelete`。 + - 新增 `deleteTunnelWithForwards`。 +- 删除失败且返回批量明细时,复用 `BatchActionResultModal`,避免具体失败规则原因被 toast 吞掉。 + +## Scope Decision +- 第一阶段只改“隧道页单条删除”。 +- `批量删除隧道` 先保持现有“级联删除规则”行为和提示文案不变。 +- 如果第一阶段确认体验和后端回滚链路都稳定,再补第二阶段:批量删除时按每条隧道分别预检和处理。 + +## Edge Cases +- 没有可替换隧道:只允许“删除这些规则”。 +- 目标隧道在提交前被禁用或删除:后端返回明确错误,前端保留当前弹框和用户选择。 +- preview 时没有规则、提交时新建了规则:以后端最终校验为准,返回需要重新处理的提示。 +- 迁移时若原入口端口在目标隧道不可用,沿用现有“换隧道”语义:优先保留原端口,不可用时自动分配可用端口,并通过 `portAdjustedCount` 给前端一个非阻塞提示。 +- 第一阶段不自动补发目标隧道的 `user_tunnel` 授权,先与现有“批量换隧道”语义保持一致;如果后续需要让用户也获得目标隧道的新增规则权限,再单独评估自动补授权。 + +## Implementation Notes +- 后端新增 `/api/v1/tunnel/delete-preview` 和 `/api/v1/tunnel/delete-with-forwards`,单条隧道删除改为先预检再执行。 +- 后端新增 `/api/v1/tunnel/batch-delete-preview` 和 `/api/v1/tunnel/batch-delete-with-forwards`,批量删除支持统一预检并选择“批量替换规则”或“批量删除规则”。 +- 规则替换删除在后端先做端口/节点占用预检,再执行逐条迁移;执行阶段若任一规则失败,会回滚已迁移规则,并把失败明细返回给前端弹窗展示。 +- 前端 `tunnel.tsx` 删除弹窗已支持三种状态:预检中、普通删除确认、有依赖规则时的“替换/删除规则”决策视图。 +- 批量删除弹窗现在会汇总每条选中隧道的依赖规则数量,并在批量替换失败时按“隧道级”返回失败原因,避免整批操作信息丢失。 + +## Verification +- `cd go-backend && go test ./internal/http/handler/...` +- `cd go-backend && go test ./tests/contract/... -run 'TestTunnelDeletePreviewIncludesDependentRulesContract|TestTunnelDeleteWithForwardsDeleteActionRemovesTunnelAndRulesContract|TestTunnelDeleteWithForwardsReplaceReturnsFailureDetailsContract'` +- `cd vite-frontend && npm run build` diff --git a/vite-frontend/src/api/index.ts b/vite-frontend/src/api/index.ts index 3eeed4c..71a77b0 100644 --- a/vite-frontend/src/api/index.ts +++ b/vite-frontend/src/api/index.ts @@ -6,6 +6,10 @@ import type { NodeReleaseApiItem, NodeApiItem, SpeedLimitApiItem, + TunnelBatchDeletePreviewApiData, + TunnelBatchDeleteWithForwardsApiData, + TunnelDeletePreviewApiData, + TunnelDeleteWithForwardsApiData, TunnelDiagnosisApiData, TunnelGroupApiItem, UserApiItem, @@ -122,6 +126,30 @@ export const updateTunnel = (data: TunnelMutationPayload) => Network.post("/tunnel/update", data); export const deleteTunnel = (id: number) => Network.post("/tunnel/delete", { id }); +export const previewTunnelDelete = (id: number) => + Network.post("/tunnel/delete-preview", { id }); +export const deleteTunnelWithForwards = (data: { + id: number; + action: "replace" | "delete_forwards"; + targetTunnelId?: number; +}) => + Network.post( + "/tunnel/delete-with-forwards", + data, + ); +export const previewBatchTunnelDelete = (ids: number[]) => + Network.post("/tunnel/batch-delete-preview", { + ids, + }); +export const batchDeleteTunnelsWithForwards = (data: { + ids: number[]; + action: "replace" | "delete_forwards"; + targetTunnelId?: number; +}) => + Network.post( + "/tunnel/batch-delete-with-forwards", + data, + ); export const diagnoseTunnel = (tunnelId: number) => Network.post( "/tunnel/diagnose", diff --git a/vite-frontend/src/api/types.ts b/vite-frontend/src/api/types.ts index 2021ae0..b2b2f22 100644 --- a/vite-frontend/src/api/types.ts +++ b/vite-frontend/src/api/types.ts @@ -222,6 +222,50 @@ export interface BatchOperationFailure { [key: string]: unknown; } +export interface TunnelDeletePreviewForwardApiItem { + id: number; + name: string; + userId: number; + userName: string; + inPort: number; + [key: string]: unknown; +} + +export interface TunnelDeletePreviewApiData { + tunnelId: number; + tunnelName: string; + forwardCount: number; + sampleForwards: TunnelDeletePreviewForwardApiItem[]; + [key: string]: unknown; +} + +export interface TunnelBatchDeletePreviewApiData { + tunnelCount: number; + totalForwardCount: number; + items: TunnelDeletePreviewApiData[]; + [key: string]: unknown; +} + +export interface TunnelDeleteWithForwardsApiData { + forwardCount: number; + migratedCount: number; + deletedForwardCount: number; + portAdjustedCount: number; + warnings?: string[]; + [key: string]: unknown; +} + +export interface TunnelBatchDeleteWithForwardsApiData { + successCount: number; + failCount: number; + failures?: BatchOperationFailure[]; + deletedForwardCount: number; + migratedCount: number; + portAdjustedCount: number; + warnings?: string[]; + [key: string]: unknown; +} + export interface UserMutationPayload { id?: number; user?: string; diff --git a/vite-frontend/src/pages/tunnel.tsx b/vite-frontend/src/pages/tunnel.tsx index f076450..0ac955e 100644 --- a/vite-frontend/src/pages/tunnel.tsx +++ b/vite-frontend/src/pages/tunnel.tsx @@ -1,4 +1,8 @@ -import type { BatchOperationFailure } from "@/api/types"; +import type { + BatchOperationFailure, + TunnelBatchDeletePreviewApiData, + TunnelDeletePreviewApiData, +} from "@/api/types"; import { useState, useEffect, useMemo, useRef, useCallback } from "react"; import toast from "react-hot-toast"; @@ -40,16 +44,19 @@ import { Divider } from "@/shadcn-bridge/heroui/divider"; import { Alert } from "@/shadcn-bridge/heroui/alert"; import { Checkbox } from "@/shadcn-bridge/heroui/checkbox"; import { Progress } from "@/shadcn-bridge/heroui/progress"; +import { Radio, RadioGroup } from "@/shadcn-bridge/heroui/radio"; import { createTunnel, + batchDeleteTunnelsWithForwards, getTunnelList, updateTunnel, - deleteTunnel, + deleteTunnelWithForwards, getNodeList, diagnoseTunnel, updateTunnelOrder, - batchDeleteTunnels, batchRedeployTunnels, + previewBatchTunnelDelete, + previewTunnelDelete, } from "@/api"; import { PageLoadingState } from "@/components/page-state"; import { @@ -136,6 +143,8 @@ interface BatchResultModalState { title: string; } +type TunnelDeleteAction = "replace" | "delete_forwards"; + const EMPTY_BATCH_RESULT_MODAL_STATE: BatchResultModalState = { failures: [], open: false, @@ -143,6 +152,8 @@ const EMPTY_BATCH_RESULT_MODAL_STATE: BatchResultModalState = { title: "", }; +const DEFAULT_TUNNEL_DELETE_ACTION: TunnelDeleteAction = "replace"; + const TUNNEL_ORDER_KEY = "tunnel-order"; const mapTunnelApiItems = (items: any[]): Tunnel[] => { @@ -178,8 +189,16 @@ export default function TunnelPage() { const [isEdit, setIsEdit] = useState(false); const [submitLoading, setSubmitLoading] = useState(false); const [deleteLoading, setDeleteLoading] = useState(false); + const [deletePreviewLoading, setDeletePreviewLoading] = useState(false); const [diagnosisLoading, setDiagnosisLoading] = useState(false); const [tunnelToDelete, setTunnelToDelete] = useState(null); + const [tunnelDeletePreview, setTunnelDeletePreview] = + useState(null); + const [deleteAction, setDeleteAction] = + useState(DEFAULT_TUNNEL_DELETE_ACTION); + const [deleteTargetTunnelId, setDeleteTargetTunnelId] = useState( + null, + ); const [currentDiagnosisTunnel, setCurrentDiagnosisTunnel] = useState(null); const [diagnosisResult, setDiagnosisResult] = @@ -247,6 +266,15 @@ export default function TunnelPage() { const [selectMode, setSelectMode] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false); + const [batchDeletePreviewLoading, setBatchDeletePreviewLoading] = + useState(false); + const [batchDeletePreview, setBatchDeletePreview] = + useState(null); + const [batchDeleteAction, setBatchDeleteAction] = + useState(DEFAULT_TUNNEL_DELETE_ACTION); + const [batchDeleteTargetTunnelId, setBatchDeleteTargetTunnelId] = useState< + number | null + >(null); const [batchLoading, setBatchLoading] = useState(false); const [batchProgress, setBatchProgress] = useState({ active: false, @@ -339,6 +367,42 @@ export default function TunnelPage() { loadData(); }, [loadData]); + const resetDeleteState = useCallback(() => { + setDeleteLoading(false); + setDeletePreviewLoading(false); + setTunnelToDelete(null); + setTunnelDeletePreview(null); + setDeleteAction(DEFAULT_TUNNEL_DELETE_ACTION); + setDeleteTargetTunnelId(null); + }, []); + + const handleDeleteModalOpenChange = useCallback( + (open: boolean) => { + setDeleteModalOpen(open); + if (!open) { + resetDeleteState(); + } + }, + [resetDeleteState], + ); + + const resetBatchDeleteState = useCallback(() => { + setBatchDeletePreviewLoading(false); + setBatchDeletePreview(null); + setBatchDeleteAction(DEFAULT_TUNNEL_DELETE_ACTION); + setBatchDeleteTargetTunnelId(null); + }, []); + + const handleBatchDeleteModalOpenChange = useCallback( + (open: boolean) => { + setBatchDeleteModalOpen(open); + if (!open) { + resetBatchDeleteState(); + } + }, + [resetBatchDeleteState], + ); + // 表单验证 const validateForm = (): boolean => { const newErrors = validateTunnelForm(form, nodes); @@ -384,22 +448,78 @@ export default function TunnelPage() { }; // 删除隧道 - const handleDelete = (tunnel: Tunnel) => { + const handleDelete = async (tunnel: Tunnel) => { setTunnelToDelete(tunnel); setDeleteModalOpen(true); + + setDeletePreviewLoading(true); + setTunnelDeletePreview(null); + setDeleteAction(DEFAULT_TUNNEL_DELETE_ACTION); + setDeleteTargetTunnelId(null); + + try { + const response = await previewTunnelDelete(tunnel.id); + + if (response.code !== 0 || !response.data) { + toast.error(response.msg || "获取删除依赖失败"); + setDeleteModalOpen(false); + resetDeleteState(); + + return; + } + + setTunnelDeletePreview(response.data); + } catch (error) { + toast.error(extractApiErrorMessage(error, "获取删除依赖失败")); + setDeleteModalOpen(false); + resetDeleteState(); + } finally { + setDeletePreviewLoading(false); + } }; const confirmDelete = async () => { if (!tunnelToDelete) return; + const forwardCount = tunnelDeletePreview?.forwardCount ?? 0; + const action: TunnelDeleteAction = + forwardCount > 0 ? deleteAction : "delete_forwards"; + + if ( + action === "replace" && + forwardCount > 0 && + (!deleteTargetTunnelId || + !deleteReplacementTunnels.some( + (tunnel) => tunnel.id === deleteTargetTunnelId, + )) + ) { + toast.error("请选择替换规则的目标隧道"); + + return; + } + setDeleteLoading(true); try { - const response = await deleteTunnel(tunnelToDelete.id); + const response = await deleteTunnelWithForwards({ + id: tunnelToDelete.id, + action, + targetTunnelId: + action === "replace" ? deleteTargetTunnelId ?? undefined : undefined, + }); if (response.code === 0) { - toast.success("删除成功"); + const deleteResult = (response.data || null) as { + warnings?: string[]; + } | null; + + if ((deleteResult?.warnings?.length ?? 0) > 0) { + toast.success( + `删除成功,另有 ${deleteResult?.warnings?.length ?? 0} 条节点清理提示`, + ); + } else { + toast.success("删除成功"); + } setDeleteModalOpen(false); - setTunnelToDelete(null); setTunnels((prev) => prev.filter((tunnel) => tunnel.id !== tunnelToDelete.id), ); @@ -417,11 +537,32 @@ export default function TunnelPage() { return next; }); + resetDeleteState(); + } else if ( + response.data && + typeof response.data === "object" && + Number((response.data as { failCount?: number }).failCount ?? 0) > 0 + ) { + const result = response.data as { + failCount?: number; + successCount?: number; + }; + const failures = extractBatchFailures(response.data); + + if (failures.length > 0) { + setBatchResultModal({ + failures, + open: true, + summary: `成功 ${Number(result.successCount ?? 0)} 项,失败 ${Number(result.failCount ?? failures.length)} 项`, + title: "规则处理失败", + }); + } + toast.error(response.msg || "删除失败"); } else { toast.error(response.msg || "删除失败"); } - } catch { - toast.error("删除失败"); + } catch (error) { + toast.error(extractApiErrorMessage(error, "删除失败")); } finally { setDeleteLoading(false); } @@ -877,8 +1018,48 @@ export default function TunnelPage() { [], ); + const handleOpenBatchDeleteModal = async () => { + if (selectedIds.size === 0) return; + + setBatchDeleteModalOpen(true); + setBatchDeletePreviewLoading(true); + setBatchDeletePreview(null); + setBatchDeleteAction(DEFAULT_TUNNEL_DELETE_ACTION); + setBatchDeleteTargetTunnelId(null); + + try { + const response = await previewBatchTunnelDelete(selectedTunnelIdList); + + if (response.code !== 0 || !response.data) { + toast.error(response.msg || "获取批量删除依赖失败"); + setBatchDeleteModalOpen(false); + resetBatchDeleteState(); + + return; + } + + setBatchDeletePreview(response.data); + } catch (error) { + toast.error(extractApiErrorMessage(error, "获取批量删除依赖失败")); + setBatchDeleteModalOpen(false); + resetBatchDeleteState(); + } finally { + setBatchDeletePreviewLoading(false); + } + }; + const handleBatchDelete = async () => { if (selectedIds.size === 0) return; + if ( + batchDeleteHasForwardDependencies && + batchDeleteAction === "replace" && + (!batchDeleteTargetTunnelId || batchDeleteReplaceUnavailable) + ) { + toast.error("请选择替换规则的目标隧道"); + + return; + } + setBatchLoading(true); setBatchProgress({ active: true, @@ -886,28 +1067,40 @@ export default function TunnelPage() { percent: 30, }); try { - const res = await batchDeleteTunnels(Array.from(selectedIds)); + const res = await batchDeleteTunnelsWithForwards({ + ids: selectedTunnelIdList, + action: batchDeleteHasForwardDependencies + ? batchDeleteAction + : "delete_forwards", + targetTunnelId: + batchDeleteHasForwardDependencies && batchDeleteAction === "replace" + ? batchDeleteTargetTunnelId ?? undefined + : undefined, + }); if (res.code === 0) { - const result = res.data; + const result = (res.data || { + successCount: 0, + failCount: 0, + warnings: [], + }) as { + successCount: number; + failCount: number; + warnings?: string[]; + }; + const warningCount = result?.warnings?.length ?? 0; if (result.failCount === 0) { - toast.success(`成功删除 ${result.successCount} 项`); + toast.success( + warningCount > 0 + ? `成功删除 ${result.successCount} 项,另有 ${warningCount} 条节点清理提示` + : `成功删除 ${result.successCount} 项`, + ); setBatchProgress({ active: true, label: `删除完成:成功 ${result.successCount} 项`, percent: 100, }); - setTunnels((prev) => - prev.filter((tunnel) => !selectedIds.has(tunnel.id)), - ); - setTunnelOrder((prev) => { - const next = prev.filter((id) => !selectedIds.has(id)); - - saveOrder(TUNNEL_ORDER_KEY, next); - - return next; - }); } else { const failures = extractBatchFailures(result); @@ -927,11 +1120,12 @@ export default function TunnelPage() { label: `部分完成:成功 ${result.successCount} 项,正在刷新列表...`, percent: 75, }); - await refreshTunnelList(false); } + await refreshTunnelList(false); setSelectedIds(new Set()); setSelectMode(false); setBatchDeleteModalOpen(false); + resetBatchDeleteState(); } else { toast.error(res.msg || "删除失败"); } @@ -1069,6 +1263,150 @@ export default function TunnelPage() { [sortedTunnels], ); + const deleteReplacementTunnels = useMemo(() => { + if (!tunnelToDelete) { + return [] as Tunnel[]; + } + + return tunnels + .filter((tunnel) => tunnel.id !== tunnelToDelete.id && tunnel.status === 1) + .sort((a, b) => { + const aInx = a.inx ?? 0; + const bInx = b.inx ?? 0; + + return aInx - bInx; + }); + }, [tunnelToDelete, tunnels]); + + useEffect(() => { + if (!deleteModalOpen) { + return; + } + + if ((tunnelDeletePreview?.forwardCount ?? 0) <= 0) { + return; + } + + if (deleteReplacementTunnels.length === 0) { + setDeleteAction("delete_forwards"); + setDeleteTargetTunnelId(null); + + return; + } + + if (deleteAction !== "replace") { + return; + } + + setDeleteTargetTunnelId((prev) => { + if (prev && deleteReplacementTunnels.some((tunnel) => tunnel.id === prev)) { + return prev; + } + + return deleteReplacementTunnels[0]?.id ?? null; + }); + }, [ + deleteAction, + deleteModalOpen, + deleteReplacementTunnels, + tunnelDeletePreview?.forwardCount, + ]); + + const deletePreviewForwardCount = tunnelDeletePreview?.forwardCount ?? 0; + const deleteHasForwardDependencies = deletePreviewForwardCount > 0; + const deleteReplaceUnavailable = + deleteHasForwardDependencies && deleteReplacementTunnels.length === 0; + const deleteConfirmLabel = deleteHasForwardDependencies + ? deleteAction === "replace" + ? "迁移规则后删除该隧道" + : "删除规则并删除该隧道" + : "删除该隧道"; + + const selectedTunnelIdList = useMemo( + () => Array.from(selectedIds), + [selectedIds], + ); + const batchDeleteReplacementTunnels = useMemo(() => { + if (selectedIds.size === 0) { + return [] as Tunnel[]; + } + + return tunnels + .filter((tunnel) => !selectedIds.has(tunnel.id) && tunnel.status === 1) + .sort((a, b) => { + const aInx = a.inx ?? 0; + const bInx = b.inx ?? 0; + + return aInx - bInx; + }); + }, [selectedIds, tunnels]); + + useEffect(() => { + if (!batchDeleteModalOpen) { + return; + } + + if ((batchDeletePreview?.totalForwardCount ?? 0) <= 0) { + return; + } + + if (batchDeleteReplacementTunnels.length === 0) { + setBatchDeleteAction("delete_forwards"); + setBatchDeleteTargetTunnelId(null); + + return; + } + + if (batchDeleteAction !== "replace") { + return; + } + + setBatchDeleteTargetTunnelId((prev) => { + if ( + prev && + batchDeleteReplacementTunnels.some((tunnel) => tunnel.id === prev) + ) { + return prev; + } + + return batchDeleteReplacementTunnels[0]?.id ?? null; + }); + }, [ + batchDeleteAction, + batchDeleteModalOpen, + batchDeletePreview?.totalForwardCount, + batchDeleteReplacementTunnels, + ]); + + const batchDeleteTotalForwardCount = batchDeletePreview?.totalForwardCount ?? 0; + const batchDeleteHasForwardDependencies = batchDeleteTotalForwardCount > 0; + const batchDeleteDependentTunnelCount = + batchDeletePreview?.items?.filter((item) => item.forwardCount > 0).length ?? 0; + const batchDeleteDirectDeleteTunnelCount = Math.max( + selectedTunnelIdList.length - batchDeleteDependentTunnelCount, + 0, + ); + const batchDeletePreviewItems = useMemo(() => { + return [...(batchDeletePreview?.items ?? [])].sort((a, b) => { + if ((a.forwardCount > 0) === (b.forwardCount > 0)) { + return a.tunnelName.localeCompare(b.tunnelName, "zh-CN"); + } + + return a.forwardCount > 0 ? -1 : 1; + }); + }, [batchDeletePreview?.items]); + const batchDeleteDependentItems = useMemo( + () => batchDeletePreviewItems.filter((item) => item.forwardCount > 0), + [batchDeletePreviewItems], + ); + const batchDeleteReplaceUnavailable = + batchDeleteHasForwardDependencies && batchDeleteReplacementTunnels.length === 0; + const batchDeleteConfirmLabel = batchDeleteHasForwardDependencies + ? batchDeleteAction === "replace" + ? `迁移规则后删除这 ${selectedTunnelIdList.length} 条隧道` + : `删除规则并删除 ${selectedTunnelIdList.length} 条隧道` + : `删除这 ${selectedTunnelIdList.length} 条隧道`; + const SortableItem = ({ id, children, @@ -1151,7 +1489,7 @@ export default function TunnelPage() { isDisabled={selectedIds.size === 0} size="sm" variant="flat" - onPress={() => setBatchDeleteModalOpen(true)} + onPress={handleOpenBatchDeleteModal} > 删除 @@ -2459,22 +2797,143 @@ export default function TunnelPage() { placement="center" scrollBehavior="outside" size="2xl" - onOpenChange={setDeleteModalOpen} + onOpenChange={handleDeleteModalOpenChange} > {(onClose) => ( <> -

确认删除

+

删除隧道

+

+ {tunnelDeletePreview?.tunnelName || tunnelToDelete?.name + ? `即将删除“${tunnelDeletePreview?.tunnelName || tunnelToDelete?.name}”,删除前会先检查是否有关联规则。` + : "删除前会先检查是否有关联规则。"} +

- -

- 确定要删除隧道{" "} - "{tunnelToDelete?.name}" 吗? -

-

- 此操作不可恢复,请谨慎操作。 -

+ + {deletePreviewLoading ? ( +
+ + 正在检查是否有规则正在使用该隧道... +
+ ) : deleteHasForwardDependencies ? ( + <> + + + {(tunnelDeletePreview?.sampleForwards?.length ?? 0) > 0 ? ( +
+
+

+ 关联规则预览 +

+ + 前 {tunnelDeletePreview?.sampleForwards?.length ?? 0} 条 + +
+
+ {tunnelDeletePreview?.sampleForwards?.map((forward) => ( +
+
+ + {forward.name} + + + :{forward.inPort || 0} + +
+

+ 用户:{forward.userName || `#${forward.userId}`} +

+
+ ))} +
+ {deletePreviewForwardCount > + (tunnelDeletePreview?.sampleForwards?.length ?? 0) ? ( +

+ 还有 {deletePreviewForwardCount - (tunnelDeletePreview?.sampleForwards?.length ?? 0)} 条规则未展开显示。 +

+ ) : null} +
+ ) : null} + + { + const nextAction = value as TunnelDeleteAction; + + setDeleteAction(nextAction); + if (nextAction !== "replace") { + setDeleteTargetTunnelId(null); + return; + } + + setDeleteTargetTunnelId( + deleteReplacementTunnels[0]?.id ?? null, + ); + }} + > + + 保留规则,迁移到其他隧道{deleteReplaceUnavailable ? "(当前无可用目标)" : "(推荐)"} + + 直接删除这些关联规则 + + + {deleteReplaceUnavailable ? ( + + ) : null} + + {deleteAction === "replace" && !deleteReplaceUnavailable ? ( +
+ +

+ 关联规则会迁移到这里,当前要删除的隧道不会出现在可选项里。 +

+
+ ) : null} + + ) : ( + + )}
@@ -3085,17 +3550,154 @@ export default function TunnelPage() { base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden", }} isOpen={batchDeleteModalOpen} - onOpenChange={setBatchDeleteModalOpen} + onOpenChange={handleBatchDeleteModalOpenChange} > {(onClose) => ( <> - 确认删除 - -

- 确定要删除选中的 {selectedIds.size}{" "} - 项隧道吗?此操作不可撤销,相关规则也将被删除。 + +

批量删除隧道

+

+ 即将删除这 {selectedTunnelIdList.length} 条隧道,删除前会先检查是否有关联规则。

+ + + {batchDeletePreviewLoading ? ( +
+ + 正在检查选中隧道是否有关联规则... +
+ ) : batchDeleteHasForwardDependencies ? ( + <> + 0 ? `其余 ${batchDeleteDirectDeleteTunnelCount} 条会直接删除。` : ""}`} + title="发现关联规则" + variant="flat" + /> + +
+ {batchDeleteDependentItems.map((item) => ( +
+
+
+

+ {item.tunnelName} +

+

+ {item.forwardCount} 条规则依赖 +

+
+ + 有关联 + +
+ + {item.sampleForwards.length > 0 ? ( +
+ {item.sampleForwards.map((forward) => ( +
+
+ + {forward.name} + + + :{forward.inPort || 0} + +
+

+ 用户:{forward.userName || `#${forward.userId}`} +

+
+ ))} + {item.forwardCount > item.sampleForwards.length ? ( +

+ 还有 {item.forwardCount - item.sampleForwards.length} 条规则未展开显示。 +

+ ) : null} +
+ ) : null} +
+ ))} +
+ + { + const nextAction = value as TunnelDeleteAction; + + setBatchDeleteAction(nextAction); + if (nextAction !== "replace") { + setBatchDeleteTargetTunnelId(null); + return; + } + + setBatchDeleteTargetTunnelId( + batchDeleteReplacementTunnels[0]?.id ?? null, + ); + }} + > + + 保留规则,统一迁移到其他隧道{batchDeleteReplaceUnavailable ? "(当前无可用目标)" : "(推荐)"} + + 直接删除这些关联规则 + + + {batchDeleteReplaceUnavailable ? ( + + ) : null} + + {batchDeleteAction === "replace" && + !batchDeleteReplaceUnavailable ? ( +
+ +

+ 所有关联规则都会迁移到这里,删除列表中的隧道不会出现在可选项里。 +

+
+ ) : null} + + ) : ( + + )}