feat: 添加Neo4j图数据库支持及前端代码重构
- 新增 Neo4j 图数据库 handler、service、model - 后端添加 SaveGraph API 接口 - 前端 Database.vue 重构,拆分为独立组件 - 新增 web/src/views/database/ 组件目录 - 删除临时文件 (temp_*.go) - 添加 Neo4j 相关 API 需求文档 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,32 +0,0 @@
|
||||
# 构建阶段
|
||||
FROM golang:1.21-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装依赖
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# 复制 go.mod 和 go.sum
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# 复制源代码
|
||||
COPY . .
|
||||
|
||||
# 构建
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /server ./cmd/api
|
||||
|
||||
# 运行阶段
|
||||
FROM alpine:latest
|
||||
|
||||
RUN apk --no-cache add ca-certificates
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 复制构建产物
|
||||
COPY --from=builder /server .
|
||||
COPY config/ ./config/
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["./server"]
|
||||
@@ -79,10 +79,12 @@ func main() {
|
||||
// 5. 初始化 Service
|
||||
dbService := service.NewDatabaseService(dbRepo, subTableRepo)
|
||||
subTableService := service.NewSubTableService(subTableRepo, dbRepo)
|
||||
neo4jService := service.NewNeo4jService(dbRepo)
|
||||
|
||||
// 6. 初始化 Handler
|
||||
dbHandler := handler.NewDatabaseHandler(dbService)
|
||||
subTableHandler := handler.NewSubTableHandler(subTableService)
|
||||
neo4jHandler := handler.NewNeo4jHandler(neo4jService)
|
||||
systemHandler := handler.NewSystemHandler()
|
||||
|
||||
// 7. 设置路由
|
||||
@@ -137,6 +139,7 @@ func main() {
|
||||
databaseGroup.POST("/add", dbHandler.Create)
|
||||
databaseGroup.PUT("/:id", dbHandler.Update)
|
||||
databaseGroup.DELETE("/:id", dbHandler.Delete)
|
||||
databaseGroup.POST("/graph/save", dbHandler.SaveGraph)
|
||||
}
|
||||
|
||||
// 子表映射管理模块
|
||||
@@ -151,6 +154,15 @@ func main() {
|
||||
subTableGroup.DELETE("/:id", subTableHandler.Delete)
|
||||
}
|
||||
|
||||
// Neo4j 连接管理模块
|
||||
neo4jGroup := r.Group("/neo4j")
|
||||
{
|
||||
neo4jGroup.POST("/check", neo4jHandler.Check)
|
||||
neo4jGroup.POST("/graphs", neo4jHandler.GetGraphs)
|
||||
neo4jGroup.POST("/nodes", neo4jHandler.GetNodes)
|
||||
neo4jGroup.POST("/relationships", neo4jHandler.GetRelationships)
|
||||
}
|
||||
|
||||
// 系统信息模块
|
||||
r.GET("/system/info", systemHandler.GetSystemInfo)
|
||||
|
||||
|
||||
@@ -98,6 +98,23 @@ func (h *DatabaseHandler) Update(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, info)
|
||||
}
|
||||
|
||||
// SaveGraph 保存图谱信息
|
||||
func (h *DatabaseHandler) SaveGraph(c *gin.Context) {
|
||||
var req model.SaveGraphRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.service.SaveGraph(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// Delete 删除
|
||||
func (h *DatabaseHandler) Delete(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
86
server/internal/handler/neo4j_handler.go
Normal file
86
server/internal/handler/neo4j_handler.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"x-agents/server/internal/model"
|
||||
"x-agents/server/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Neo4jHandler struct {
|
||||
service *service.Neo4jService
|
||||
}
|
||||
|
||||
func NewNeo4jHandler(svc *service.Neo4jService) *Neo4jHandler {
|
||||
return &Neo4jHandler{service: svc}
|
||||
}
|
||||
|
||||
// Check 检查 Neo4j 连接
|
||||
func (h *Neo4jHandler) Check(c *gin.Context) {
|
||||
var req model.Neo4jCheckRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.service.Check(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// GetGraphs 获取图谱概览数据
|
||||
func (h *Neo4jHandler) GetGraphs(c *gin.Context) {
|
||||
var req model.Neo4jGraphRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.service.GetGraphs(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// GetNodes 获取节点详情
|
||||
func (h *Neo4jHandler) GetNodes(c *gin.Context) {
|
||||
var req model.Neo4jNodeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.service.GetNodes(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// GetRelationships 获取关系详情
|
||||
func (h *Neo4jHandler) GetRelationships(c *gin.Context) {
|
||||
var req model.Neo4jRelRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.service.GetRelationships(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
@@ -9,7 +9,7 @@ type DatabaseInfo struct {
|
||||
ID string `json:"id" gorm:"primaryKey;size:36"` // UUID
|
||||
Name string `json:"name" gorm:"size:100;not null"` // 数据库名称
|
||||
Description string `json:"description" gorm:"size:500"` // 描述
|
||||
DBType string `json:"db_type" gorm:"size:20;not null"` // 数据库类型: mysql, postgres, mongodb等
|
||||
DBType string `json:"db_type" gorm:"size:20;not null"` // 数据库类型: mysql, postgres, mongodb, neo4j等
|
||||
Host string `json:"host" gorm:"size:255;not null"` // 主机地址
|
||||
Port int `json:"port" gorm:"not null"` // 端口
|
||||
Username string `json:"username" gorm:"size:100;not null"` // 用户名
|
||||
@@ -17,6 +17,12 @@ type DatabaseInfo struct {
|
||||
Database string `json:"database" gorm:"size:100"` // 数据库名
|
||||
TableCount int `json:"table_count" gorm:"default:0"` // 子表数量
|
||||
|
||||
// Neo4j 专用字段
|
||||
URI string `json:"uri" gorm:"size:255"` // Neo4j 连接地址 (bolt://host:7687)
|
||||
GraphLabels string `json:"graph_labels" gorm:"size:1000"` // Neo4j 标签列表 (JSON 格式)
|
||||
GraphRelationship string `json:"graph_relationship" gorm:"size:1000"` // Neo4j 关系类型列表 (JSON 格式)
|
||||
SelectedLabel string `json:"selected_label" gorm:"size:100"` // 当前选中的标签
|
||||
|
||||
// 连接选项
|
||||
Charset string `json:"charset" gorm:"size:20;default:utf8mb4"` // 字符集
|
||||
SSLMode string `json:"ssl_mode" gorm:"size:20"` // SSL模式
|
||||
@@ -48,17 +54,38 @@ type CreateDatabaseRequest struct {
|
||||
|
||||
// UpdateRequest 更新数据库信息请求
|
||||
type UpdateDatabaseRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
DBType string `json:"db_type"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Database string `json:"database"`
|
||||
TableCount int `json:"table_count"`
|
||||
Charset string `json:"charset"`
|
||||
SSLMode string `json:"ssl_mode"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
DBType string `json:"db_type"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Database string `json:"database"`
|
||||
TableCount int `json:"table_count"`
|
||||
Charset string `json:"charset"`
|
||||
SSLMode string `json:"ssl_mode"`
|
||||
URI string `json:"uri"` // Neo4j 连接地址
|
||||
GraphLabels string `json:"graph_labels"` // Neo4j 标签列表 (JSON)
|
||||
GraphRelationship string `json:"graph_relationship"` // Neo4j 关系类型列表 (JSON)
|
||||
SelectedLabel string `json:"selected_label"` // 当前选中的标签
|
||||
}
|
||||
|
||||
// SaveGraphRequest 保存图谱信息请求
|
||||
type SaveGraphRequest struct {
|
||||
DatabaseID string `json:"databaseId" binding:"required"`
|
||||
DatabaseName string `json:"databaseName" binding:"required"`
|
||||
URI string `json:"uri" binding:"required"`
|
||||
Username string `json:"username" binding:"required"`
|
||||
Labels []string `json:"labels" binding:"required"`
|
||||
RelationshipTypes []string `json:"relationshipTypes" binding:"required"`
|
||||
SelectedLabel string `json:"selectedLabel"`
|
||||
}
|
||||
|
||||
// SaveGraphResponse 保存图谱信息响应
|
||||
type SaveGraphResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// CheckRequest 检查连接请求
|
||||
@@ -72,12 +99,97 @@ type CheckRequest struct {
|
||||
Charset string `json:"charset"`
|
||||
SSLMode string `json:"ssl_mode"`
|
||||
DatabaseID string `json:"database_id"` // 可选,用于获取已保存的字段映射
|
||||
URI string `json:"uri"` // Neo4j 连接地址,如 bolt://localhost:7687
|
||||
}
|
||||
|
||||
// CheckResponse 检查连接响应
|
||||
type CheckResponse struct {
|
||||
Success bool `json:"success"` // 是否连接成功
|
||||
Message string `json:"message"` // 消息
|
||||
Tables []TableDDLInfo `json:"tables,omitempty"` // 表列表(连接成功时返回)
|
||||
Database string `json:"database"` // 数据库名
|
||||
Success bool `json:"success"` // 是否连接成功
|
||||
Message string `json:"message"` // 消息
|
||||
Tables []TableDDLInfo `json:"tables,omitempty"` // 表列表(关系型数据库)
|
||||
Database string `json:"database"` // 数据库名
|
||||
Graphs *GraphOverview `json:"graphs,omitempty"` // 图谱概览(Neo4j)
|
||||
}
|
||||
|
||||
// GraphOverview 图谱概览数据
|
||||
type GraphOverview struct {
|
||||
Labels []LabelCount `json:"labels"` // 标签列表
|
||||
RelationshipTypes []RelTypeCount `json:"relationshipTypes"` // 关系类型列表
|
||||
Nodes []NodeProperty `json:"nodes"` // 节点属性定义
|
||||
Relationships []RelProperty `json:"relationships"` // 关系属性定义
|
||||
}
|
||||
|
||||
// LabelCount 标签数量统计
|
||||
type LabelCount struct {
|
||||
Name string `json:"name"` // 标签名
|
||||
Count int `json:"count"` // 节点数量
|
||||
}
|
||||
|
||||
// RelTypeCount 关系类型数量统计
|
||||
type RelTypeCount struct {
|
||||
Name string `json:"name"` // 关系类型名
|
||||
Count int `json:"count"` // 关系数量
|
||||
}
|
||||
|
||||
// NodeProperty 节点属性定义
|
||||
type NodeProperty struct {
|
||||
Label string `json:"label"` // 节点标签名
|
||||
Properties []PropertyInfo `json:"properties"` // 属性列表
|
||||
}
|
||||
|
||||
// PropertyInfo 属性信息
|
||||
type PropertyInfo struct {
|
||||
Name string `json:"name"` // 属性名
|
||||
Type string `json:"type"` // 属性类型
|
||||
}
|
||||
|
||||
// RelProperty 关系属性定义
|
||||
type RelProperty struct {
|
||||
Type string `json:"type"` // 关系类型名
|
||||
StartLabel string `json:"startLabel"` // 起始节点标签
|
||||
EndLabel string `json:"endLabel"` // 目标节点标签
|
||||
Properties []PropertyInfo `json:"properties"` // 属性列表
|
||||
}
|
||||
|
||||
// Neo4jNodeRequest 获取节点详情请求
|
||||
type Neo4jNodeRequest struct {
|
||||
URI string `json:"uri" binding:"required"`
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
Database string `json:"database"`
|
||||
Label string `json:"label" binding:"required"`
|
||||
Limit int `json:"limit"` // 默认 10
|
||||
}
|
||||
|
||||
// Neo4jNodeResponse 获取节点详情响应
|
||||
type Neo4jNodeResponse struct {
|
||||
Success bool `json:"success"` // 是否成功
|
||||
Message string `json:"message"` // 消息
|
||||
Nodes []map[string]interface{} `json:"nodes"` // 节点数据
|
||||
Properties []PropertyInfo `json:"properties"` // 属性定义列表
|
||||
}
|
||||
|
||||
// Neo4jRelRequest 获取关系详情请求
|
||||
type Neo4jRelRequest struct {
|
||||
URI string `json:"uri" binding:"required"`
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
Database string `json:"database"`
|
||||
RelationshipType string `json:"relationship_type" binding:"required"`
|
||||
Limit int `json:"limit"` // 默认 10
|
||||
}
|
||||
|
||||
// Neo4jRelResponse 获取关系详情响应
|
||||
type Neo4jRelResponse struct {
|
||||
Success bool `json:"success"` // 是否成功
|
||||
Message string `json:"message"` // 消息
|
||||
Relationships []Neo4jRelationship `json:"relationships"` // 关系数据
|
||||
}
|
||||
|
||||
// Neo4jRelationship 关系数据
|
||||
type Neo4jRelationship struct {
|
||||
ID string `json:"id"` // 关系唯一标识
|
||||
Source string `json:"source"` // 起始节点ID
|
||||
Target string `json:"target"` // 目标节点ID
|
||||
Properties map[string]interface{} `json:"properties"` // 关系属性
|
||||
}
|
||||
|
||||
39
server/internal/model/neo4j_info.go
Normal file
39
server/internal/model/neo4j_info.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package model
|
||||
|
||||
// Neo4jCheckRequest Neo4j 连接测试请求
|
||||
type Neo4jCheckRequest struct {
|
||||
Name string `json:"name"` // 数据库名称
|
||||
Host string `json:"host" binding:"required"`
|
||||
Port int `json:"port" binding:"required"`
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
Database string `json:"database"` // 可选,默认 neo4j
|
||||
URI string `json:"uri"` // 可选,Neo4j 连接地址 (bolt://host:7687)
|
||||
Description string `json:"description"` // 可选,数据库描述
|
||||
}
|
||||
|
||||
// Neo4jCheckResponse Neo4j 连接测试响应
|
||||
type Neo4jCheckResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Databases []string `json:"databases,omitempty"`
|
||||
DatabaseID string `json:"databaseId,omitempty"` // 数据库记录 ID
|
||||
Name string `json:"name,omitempty"` // 数据库名称
|
||||
Description string `json:"description,omitempty"` // 数据库描述
|
||||
}
|
||||
|
||||
// Neo4jGraphRequest 获取图谱概览请求
|
||||
type Neo4jGraphRequest struct {
|
||||
URI string `json:"uri" binding:"required"`
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
Database string `json:"database"` // 可选,默认 neo4j
|
||||
}
|
||||
|
||||
// Neo4jGraphResponse 获取图谱概览响应
|
||||
type Neo4jGraphResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Graphs *GraphOverview `json:"graphs,omitempty"`
|
||||
}
|
||||
@@ -41,6 +41,11 @@ func (r *DatabaseRepository) Update(id string, info *model.DatabaseInfo) error {
|
||||
return r.db.Model(&model.DatabaseInfo{}).Where("id = ?", id).Updates(info).Error
|
||||
}
|
||||
|
||||
// UpdateFields 更新指定字段
|
||||
func (r *DatabaseRepository) UpdateFields(id string, fields map[string]interface{}) error {
|
||||
return r.db.Model(&model.DatabaseInfo{}).Where("id = ?", id).Updates(fields).Error
|
||||
}
|
||||
|
||||
// Delete 删除
|
||||
func (r *DatabaseRepository) Delete(id string) error {
|
||||
return r.db.Delete(&model.DatabaseInfo{}, "id = ?", id).Error
|
||||
|
||||
@@ -259,6 +259,27 @@ func (s *DatabaseService) Check(req model.CheckRequest) (*model.CheckResponse, e
|
||||
var db *sql.DB
|
||||
var err error
|
||||
|
||||
// Neo4j 处理
|
||||
if dbType == "neo4j" {
|
||||
log.Printf("[Check] 检测到 Neo4j 类型,使用图数据库连接...")
|
||||
neo4jService := NewNeo4jService(s.repo)
|
||||
graph, err := neo4jService.GetGraphOverview(req)
|
||||
if err != nil {
|
||||
log.Printf("[Check] Neo4j 连接失败: %v", err)
|
||||
return &model.CheckResponse{
|
||||
Success: false,
|
||||
Message: fmt.Sprintf("neo4j connection failed: %v", err),
|
||||
}, nil
|
||||
}
|
||||
log.Printf("[Check] Neo4j 连接成功,获取到 %d 个标签", len(graph.Labels))
|
||||
return &model.CheckResponse{
|
||||
Success: true,
|
||||
Message: "connection successful",
|
||||
Graphs: graph,
|
||||
Database: req.Database,
|
||||
}, nil
|
||||
}
|
||||
|
||||
switch dbType {
|
||||
case "mysql":
|
||||
db, err = sql.Open("mysql", dsn)
|
||||
@@ -684,6 +705,92 @@ func (s *DatabaseService) Update(id string, req model.UpdateDatabaseRequest) (*m
|
||||
return s.repo.FindByID(id)
|
||||
}
|
||||
|
||||
// SaveGraph 保存图谱信息
|
||||
func (s *DatabaseService) SaveGraph(req model.SaveGraphRequest) (*model.SaveGraphResponse, error) {
|
||||
log.Printf("[SaveGraph] 保存图谱信息, databaseId=%s, databaseName=%s", req.DatabaseID, req.DatabaseName)
|
||||
|
||||
// 检查数据库是否存在
|
||||
_, err := s.repo.FindByID(req.DatabaseID)
|
||||
if err != nil {
|
||||
// 如果不存在,创建一个新的
|
||||
if err == ErrDatabaseNotFound {
|
||||
// 创建新的数据库记录
|
||||
dbType := "neo4j"
|
||||
// 从 URI 解析 host 和 port
|
||||
host := "localhost"
|
||||
port := 7687
|
||||
if req.URI != "" {
|
||||
uri := strings.TrimPrefix(req.URI, "bolt://")
|
||||
uri = strings.TrimPrefix(uri, "neo4j://")
|
||||
if idx := strings.Index(uri, ":"); idx > 0 {
|
||||
host = uri[:idx]
|
||||
fmt.Sscanf(uri[idx+1:], "%d", &port)
|
||||
}
|
||||
}
|
||||
|
||||
// 将 labels 和 relationshipTypes 转为 JSON 字符串
|
||||
labelsJSON, _ := json.Marshal(req.Labels)
|
||||
relJSON, _ := json.Marshal(req.RelationshipTypes)
|
||||
|
||||
info := &model.DatabaseInfo{
|
||||
ID: req.DatabaseID,
|
||||
Name: req.DatabaseName,
|
||||
DBType: dbType,
|
||||
Host: host,
|
||||
Port: port,
|
||||
Username: req.Username,
|
||||
URI: req.URI,
|
||||
GraphLabels: string(labelsJSON),
|
||||
GraphRelationship: string(relJSON),
|
||||
SelectedLabel: req.SelectedLabel,
|
||||
}
|
||||
|
||||
if err := s.repo.Create(info); err != nil {
|
||||
log.Printf("[SaveGraph] 创建失败: %v", err)
|
||||
return &model.SaveGraphResponse{
|
||||
Success: false,
|
||||
Message: fmt.Sprintf("创建失败: %v", err),
|
||||
}, err
|
||||
}
|
||||
|
||||
return &model.SaveGraphResponse{
|
||||
Success: true,
|
||||
Message: "保存成功",
|
||||
}, nil
|
||||
}
|
||||
log.Printf("[SaveGraph] 查询失败: %v", err)
|
||||
return &model.SaveGraphResponse{
|
||||
Success: false,
|
||||
Message: fmt.Sprintf("查询失败: %v", err),
|
||||
}, err
|
||||
}
|
||||
|
||||
// 更新现有记录
|
||||
labelsJSON, _ := json.Marshal(req.Labels)
|
||||
relJSON, _ := json.Marshal(req.RelationshipTypes)
|
||||
|
||||
updates := map[string]interface{}{
|
||||
"uri": req.URI,
|
||||
"username": req.Username,
|
||||
"graph_labels": string(labelsJSON),
|
||||
"graph_relationship": string(relJSON),
|
||||
"selected_label": req.SelectedLabel,
|
||||
}
|
||||
|
||||
if err := s.repo.UpdateFields(req.DatabaseID, updates); err != nil {
|
||||
log.Printf("[SaveGraph] 更新失败: %v", err)
|
||||
return &model.SaveGraphResponse{
|
||||
Success: false,
|
||||
Message: fmt.Sprintf("更新失败: %v", err),
|
||||
}, err
|
||||
}
|
||||
|
||||
return &model.SaveGraphResponse{
|
||||
Success: true,
|
||||
Message: "保存成功",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// fillFieldMappings 填充字段映射到表结构中
|
||||
func (s *DatabaseService) fillFieldMappings(databaseID string, tables []model.TableDDLInfo) {
|
||||
// 从数据库中获取该数据库下所有子表的字段映射
|
||||
|
||||
850
server/internal/service/neo4j_service.go
Normal file
850
server/internal/service/neo4j_service.go
Normal file
@@ -0,0 +1,850 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"x-agents/server/internal/model"
|
||||
"x-agents/server/internal/repository"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Neo4jService Neo4j 服务
|
||||
type Neo4jService struct {
|
||||
client *http.Client
|
||||
databaseRepo *repository.DatabaseRepository
|
||||
}
|
||||
|
||||
func NewNeo4jService(dbRepo *repository.DatabaseRepository) *Neo4jService {
|
||||
return &Neo4jService{
|
||||
client: &http.Client{},
|
||||
databaseRepo: dbRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// GetGraphs 获取图谱概览数据(新增接口)
|
||||
func (s *Neo4jService) GetGraphs(req model.Neo4jGraphRequest) (*model.Neo4jGraphResponse, error) {
|
||||
host := "localhost"
|
||||
port := 7687
|
||||
if req.URI != "" {
|
||||
// 解析 URI
|
||||
uri := strings.TrimPrefix(req.URI, "bolt://")
|
||||
uri = strings.TrimPrefix(uri, "neo4j://")
|
||||
if idx := strings.Index(uri, ":"); idx > 0 {
|
||||
host = uri[:idx]
|
||||
fmt.Sscanf(uri[idx+1:], "%d", &port)
|
||||
}
|
||||
}
|
||||
|
||||
db := req.Database
|
||||
if db == "" {
|
||||
db = "neo4j"
|
||||
}
|
||||
|
||||
auth := fmt.Sprintf("%s:%s", req.Username, req.Password)
|
||||
encodedAuth := base64.StdEncoding.EncodeToString([]byte(auth))
|
||||
|
||||
// 尝试多个端口
|
||||
ports := []int{port - 1000, 7474, port}
|
||||
checkedPorts := make(map[int]bool)
|
||||
|
||||
for _, p := range ports {
|
||||
if checkedPorts[p] {
|
||||
continue
|
||||
}
|
||||
checkedPorts[p] = true
|
||||
|
||||
url := fmt.Sprintf("http://%s:%d/db/%s/tx/commit", host, p, db)
|
||||
graph, err := s.getGraphOverviewWithURL(url, encodedAuth, db)
|
||||
if err == nil && graph != nil {
|
||||
return &model.Neo4jGraphResponse{
|
||||
Success: true,
|
||||
Message: "success",
|
||||
Graphs: graph,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return &model.Neo4jGraphResponse{
|
||||
Success: false,
|
||||
Message: "failed to connect to Neo4j",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Check 测试 Neo4j 连接
|
||||
func (s *Neo4jService) Check(req model.Neo4jCheckRequest) (*model.Neo4jCheckResponse, error) {
|
||||
db := req.Database
|
||||
if db == "" {
|
||||
db = "neo4j"
|
||||
}
|
||||
|
||||
auth := fmt.Sprintf("%s:%s", req.Username, req.Password)
|
||||
encodedAuth := base64.StdEncoding.EncodeToString([]byte(auth))
|
||||
|
||||
httpPort := req.Port - 1000
|
||||
if httpPort <= 0 {
|
||||
httpPort = 7474
|
||||
}
|
||||
|
||||
ports := []int{httpPort, 7474, req.Port}
|
||||
checkedPorts := make(map[int]bool)
|
||||
|
||||
var version string
|
||||
for _, port := range ports {
|
||||
if checkedPorts[port] {
|
||||
continue
|
||||
}
|
||||
checkedPorts[port] = true
|
||||
|
||||
url := fmt.Sprintf("http://%s:%d/db/%s/tx/commit", req.Host, port, db)
|
||||
resp, err := s.checkWithURL(url, encodedAuth, db)
|
||||
if err == nil && resp.Success {
|
||||
version = resp.Version
|
||||
// 连接成功,检查或创建数据库记录
|
||||
log.Printf("[Check] Neo4j 连接成功,准备获取/创建数据库记录, host=%s, port=%d, db=%s", req.Host, req.Port, db)
|
||||
dbInfo, err := s.ensureNeo4jDatabase(req, db)
|
||||
if err != nil {
|
||||
log.Printf("[Check] 确保数据库记录失败: %v", err)
|
||||
}
|
||||
log.Printf("[Check] 数据库记录ID: %s, Name: %s", dbInfo.ID, dbInfo.Name)
|
||||
return &model.Neo4jCheckResponse{
|
||||
Success: true,
|
||||
Message: "connection successful",
|
||||
Version: version,
|
||||
Databases: []string{db},
|
||||
DatabaseID: dbInfo.ID,
|
||||
Name: dbInfo.Name,
|
||||
Description: dbInfo.Description,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return &model.Neo4jCheckResponse{
|
||||
Success: false,
|
||||
Message: fmt.Sprintf("connection failed on all ports (7474, %d)", req.Port),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Neo4jDatabaseInfo Neo4j 数据库记录信息
|
||||
type Neo4jDatabaseInfo struct {
|
||||
ID string
|
||||
Name string
|
||||
Description string
|
||||
}
|
||||
|
||||
// ensureNeo4jDatabase 确保 Neo4j 数据库记录存在
|
||||
func (s *Neo4jService) ensureNeo4jDatabase(req model.Neo4jCheckRequest, dbName string) (*Neo4jDatabaseInfo, error) {
|
||||
log.Printf("[ensureNeo4jDatabase] 开始处理, host=%s, port=%d, username=%s, dbName=%s, uri=%s", req.Host, req.Port, req.Username, dbName, req.URI)
|
||||
|
||||
// 根据 host, port, username, database 查找是否已存在
|
||||
databases, err := s.databaseRepo.FindAll()
|
||||
if err != nil {
|
||||
log.Printf("[ensureNeo4jDatabase] FindAll 失败: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
log.Printf("[ensureNeo4jDatabase] 找到 %d 条数据库记录", len(databases))
|
||||
|
||||
// 构建 URI
|
||||
uri := req.URI
|
||||
if uri == "" {
|
||||
uri = fmt.Sprintf("bolt://%s:%d", req.Host, req.Port)
|
||||
}
|
||||
log.Printf("[ensureNeo4jDatabase] 使用 URI: %s", uri)
|
||||
|
||||
// 查找已存在的记录
|
||||
for _, d := range databases {
|
||||
log.Printf("[ensureNeo4jDatabase] 对比: URI=%s, Username=%s, Database=%s", d.URI, d.Username, d.Database)
|
||||
if d.URI == uri && d.Username == req.Username && d.Database == dbName {
|
||||
log.Printf("[ensureNeo4jDatabase] 找到已存在的记录, id=%s, name=%s", d.ID, d.Name)
|
||||
return &Neo4jDatabaseInfo{
|
||||
ID: d.ID,
|
||||
Name: d.Name,
|
||||
Description: d.Description,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 不存在,创建新记录
|
||||
log.Printf("[ensureNeo4jDatabase] 未找到匹配记录,创建新记录")
|
||||
dbType := "neo4j"
|
||||
name := req.Name
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("Neo4j-%s", dbName)
|
||||
}
|
||||
description := req.Description
|
||||
if description == "" {
|
||||
description = fmt.Sprintf("Neo4j %s@%s:%d", dbName, req.Host, req.Port)
|
||||
}
|
||||
|
||||
newDB := &model.DatabaseInfo{
|
||||
ID: uuid.New().String(),
|
||||
Name: name,
|
||||
DBType: dbType,
|
||||
Host: req.Host,
|
||||
Port: req.Port,
|
||||
Username: req.Username,
|
||||
Password: req.Password,
|
||||
Database: dbName,
|
||||
URI: uri,
|
||||
Description: description,
|
||||
}
|
||||
|
||||
log.Printf("[ensureNeo4jDatabase] 创建新数据库: ID=%s, Name=%s, Host=%s, Port=%d, URI=%s", newDB.ID, newDB.Name, newDB.Host, newDB.Port, newDB.URI)
|
||||
|
||||
if err := s.databaseRepo.Create(newDB); err != nil {
|
||||
log.Printf("[ensureNeo4jDatabase] Create 失败: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Printf("[ensureNeo4jDatabase] 创建成功, 返回 ID=%s", newDB.ID)
|
||||
return &Neo4jDatabaseInfo{
|
||||
ID: newDB.ID,
|
||||
Name: newDB.Name,
|
||||
Description: newDB.Description,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetGraphOverview 获取图谱概览数据
|
||||
func (s *Neo4jService) GetGraphOverview(req model.CheckRequest) (*model.GraphOverview, error) {
|
||||
// 从 CheckRequest 获取连接信息
|
||||
// URI 可能是 bolt://host:7687 格式
|
||||
host := req.Host
|
||||
port := req.Port
|
||||
if req.URI != "" {
|
||||
// 解析 URI
|
||||
uri := strings.TrimPrefix(req.URI, "bolt://")
|
||||
uri = strings.TrimPrefix(uri, "neo4j://")
|
||||
if idx := strings.Index(uri, ":"); idx > 0 {
|
||||
host = uri[:idx]
|
||||
fmt.Sscanf(uri[idx+1:], "%d", &port)
|
||||
}
|
||||
}
|
||||
|
||||
db := req.Database
|
||||
if db == "" {
|
||||
db = "neo4j"
|
||||
}
|
||||
|
||||
auth := fmt.Sprintf("%s:%s", req.Username, req.Password)
|
||||
encodedAuth := base64.StdEncoding.EncodeToString([]byte(auth))
|
||||
|
||||
// 尝试多个端口
|
||||
ports := []int{port - 1000, 7474, port}
|
||||
checkedPorts := make(map[int]bool)
|
||||
|
||||
var graph *model.GraphOverview
|
||||
for _, p := range ports {
|
||||
if checkedPorts[p] {
|
||||
continue
|
||||
}
|
||||
checkedPorts[p] = true
|
||||
|
||||
url := fmt.Sprintf("http://%s:%d/db/%s/tx/commit", host, p, db)
|
||||
var err error
|
||||
graph, err = s.getGraphOverviewWithURL(url, encodedAuth, db)
|
||||
if err == nil && graph != nil {
|
||||
return graph, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("failed to connect to Neo4j")
|
||||
}
|
||||
|
||||
func (s *Neo4jService) getGraphOverviewWithURL(url, encodedAuth, db string) (*model.GraphOverview, error) {
|
||||
// 查询所有标签及其数量
|
||||
labelsQuery := `CALL db.labels() YIELD label RETURN label`
|
||||
relTypesQuery := `CALL db.relationshipTypes() YIELD relationshipType RETURN relationshipType`
|
||||
|
||||
// 构建复合查询
|
||||
query := fmt.Sprintf(`{"statements": [
|
||||
{"statement": "%s", "resultDataContents": ["row"]},
|
||||
{"statement": "%s", "resultDataContents": ["row"]},
|
||||
{"statement": "MATCH (n) RETURN labels(n) as nodeLabels, count(*) as count", "resultDataContents": ["row"]},
|
||||
{"statement": "MATCH ()-[r]->() RETURN type(r) as relType, count(*) as count", "resultDataContents": ["row"]}
|
||||
]}`, labelsQuery, relTypesQuery)
|
||||
|
||||
reqBody := strings.NewReader(query)
|
||||
httpReq, err := http.NewRequest("POST", url, reqBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Basic "+encodedAuth)
|
||||
|
||||
resp, err := s.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 检查错误
|
||||
if errors, ok := result["errors"].([]interface{}); ok && len(errors) > 0 {
|
||||
if errMap, ok := errors[0].(map[string]interface{}); ok {
|
||||
if msg, ok := errMap["message"].(string); ok {
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("query error")
|
||||
}
|
||||
|
||||
// 解析结果
|
||||
results, ok := result["results"].([]interface{})
|
||||
if !ok || len(results) < 4 {
|
||||
return nil, fmt.Errorf("invalid response format")
|
||||
}
|
||||
|
||||
graph := &model.GraphOverview{
|
||||
Labels: []model.LabelCount{},
|
||||
RelationshipTypes: []model.RelTypeCount{},
|
||||
Nodes: []model.NodeProperty{},
|
||||
Relationships: []model.RelProperty{},
|
||||
}
|
||||
|
||||
// 解析标签
|
||||
if len(results) > 0 {
|
||||
if res0, ok := results[0].(map[string]interface{}); ok {
|
||||
if data, ok := res0["data"].([]interface{}); ok {
|
||||
for _, item := range data {
|
||||
if row, ok := item.(map[string]interface{}); ok {
|
||||
if label, ok := row["row"].([]interface{}); ok && len(label) > 0 {
|
||||
graph.Labels = append(graph.Labels, model.LabelCount{
|
||||
Name: fmt.Sprintf("%v", label[0]),
|
||||
Count: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 解析关系类型
|
||||
if len(results) > 1 {
|
||||
if res1, ok := results[1].(map[string]interface{}); ok {
|
||||
if data, ok := res1["data"].([]interface{}); ok {
|
||||
for _, item := range data {
|
||||
if row, ok := item.(map[string]interface{}); ok {
|
||||
if relType, ok := row["row"].([]interface{}); ok && len(relType) > 0 {
|
||||
graph.RelationshipTypes = append(graph.RelationshipTypes, model.RelTypeCount{
|
||||
Name: fmt.Sprintf("%v", relType[0]),
|
||||
Count: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 解析节点统计
|
||||
if len(results) > 2 {
|
||||
if res2, ok := results[2].(map[string]interface{}); ok {
|
||||
if data, ok := res2["data"].([]interface{}); ok {
|
||||
for _, item := range data {
|
||||
if row, ok := item.(map[string]interface{}); ok {
|
||||
if rowData, ok := row["row"].([]interface{}); ok && len(rowData) >= 2 {
|
||||
if labels, ok := rowData[0].([]interface{}); ok && len(labels) > 0 {
|
||||
labelName := fmt.Sprintf("%v", labels[0])
|
||||
count := int(rowData[1].(float64))
|
||||
|
||||
// 更新标签数量
|
||||
for i := range graph.Labels {
|
||||
if graph.Labels[i].Name == labelName {
|
||||
graph.Labels[i].Count = count
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 解析关系统计
|
||||
if len(results) > 3 {
|
||||
if res3, ok := results[3].(map[string]interface{}); ok {
|
||||
if data, ok := res3["data"].([]interface{}); ok {
|
||||
for _, item := range data {
|
||||
if row, ok := item.(map[string]interface{}); ok {
|
||||
if rowData, ok := row["row"].([]interface{}); ok && len(rowData) >= 2 {
|
||||
relTypeName := fmt.Sprintf("%v", rowData[0])
|
||||
count := int(rowData[1].(float64))
|
||||
|
||||
// 更新关系类型数量
|
||||
for i := range graph.RelationshipTypes {
|
||||
if graph.RelationshipTypes[i].Name == relTypeName {
|
||||
graph.RelationshipTypes[i].Count = count
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取节点属性定义
|
||||
s.fillNodeProperties(url, encodedAuth, db, graph)
|
||||
// 获取关系属性定义
|
||||
s.fillRelProperties(url, encodedAuth, db, graph)
|
||||
|
||||
return graph, nil
|
||||
}
|
||||
|
||||
func (s *Neo4jService) fillNodeProperties(url, encodedAuth, db string, graph *model.GraphOverview) {
|
||||
// 获取每个标签的属性
|
||||
for i, label := range graph.Labels {
|
||||
query := fmt.Sprintf(`MATCH (n:%s) RETURN properties(n) as props LIMIT 1`, label.Name)
|
||||
body := fmt.Sprintf(`{"statements": [{"statement": "%s", "resultDataContents": ["row"]}]}`, query)
|
||||
|
||||
reqBody := strings.NewReader(body)
|
||||
httpReq, err := http.NewRequest("POST", url, reqBody)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Basic "+encodedAuth)
|
||||
|
||||
resp, err := s.client.Do(httpReq)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
results, ok := result["results"].([]interface{})
|
||||
if !ok || len(results) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if res0, ok := results[0].(map[string]interface{}); ok {
|
||||
if data, ok := res0["data"].([]interface{}); ok && len(data) > 0 {
|
||||
if item, ok := data[0].(map[string]interface{}); ok {
|
||||
if row, ok := item["row"].([]interface{}); ok && len(row) > 0 {
|
||||
if props, ok := row[0].(map[string]interface{}); ok {
|
||||
nodeProp := model.NodeProperty{
|
||||
Label: label.Name,
|
||||
Properties: []model.PropertyInfo{},
|
||||
}
|
||||
for name, value := range props {
|
||||
nodeProp.Properties = append(nodeProp.Properties, model.PropertyInfo{
|
||||
Name: name,
|
||||
Type: fmt.Sprintf("%T", value),
|
||||
})
|
||||
}
|
||||
graph.Nodes = append(graph.Nodes, nodeProp)
|
||||
graph.Labels[i].Count = label.Count
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Neo4jService) fillRelProperties(url, encodedAuth, db string, graph *model.GraphOverview) {
|
||||
// 获取每个关系类型的属性
|
||||
for _, relType := range graph.RelationshipTypes {
|
||||
query := fmt.Sprintf(`MATCH ()-[r:%s]->() RETURN properties(r) as props LIMIT 1`, relType.Name)
|
||||
body := fmt.Sprintf(`{"statements": [{"statement": "%s", "resultDataContents": ["row"]}]}`, query)
|
||||
|
||||
reqBody := strings.NewReader(body)
|
||||
httpReq, err := http.NewRequest("POST", url, reqBody)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Basic "+encodedAuth)
|
||||
|
||||
resp, err := s.client.Do(httpReq)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
results, ok := result["results"].([]interface{})
|
||||
if !ok || len(results) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if res0, ok := results[0].(map[string]interface{}); ok {
|
||||
if data, ok := res0["data"].([]interface{}); ok && len(data) > 0 {
|
||||
if item, ok := data[0].(map[string]interface{}); ok {
|
||||
if row, ok := item["row"].([]interface{}); ok && len(row) > 0 {
|
||||
if props, ok := row[0].(map[string]interface{}); ok {
|
||||
relProp := model.RelProperty{
|
||||
Type: relType.Name,
|
||||
Properties: []model.PropertyInfo{},
|
||||
}
|
||||
for name, value := range props {
|
||||
relProp.Properties = append(relProp.Properties, model.PropertyInfo{
|
||||
Name: name,
|
||||
Type: fmt.Sprintf("%T", value),
|
||||
})
|
||||
}
|
||||
graph.Relationships = append(graph.Relationships, relProp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetNodes 获取节点详情
|
||||
func (s *Neo4jService) GetNodes(req model.Neo4jNodeRequest) (*model.Neo4jNodeResponse, error) {
|
||||
host := "localhost"
|
||||
port := 7687
|
||||
if req.URI != "" {
|
||||
uri := strings.TrimPrefix(req.URI, "bolt://")
|
||||
uri = strings.TrimPrefix(uri, "neo4j://")
|
||||
if idx := strings.Index(uri, ":"); idx > 0 {
|
||||
host = uri[:idx]
|
||||
fmt.Sscanf(uri[idx+1:], "%d", &port)
|
||||
}
|
||||
}
|
||||
|
||||
db := req.Database
|
||||
if db == "" {
|
||||
db = "neo4j"
|
||||
}
|
||||
|
||||
limit := req.Limit
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
auth := fmt.Sprintf("%s:%s", req.Username, req.Password)
|
||||
encodedAuth := base64.StdEncoding.EncodeToString([]byte(auth))
|
||||
|
||||
ports := []int{port - 1000, 7474, port}
|
||||
checkedPorts := make(map[int]bool)
|
||||
|
||||
for _, p := range ports {
|
||||
if checkedPorts[p] {
|
||||
continue
|
||||
}
|
||||
checkedPorts[p] = true
|
||||
|
||||
url := fmt.Sprintf("http://%s:%d/db/%s/tx/commit", host, p, db)
|
||||
nodes, props, err := s.getNodesWithURL(url, encodedAuth, req.Label, limit)
|
||||
if err == nil {
|
||||
return &model.Neo4jNodeResponse{
|
||||
Success: true,
|
||||
Message: "success",
|
||||
Nodes: nodes,
|
||||
Properties: props,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return &model.Neo4jNodeResponse{
|
||||
Success: false,
|
||||
Message: "failed to connect to Neo4j",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Neo4jService) getNodesWithURL(url, encodedAuth, label string, limit int) ([]map[string]interface{}, []model.PropertyInfo, error) {
|
||||
query := fmt.Sprintf(`MATCH (n:%s) RETURN properties(n) as props, elementId(n) as id LIMIT %d`, label, limit)
|
||||
body := fmt.Sprintf(`{"statements": [{"statement": "%s", "resultDataContents": ["row"]}]}`, query)
|
||||
|
||||
reqBody := strings.NewReader(body)
|
||||
httpReq, err := http.NewRequest("POST", url, reqBody)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Basic "+encodedAuth)
|
||||
|
||||
resp, err := s.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if errors, ok := result["errors"].([]interface{}); ok && len(errors) > 0 {
|
||||
if errMap, ok := errors[0].(map[string]interface{}); ok {
|
||||
if msg, ok := errMap["message"].(string); ok {
|
||||
return nil, nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
}
|
||||
return nil, nil, fmt.Errorf("query error")
|
||||
}
|
||||
|
||||
results, ok := result["results"].([]interface{})
|
||||
if !ok || len(results) == 0 {
|
||||
return []map[string]interface{}{}, []model.PropertyInfo{}, nil
|
||||
}
|
||||
|
||||
var nodes []map[string]interface{}
|
||||
propsMap := make(map[string]string) // 用于去重属性
|
||||
|
||||
if res0, ok := results[0].(map[string]interface{}); ok {
|
||||
if data, ok := res0["data"].([]interface{}); ok {
|
||||
for _, item := range data {
|
||||
if row, ok := item.(map[string]interface{}); ok {
|
||||
if rowData, ok := row["row"].([]interface{}); ok && len(rowData) >= 2 {
|
||||
node := map[string]interface{}{
|
||||
"id": rowData[1],
|
||||
}
|
||||
if props, ok := rowData[0].(map[string]interface{}); ok {
|
||||
for k, v := range props {
|
||||
node[k] = v
|
||||
// 收集属性类型
|
||||
if _, exists := propsMap[k]; !exists {
|
||||
propsMap[k] = fmt.Sprintf("%T", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
nodes = append(nodes, node)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 构建属性列表
|
||||
var properties []model.PropertyInfo
|
||||
for name, propType := range propsMap {
|
||||
properties = append(properties, model.PropertyInfo{
|
||||
Name: name,
|
||||
Type: propType,
|
||||
})
|
||||
}
|
||||
|
||||
return nodes, properties, nil
|
||||
}
|
||||
|
||||
// GetRelationships 获取关系详情
|
||||
func (s *Neo4jService) GetRelationships(req model.Neo4jRelRequest) (*model.Neo4jRelResponse, error) {
|
||||
host := "localhost"
|
||||
port := 7687
|
||||
if req.URI != "" {
|
||||
uri := strings.TrimPrefix(req.URI, "bolt://")
|
||||
uri = strings.TrimPrefix(uri, "neo4j://")
|
||||
if idx := strings.Index(uri, ":"); idx > 0 {
|
||||
host = uri[:idx]
|
||||
fmt.Sscanf(uri[idx+1:], "%d", &port)
|
||||
}
|
||||
}
|
||||
|
||||
db := req.Database
|
||||
if db == "" {
|
||||
db = "neo4j"
|
||||
}
|
||||
|
||||
limit := req.Limit
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
auth := fmt.Sprintf("%s:%s", req.Username, req.Password)
|
||||
encodedAuth := base64.StdEncoding.EncodeToString([]byte(auth))
|
||||
|
||||
ports := []int{port - 1000, 7474, port}
|
||||
checkedPorts := make(map[int]bool)
|
||||
|
||||
for _, p := range ports {
|
||||
if checkedPorts[p] {
|
||||
continue
|
||||
}
|
||||
checkedPorts[p] = true
|
||||
|
||||
url := fmt.Sprintf("http://%s:%d/db/%s/tx/commit", host, p, db)
|
||||
rels, err := s.getRelationshipsWithURL(url, encodedAuth, req.RelationshipType, limit)
|
||||
if err == nil {
|
||||
return &model.Neo4jRelResponse{
|
||||
Success: true,
|
||||
Message: "success",
|
||||
Relationships: rels,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return &model.Neo4jRelResponse{
|
||||
Success: false,
|
||||
Message: "failed to connect to Neo4j",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Neo4jService) getRelationshipsWithURL(url, encodedAuth, relType string, limit int) ([]model.Neo4jRelationship, error) {
|
||||
query := fmt.Sprintf(`MATCH (a)-[r:%s]->(b) RETURN properties(r) as props, elementId(a) as startId, elementId(b) as endId, labels(a) as startLabels, labels(b) as endLabels, elementId(r) as relId LIMIT %d`, relType, limit)
|
||||
body := fmt.Sprintf(`{"statements": [{"statement": "%s", "resultDataContents": ["row"]}]}`, query)
|
||||
|
||||
reqBody := strings.NewReader(body)
|
||||
httpReq, err := http.NewRequest("POST", url, reqBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Basic "+encodedAuth)
|
||||
|
||||
resp, err := s.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if errors, ok := result["errors"].([]interface{}); ok && len(errors) > 0 {
|
||||
if errMap, ok := errors[0].(map[string]interface{}); ok {
|
||||
if msg, ok := errMap["message"].(string); ok {
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("query error")
|
||||
}
|
||||
|
||||
results, ok := result["results"].([]interface{})
|
||||
if !ok || len(results) == 0 {
|
||||
return []model.Neo4jRelationship{}, nil
|
||||
}
|
||||
|
||||
var rels []model.Neo4jRelationship
|
||||
if res0, ok := results[0].(map[string]interface{}); ok {
|
||||
if data, ok := res0["data"].([]interface{}); ok {
|
||||
for _, item := range data {
|
||||
if row, ok := item.(map[string]interface{}); ok {
|
||||
if rowData, ok := row["row"].([]interface{}); ok && len(rowData) >= 6 {
|
||||
rel := model.Neo4jRelationship{
|
||||
ID: fmt.Sprintf("%v", rowData[5]),
|
||||
Source: fmt.Sprintf("%v", rowData[1]),
|
||||
Target: fmt.Sprintf("%v", rowData[2]),
|
||||
}
|
||||
if props, ok := rowData[0].(map[string]interface{}); ok {
|
||||
rel.Properties = props
|
||||
}
|
||||
rels = append(rels, rel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rels, nil
|
||||
}
|
||||
|
||||
func (s *Neo4jService) checkWithURL(url, encodedAuth, db string) (*model.Neo4jCheckResponse, error) {
|
||||
query := `{"statements": [{"statement": "RETURN 1 AS num"}]}`
|
||||
reqBody := strings.NewReader(query)
|
||||
|
||||
httpReq, err := http.NewRequest("POST", url, reqBody)
|
||||
if err != nil {
|
||||
return &model.Neo4jCheckResponse{
|
||||
Success: false,
|
||||
Message: fmt.Sprintf("failed to create request: %v", err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Basic "+encodedAuth)
|
||||
|
||||
resp, err := s.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return &model.Neo4jCheckResponse{
|
||||
Success: false,
|
||||
Message: fmt.Sprintf("failed to read response: %v", err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return &model.Neo4jCheckResponse{
|
||||
Success: false,
|
||||
Message: fmt.Sprintf("failed to parse response: %v", err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
if errors, ok := result["errors"].([]interface{}); ok && len(errors) > 0 {
|
||||
msg := "connection failed"
|
||||
if errMap, ok := errors[0].(map[string]interface{}); ok {
|
||||
if m, ok := errMap["message"].(string); ok {
|
||||
msg = m
|
||||
}
|
||||
}
|
||||
return &model.Neo4jCheckResponse{
|
||||
Success: false,
|
||||
Message: msg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
version := "unknown"
|
||||
if resp.Header.Get("X-Neo4j-Version") != "" {
|
||||
version = resp.Header.Get("X-Neo4j-Version")
|
||||
}
|
||||
|
||||
return &model.Neo4jCheckResponse{
|
||||
Success: true,
|
||||
Message: "connection successful",
|
||||
Version: version,
|
||||
Databases: []string{db},
|
||||
}, nil
|
||||
}
|
||||
BIN
server/server.exe
Normal file
BIN
server/server.exe
Normal file
Binary file not shown.
@@ -1,122 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Teacher struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Name string `gorm:"size:50;charset=utf8mb4"`
|
||||
Subject string `gorm:"size:50;charset=utf8mb4"`
|
||||
Phone string `gorm:"size:20"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Student struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Name string `gorm:"size:50;charset=utf8mb4"`
|
||||
Age int
|
||||
Gender string `gorm:"size:10;charset=utf8mb4"`
|
||||
Class string `gorm:"size:50;charset=utf8mb4"`
|
||||
Phone string `gorm:"size:20"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Score struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
StudentID uint
|
||||
Subject string `gorm:"size:50;charset=utf8mb4"`
|
||||
Score float64
|
||||
TeacherID uint
|
||||
ExamDate time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func main() {
|
||||
dsn := "root:881116142@tcp(10.10.10.189:3306)/students?charset=utf8mb4&parseTime=True&loc=Local"
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
panic("连接数据库失败: " + err.Error())
|
||||
}
|
||||
|
||||
// 自动迁移表
|
||||
db.AutoMigrate(&Teacher{}, &Student{}, &Score{})
|
||||
|
||||
// 清理旧数据
|
||||
db.Exec("DELETE FROM scores")
|
||||
db.Exec("DELETE FROM students")
|
||||
db.Exec("DELETE FROM teachers")
|
||||
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
|
||||
// 创建教师
|
||||
teachers := []Teacher{
|
||||
{Name: "张老师", Subject: "数学", Phone: "13800001001"},
|
||||
{Name: "李老师", Subject: "语文", Phone: "13800001002"},
|
||||
{Name: "王老师", Subject: "英语", Phone: "13800001003"},
|
||||
{Name: "刘老师", Subject: "物理", Phone: "13800001004"},
|
||||
{Name: "陈老师", Subject: "化学", Phone: "13800001005"},
|
||||
{Name: "杨老师", Subject: "生物", Phone: "13800001006"},
|
||||
{Name: "赵老师", Subject: "历史", Phone: "13800001007"},
|
||||
{Name: "周老师", Subject: "地理", Phone: "13800001008"},
|
||||
}
|
||||
db.Create(&teachers)
|
||||
|
||||
// 创建30个学生
|
||||
names := []string{"张三", "李四", "王五", "刘六", "陈七", "杨八", "赵九", "钱十",
|
||||
"孙一", "周二", "吴三", "郑四", "冯五", "褚六", "卫七", "蒋八",
|
||||
"沈九", "韩十", "朱十一", "秦十二", "许十三", "何十四", "吕十五", "施十六",
|
||||
"张十七", "孔十八", "曹十九", "严二十", "华二十一", "金二十二"}
|
||||
genders := []string{"男", "女"}
|
||||
classes := []string{"高一(1)班", "高一(2)班", "高一(3)班", "高二(1)班", "高二(2)班"}
|
||||
|
||||
students := make([]Student, 30)
|
||||
for i := 0; i < 30; i++ {
|
||||
students[i] = Student{
|
||||
Name: names[i],
|
||||
Age: 15 + rand.Intn(3),
|
||||
Gender: genders[rand.Intn(len(genders))],
|
||||
Class: classes[rand.Intn(len(classes))],
|
||||
Phone: fmt.Sprintf("139%08d", 10000000+rand.Intn(90000000)),
|
||||
}
|
||||
}
|
||||
db.Create(&students)
|
||||
|
||||
// 为每个学生创建成绩记录
|
||||
subjects := []string{"数学", "语文", "英语", "物理", "化学", "生物", "历史", "地理"}
|
||||
scores := make([]Score, 0)
|
||||
|
||||
for i := 0; i < 30; i++ {
|
||||
numSubjects := 4 + rand.Intn(3)
|
||||
selectedSubjects := make(map[string]bool)
|
||||
for len(selectedSubjects) < numSubjects {
|
||||
subj := subjects[rand.Intn(len(subjects))]
|
||||
if !selectedSubjects[subj] {
|
||||
selectedSubjects[subj] = true
|
||||
|
||||
teacherID := uint(1 + rand.Intn(len(teachers)))
|
||||
examDate := time.Now().AddDate(0, -rand.Intn(6), -rand.Intn(30))
|
||||
|
||||
score := Score{
|
||||
StudentID: students[i].ID,
|
||||
Subject: subj,
|
||||
Score: 60 + rand.Float64()*40,
|
||||
TeacherID: teacherID,
|
||||
ExamDate: examDate,
|
||||
}
|
||||
scores = append(scores, score)
|
||||
}
|
||||
}
|
||||
}
|
||||
db.Create(&scores)
|
||||
|
||||
fmt.Println("数据创建成功!")
|
||||
fmt.Printf("教师: %d 条\n", len(teachers))
|
||||
fmt.Printf("学生: %d 条\n", len(students))
|
||||
fmt.Printf("成绩: %d 条\n", len(scores))
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dsn := "root:881116142@tcp(10.10.10.189:3306)/mysql?charset=utf8mb4&parseTime=True&loc=Local"
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
panic("连接失败: " + err.Error())
|
||||
}
|
||||
|
||||
type User struct {
|
||||
User string
|
||||
Host string
|
||||
}
|
||||
|
||||
var users []User
|
||||
db.Raw("SELECT User, Host FROM mysql.user WHERE User='root'").Scan(&users)
|
||||
|
||||
println("Root 用户列表:")
|
||||
for _, u := range users {
|
||||
println("- User: " + u.User + ", Host: " + u.Host)
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dsn := "root:881116142@tcp(10.10.10.189:3306)/mysql?charset=utf8mb4&parseTime=True&loc=Local"
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
panic("连接失败: " + err.Error())
|
||||
}
|
||||
|
||||
// 使用 mysql_native_password 插件重建用户
|
||||
sqls := []string{
|
||||
"DROP USER IF EXISTS 'root'@'%'",
|
||||
"CREATE USER 'root'@'%' IDENTIFIED WITH mysql_native_password BY '881116142'",
|
||||
"GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' WITH GRANT OPTION",
|
||||
"FLUSH PRIVILEGES",
|
||||
}
|
||||
|
||||
for _, sql := range sqls {
|
||||
if err := db.Exec(sql).Error; err != nil {
|
||||
println("执行: " + sql + " - 错误: " + err.Error())
|
||||
} else {
|
||||
println("成功: " + sql)
|
||||
}
|
||||
}
|
||||
|
||||
println("完成! 用 mysql_native_password 插件重建了 root 用户")
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 尝试用 root 用户连接,但指定 IP
|
||||
dsn := "root:881116142@tcp(127.0.0.1:3306)/mysql?charset=utf8mb4&parseTime=True&loc=Local&multiStatements=true"
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
// 尝试其他方式
|
||||
dsn2 := "root:881116142@tcp(localhost:3306)/mysql?charset=utf8mb4&parseTime=True&loc=Local&multiStatements=true"
|
||||
db, err = gorm.Open(mysql.Open(dsn2), &gorm.Config{})
|
||||
if err != nil {
|
||||
println("无法连接,请通过其他方式在 MySQL 服务器上执行:")
|
||||
println("CREATE USER IF NOT EXISTS 'root'@'%' IDENTIFIED BY '881116142';")
|
||||
println("GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' WITH GRANT OPTION;")
|
||||
println("FLUSH PRIVILEGES;")
|
||||
panic("连接失败: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新用户
|
||||
sqls := []string{
|
||||
"CREATE USER IF NOT EXISTS 'admin'@'%' IDENTIFIED BY 'admin123'",
|
||||
"GRANT ALL PRIVILEGES ON *.* TO 'admin'@'%' WITH GRANT OPTION",
|
||||
"FLUSH PRIVILEGES",
|
||||
}
|
||||
|
||||
for _, sql := range sqls {
|
||||
if err := db.Exec(sql).Error; err != nil {
|
||||
println("执行: " + sql + " - 错误: " + err.Error())
|
||||
} else {
|
||||
println("执行成功: " + sql)
|
||||
}
|
||||
}
|
||||
|
||||
println("创建了新用户 admin,可以用这个连接 Navicat")
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dsn := "root:881116142@tcp(10.10.10.189:3306)/mysql?charset=utf8mb4&parseTime=True&loc=Local&multiStatements=true"
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
panic("连接失败: " + err.Error())
|
||||
}
|
||||
|
||||
// 重建 root@% 用户并设置密码
|
||||
sqls := []string{
|
||||
"DROP USER IF EXISTS 'root'@'%'",
|
||||
"CREATE USER 'root'@'%' IDENTIFIED BY '881116142'",
|
||||
"GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' WITH GRANT OPTION",
|
||||
"FLUSH PRIVILEGES",
|
||||
}
|
||||
|
||||
for _, sql := range sqls {
|
||||
if err := db.Exec(sql).Error; err != nil {
|
||||
println("执行: " + sql + " - 错误: " + err.Error())
|
||||
} else {
|
||||
println("执行成功: " + sql)
|
||||
}
|
||||
}
|
||||
|
||||
println("完成!")
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dsn := "root:881116142@tcp(10.10.10.189:3306)/mysql?charset=utf8mb4&parseTime=True&loc=Local&allowOldStrings=true"
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
panic("连接失败: " + err.Error())
|
||||
}
|
||||
|
||||
// 删除所有 root 用户
|
||||
sqls := []string{
|
||||
"DROP USER IF EXISTS 'root'@'%'",
|
||||
"DROP USER IF EXISTS 'root'@'10.10.10.122'",
|
||||
"DROP USER IF EXISTS 'root'@'localhost'",
|
||||
"DROP USER IF EXISTS 'root'@'127.0.0.1'",
|
||||
"CREATE USER 'root'@'%' IDENTIFIED BY '881116142'",
|
||||
"GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' WITH GRANT OPTION",
|
||||
"FLUSH PRIVILEGES",
|
||||
}
|
||||
|
||||
for _, sql := range sqls {
|
||||
if err := db.Exec(sql).Error; err != nil {
|
||||
println("执行: " + sql + " - 错误: " + err.Error())
|
||||
} else {
|
||||
println("成功: " + sql)
|
||||
}
|
||||
}
|
||||
|
||||
println("完成!")
|
||||
}
|
||||
Reference in New Issue
Block a user