commit 67b0ad27231d6e0e8948d515dc4951d608cc3edc Author: Wyle.Gong-巩文昕 Date: Tue Apr 22 16:42:48 2025 +0800 init diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ef5cd8a --- /dev/null +++ b/Makefile @@ -0,0 +1,9 @@ +# +# Makefile +# Copyright (C) 2024 veypi +// 2025-02-27 19:32:09 +# Distributed under terms of the GPL license. +# + +run: + @go run ./cli/*.go -f ./cfg/dev.yaml diff --git a/OneAuth b/OneAuth new file mode 160000 index 0000000..b32b128 --- /dev/null +++ b/OneAuth @@ -0,0 +1 @@ +Subproject commit b32b12878c2b17451d5a09b305d856a88428413c diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..0b28ff6 --- /dev/null +++ b/TODO.md @@ -0,0 +1,7 @@ +模型生成需求 + +name: + +description: + +parent_name: diff --git a/api/.DS_Store b/api/.DS_Store new file mode 100644 index 0000000..fcde284 Binary files /dev/null and b/api/.DS_Store differ diff --git a/api/demand/handler.go b/api/demand/handler.go new file mode 100644 index 0000000..6f451b8 --- /dev/null +++ b/api/demand/handler.go @@ -0,0 +1,265 @@ +package demand + +import ( + "app/cfg" + "app/models" + "app/utils" + "encoding/json" + "errors" + "fmt" + "io" + + "github.com/veypi/OneBD/rest" + "gorm.io/gorm" +) + +var _ = Router.Get("/:id/descendants", getDescendantsHandle) +var _ = Router.Get("/:id/:min_level/", getHandle) +var _ = Router.Get("/:id/", getHandle) +var _ = Router.Get("", getHandle) +// 获取需求 +func getHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + id, ok := x.Params.Get("id") + minLevel, hasMinLevel := x.Params.Get("min_level") + doc_id := id + fmt.Println(doc_id) + + // 获取所有根节点需求 + var demands []models.Demand + query := cfg.DB() + + // 如果指定了最小级别,添加筛选条件 + if hasMinLevel { + minLevelInt := 0 + fmt.Sscanf(minLevel, "%d", &minLevelInt) + query = query.Where("level >= ?", minLevelInt) + } + if !ok || id == "-1" { + if err := query.Find(&demands).Error; err != nil { + return nil, err + } + return demands, nil + } + if err := query.Where("doc_id = ?", doc_id).Find(&demands).Error; err != nil { + return nil, err + } + return demands, nil + // } + + // 获取特定节点及其子节点 + // var demand models.Demand + // if err := cfg.DB().Where("id = ?", id).First(&demand).Error; err != nil { + // return nil, err + // } + + // children, _ := demand.Children(cfg.DB()) + // var res = map[string]interface{}{ + // "node": demand, + // "children": children, + // } + + // return res, nil +} + +// 获取所有后代节点 +func getDescendantsHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + id, ok := x.Params.Get("id") + if !ok { + return nil, errors.New("缺少id") + } + + // 获取特定节点 + var demand models.Demand + if err := cfg.DB().Where("id = ?", id).First(&demand).Error; err != nil { + return nil, err + } + + // 获取所有子孙节点 + descendants, err := demand.Descendants(cfg.DB()) + if err != nil { + return nil, err + } + + return map[string]interface{}{ + "node": demand, + "descendants": descendants, + }, nil +} + +// 创建需求 +var _ = Router.Post("/:id/", postHandle) + +func postHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + docID, hasDocID := x.Params.Get("id") + if !hasDocID { + return nil, errors.New("缺少doc_id") + } + + demand := &models.Demand{} + if err := json.NewDecoder(x.Request.Body).Decode(&demand); err != nil { + return nil, err + } + fmt.Printf("demand: %v\n", demand) + fmt.Printf("demand.parentid: %v\n", demand.ParentID) + // 设置文档ID和UUID + demand.DocID = docID + // demand.ID = uuid.New().String()[0:32] + + // 设置级别 + if demand.ParentID == "-1" || demand.ParentID == "" { + demand.Level = 0 + } else { + var parent models.Demand + if err := cfg.DB().Where("id = ? AND doc_id = ?", demand.ParentID, docID).First(&parent).Error; err != nil { + return nil, fmt.Errorf("父节点不存在或不属于当前文档: %v", err) + } + demand.Level = parent.Level + 1 + } + + if err := cfg.DB().Create(&demand).Error; err != nil { + return nil, err + } + return demand, nil +} +var _ = Router.Patch("/:id/", patchHandle) + +// 更新需求 +func patchHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + body, err := io.ReadAll(x.Request.Body) + if err != nil { + return nil, fmt.Errorf("读取请求体失败: %v", err) + } + + id, ok := x.Params.Get("id") + if !ok { + return nil, errors.New("缺少id") + } + var updateMap map[string]interface{} + if err := json.Unmarshal(body, &updateMap); err != nil { + return nil, fmt.Errorf("解析JSON失败: %v", err) + } + + // 先检查需求是否存在 + var demand models.Demand + if err := cfg.DB().Where("id = ?", id).First(&demand).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("需求不存在") + } + return nil, err + } + + // 如果更新包含parent_id,需要特殊处理层级 + if parentID, ok := updateMap["parent_id"]; ok { + newParentID, ok := parentID.(string) + if !ok { + return nil, errors.New("parent_id 必须是字符串类型") + } + + // 检查是否形成循环引用 + if newParentID == demand.ID { + return nil, errors.New("不能将节点的父节点设置为自身") + } + + if newParentID != demand.ParentID { + var newLevel int + // 检查新的父节点是否存在(除非是设置为根节点) + if newParentID != "-1" && newParentID != "" { + var parent models.Demand + if err := cfg.DB().Where("id = ?", newParentID).First(&parent).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("父节点不存在") + } + return nil, err + } + + // 检查新父节点是否是当前节点的子节点 + children, err := demand.Children(cfg.DB()) + if err != nil { + return nil, err + } + for _, child := range children { + if child.ID == newParentID { + return nil, errors.New("不能将子节点设置为父节点") + } + } + + newLevel = parent.Level + 1 + } else { + newLevel = 0 + } + + updateMap["level"] = newLevel + } + } + + // 删除不允许更新的字段 + delete(updateMap, "id") + delete(updateMap, "doc_id") + + // 只更新提供的字段 + if len(updateMap) > 0 { + if err := cfg.DB().Model(&demand).Updates(updateMap).Error; err != nil { + return nil, err + } + } + + // 重新查询更新后的完整数据 + if err := cfg.DB().Where("id = ?", id).First(&demand).Error; err != nil { + return nil, err + } + + return demand, nil +} +var _ = Router.Delete("/:id/", deleteHandle) + +// 删除需求 +func deleteHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + id, ok := x.Params.Get("id") + if !ok { + return nil, errors.New("缺少id") + } + + var demand models.Demand + if err := cfg.DB().Where("id = ?", id).First(&demand).Error; err != nil { + return nil, err + } + + // 删除需求及其所有子需求 + descendants, err := demand.Descendants(cfg.DB()) + if err != nil { + return nil, err + } + + tx := cfg.DB().Begin() + + // 删除所有子需求 + for _, desc := range descendants { + if err := tx.Delete(&desc).Error; err != nil { + tx.Rollback() + return nil, err + } + } + + // 删除当前需求 + if err := tx.Delete(&demand).Error; err != nil { + tx.Rollback() + return nil, err + } + + tx.Commit() + + return map[string]interface{}{ + "msg": "删除成功", + }, nil +} +var _ = Router.Any("/*", anyHandle) + +func anyHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + return nil, nil +} diff --git a/api/demand/init.go b/api/demand/init.go new file mode 100644 index 0000000..4da98b8 --- /dev/null +++ b/api/demand/init.go @@ -0,0 +1,11 @@ +package demand + +import ( + "github.com/veypi/OneBD/rest" +) + +var Router = rest.NewRouter() + +func init() { + // 初始化路由 +} \ No newline at end of file diff --git a/api/doc/doc_processor.go b/api/doc/doc_processor.go new file mode 100644 index 0000000..64b2cc2 --- /dev/null +++ b/api/doc/doc_processor.go @@ -0,0 +1,619 @@ +package doc + +import ( + "app/cfg" + M "app/models" + "bytes" + "context" + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/invopop/jsonschema" + "github.com/ledongthuc/pdf" + "github.com/openai/openai-go" + "github.com/openai/openai-go/option" + "github.com/xeipuuv/gojsonschema" +) + +type Parameter struct { + Name string `json:"name" jsonschema:"required,description=参数名称"` + Location string `json:"location" jsonschema:"enum=query,enum=header,enum=body,enum=path,description=参数位置"` + Type string `json:"type,omitempty" jsonschema:"default=string,description=参数类型"` + ParamType string `json:"param_type,omitempty" jsonschema:"enum=application/json,enum=text/plain,enum=multipart/form-data,enum=application/x-www-form-urlencoded,enum=,description=只有position为body时有效,其他时候为空即可"` + Description string `json:"description" jsonschema:"required,description=参数描述"` + Value string `json:"value,omitempty" jsonschema:"description=参数示例值,统一用字符串形式,不要用json,如果是对象,需要转换成json字符串,一定要是纯字符串,绝对不要有字符串加字符串这种字符串间的拼接运算,还要注意字符串中不要给{}[]加转义字符,这样会导致错误"` +} + +type Response struct { + StatusCode int `json:"status_code" jsonschema:"required,description=HTTP状态码"` + Example string `json:"example" jsonschema:"description=响应示例,是json字符串,一定要是纯字符串,绝对不要有字符串加字符串这种字符串间的拼接运算,还要注意字符串中不要给{}[]加转义字符,这样会导致错误"` +} + +// APISpec represents an API specification +type APISpec struct { + Name string `json:"name" jsonschema:"required,description=API名称"` + Description string `json:"description" jsonschema:"required,description=API描述"` + Inputs []Parameter `json:"inputs" jsonschema:"description=输入参数列表"` + Outputs []Parameter `json:"outputs" jsonschema:"description=输出参数列表"` + Method string `json:"method" jsonschema:"required,enum=GET,enum=POST,enum=PUT,enum=DELETE,enum=PATCH,description=HTTP方法"` + Path string `json:"path" jsonschema:"required,description=API路径"` + BodyType string `json:"body_type,omitempty" jsonschema:"default=application/json,description=请求体类型"` + Response Response `json:"response" jsonschema:"required,description=API响应信息"` +} + +// APISpecList represents a list of API specifications +type APISpecList struct { + APIs []APISpec `json:"apis" jsonschema:"required,description=API列表"` + HasMore bool `json:"has_more" jsonschema:"required,description=是否还有更多API需要继续在下一次输出中继续输出"` + AnalysisPercent int `json:"analysis_percent" jsonschema:"required,description=估计的分析进度百分比,范围是0-100,不一定递增,可以调整,比如发现增长过快的时候可以减少,只有全部解析完毕才可以到100"` +} + +// ChatHistory represents the chat history +type ChatHistory struct { + Messages []openai.ChatCompletionMessageParamUnion + DocContent string + Schema string +} + +// setupLogger creates and configures the logger +func setupLogger() *log.Logger { + if err := os.MkdirAll("logs", 0755); err != nil { + log.Fatal(err) + } + + timestamp := time.Now().Format("20060102_150405") + logFilename := filepath.Join("logs", fmt.Sprintf("llm_output_%s.log", timestamp)) + + file, err := os.OpenFile(logFilename, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + if err != nil { + log.Fatal(err) + } + + return log.New(file, "", log.LstdFlags) +} + +// DocumentAnalyzer handles document analysis +type DocumentAnalyzer struct { + client *openai.Client + logger *log.Logger +} + +// NewDocumentAnalyzer creates a new DocumentAnalyzer instance +func NewDocumentAnalyzer() (*DocumentAnalyzer, error) { + client := openai.NewClient( + option.WithAPIKey("sk-0213c70194624703a1d0d80e0f762b0e"), + option.WithBaseURL("https://dashscope.aliyuncs.com/compatible-mode/v1/"), + // option.WithBaseURL("http://127.0.0.1:11434/"), + ) + fmt.Println("API client initialized successfully") + return &DocumentAnalyzer{ + client: client, + logger: setupLogger(), + }, nil +} + +// generateJSONSchema generates JSON schema from the APISpecList struct +func generateJSONSchema() ([]byte, error) { + reflector := jsonschema.Reflector{ + RequiredFromJSONSchemaTags: true, + AllowAdditionalProperties: true, + DoNotReference: true, + } + + schema := reflector.Reflect(&APISpecList{}) + + return json.MarshalIndent(schema, "", " ") +} + +// validateJSON validates the JSON response against the schema +func validateJSON(data []byte) error { + schema, err := generateJSONSchema() + if err != nil { + return fmt.Errorf("failed to generate schema: %v", err) + } + + schemaLoader := gojsonschema.NewBytesLoader(schema) + documentLoader := gojsonschema.NewBytesLoader(data) + + result, err := gojsonschema.Validate(schemaLoader, documentLoader) + if err != nil { + return fmt.Errorf("validation error: %v", err) + } + + if !result.Valid() { + var errors []string + for _, desc := range result.Errors() { + errors = append(errors, desc.String()) + } + return fmt.Errorf("invalid JSON: %v", errors) + } + + return nil +} + +// cleanJSONResponse cleans and validates the LLM response +func cleanJSONResponse(response string) (string, error) { + // Find the first { and last } + start := 0 + end := len(response) + + for i := 0; i < len(response); i++ { + if response[i] == '{' { + start = i + break + } + } + + for i := len(response) - 1; i >= 0; i-- { + if response[i] == '}' { + end = i + 1 + break + } + } + + if start >= end { + return "", fmt.Errorf("invalid JSON structure") + } + + jsonStr := response[start:end] + + // Validate the JSON + if err := validateJSON([]byte(jsonStr)); err != nil { + return "", fmt.Errorf("JSON validation failed: %v", err) + } + + return jsonStr, nil +} + +func (da *DocumentAnalyzer) repairJSON(ctx context.Context, malformedJSON string, originalError error) (string, error) { + prompt := fmt.Sprintf(`Fix this malformed JSON that had error: %v + + JSON to fix: + %s + + Return only the fixed JSON with no explanations.`, originalError, malformedJSON) + + var responseBuilder strings.Builder + + stream := da.client.Chat.Completions.NewStreaming(ctx, openai.ChatCompletionNewParams{ + Messages: openai.F([]openai.ChatCompletionMessageParamUnion{ + openai.UserMessage(prompt), + }), + Model: openai.F("qwen-plus"), + }) + + for stream.Next() { + chunk := stream.Current() + for _, choice := range chunk.Choices { + responseBuilder.WriteString(choice.Delta.Content) + } + } + + if err := stream.Err(); err != nil { + return "", fmt.Errorf("failed to send message: %v", err) + } + + fixed := responseBuilder.String() + if fixed == "" { + return "", fmt.Errorf("no repair response") + } + + return fixed, nil +} + +// extractAPIs extracts API information from document content +func (da *DocumentAnalyzer) extractAPIs(ctx context.Context, docContent string, doc *M.Doc) error { + schema, err := generateJSONSchema() + if err != nil { + return fmt.Errorf("failed to generate schema: %v", err) + } + da.logger.Printf("JSON Schema:\n%s", schema) + + // 初始化对话历史 + history := &ChatHistory{ + Messages: make([]openai.ChatCompletionMessageParamUnion, 0), + } + + // 初始化对话,发送文档内容和schema + initialPrompt := fmt.Sprintf(`你是一个API文档分析助手。你的任务是从文档中提取API信息。 + 请从以下文档中提取所有API接口信息,包括页面、功能操作名称、功能描述、请求类型、接口地址、输入和输出参数。 + 请确保完整提取每个接口的所有信息,不要遗漏或截断,如果有不确定的项,可以设置为空。 + 如果文档内容较多,你可以分批次输出,每次输出一部分API信息,并设置has_more为true表示还有更多API需要在下一次输出。 + 当所有API都输出完成时,设置has_more为false。 + + 文档内容: + %s + + JSON Schema: + %s + + 请严格按照schema格式输出,确保是有效的JSON格式。输出的JSON必须符合以上schema的规范。`, docContent, string(schema)) + + totalAPIs := 0 // 用于跟踪总共处理的API数量 + maxRetries := 10 + + // 添加初始消息到历史记录 + history.Messages = append(history.Messages, openai.UserMessage(initialPrompt)) + + maxInitRetries := 3 + var responseBuilder strings.Builder + var streamErr error + + // 发送初始消息并重试 + for i := 0; i < maxInitRetries; i++ { + responseBuilder.Reset() + stream := da.client.Chat.Completions.NewStreaming(ctx, openai.ChatCompletionNewParams{ + Messages: openai.F(history.Messages), + Model: openai.F("qwen-plus"), + }) + + for stream.Next() { + chunk := stream.Current() + for _, choice := range chunk.Choices { + responseBuilder.WriteString(choice.Delta.Content) + } + } + + if streamErr = stream.Err(); streamErr == nil { + break + } + time.Sleep(time.Second) + } + + if streamErr != nil { + da.logger.Printf("Failed to send initial message after retries: %v", streamErr) + return fmt.Errorf("failed to send initial message: %v", streamErr) + } + + response := responseBuilder.String() + // 添加模型的响应到历史记录 + history.Messages = append(history.Messages, openai.AssistantMessage(response)) + + for { + // sleep 30s to avoid rate limiting + time.Sleep(time.Second * 30) + + da.logger.Printf("LLM Response:\n%s", response) + + // Clean and validate the response + cleanedJSON, err := cleanJSONResponse(response) + if err != nil { + // Try to repair the JSON + da.logger.Printf("JSON repair attempt: %v", err) + fixed, repairErr := da.repairJSON(ctx, response, err) + if repairErr != nil { + return fmt.Errorf("JSON repair failed: %v (original: %v)", repairErr, err) + } + da.logger.Printf("JSON repaired:\n%s", fixed) + cleanedJSON, err = cleanJSONResponse(fixed) + if err != nil { + return fmt.Errorf("JSON validation failed: %v", err) + } + } + // Parse the response + var result APISpecList + if err := json.Unmarshal([]byte(cleanedJSON), &result); err != nil { + return fmt.Errorf("failed to parse LLM response: %v", err) + } + + // 处理这一批次的APIs + for _, api := range result.APIs { + totalAPIs++ + + // 创建新的endpoint + endpoint := &M.Endpoint{ + DocID: doc.ID, + Name: api.Name, + Path: api.Path, + Method: api.Method, + Description: api.Description, + BodyType: api.BodyType, + Merged: false, + Node: "proxy", + } + + if err := cfg.DB().Create(endpoint).Error; err != nil { + return fmt.Errorf("failed to create endpoint: %v", err) + } + + // 创建响应记录 + response := &M.Response{ + EndpointID: endpoint.ID, + StatusCode: api.Response.StatusCode, + Example: api.Response.Example, + Name: "Default Response", // 默认值 + ContentType: "application/json", // 默认值 + Description: "API Response", // 默认值 + } + + if err := cfg.DB().Create(response).Error; err != nil { + return fmt.Errorf("failed to create response: %v", err) + } + + // 存储参数 + var jsonParams []*Parameter + var otherParams []*Parameter + + for _, param := range api.Inputs { + jsonParams = append(jsonParams, ¶m) + otherParams = append(otherParams, ¶m) + } + + // 如果有application/json类型的参数,将它们合并 + if len(jsonParams) > 0 { + mergedValue := make(map[string]interface{}) + var descriptions []string + + for _, param := range jsonParams { + if param.Value != "" { + mergedValue[param.Name] = param.Value + } + if param.Description != "" { + descriptions = append(descriptions, param.Name+": "+param.Description) + } + } + // 创建合并后的参数 + mergedValueJSON, _ := json.Marshal(mergedValue) + mergedParam := &M.Parameter{ + EndpointID: endpoint.ID, + Name: "", // 空名称 + Type: "body", + ParamType: "string", + Required: true, + Description: strings.Join(descriptions, "; "), + Example: "", + Value: string(mergedValueJSON), + } + + if err := cfg.DB().Create(mergedParam).Error; err != nil { + return fmt.Errorf("failed to create merged json parameter: %v", err) + } + } + + // 处理其他非application/json参数 + locations := []string{"query", "path", "body"} + for _, location := range locations { + for _, param := range otherParams { + parameter := &M.Parameter{ + EndpointID: endpoint.ID, + Name: param.Name, + Type: location, + ParamType: param.Type, + Required: true, + Description: param.Description, + Example: "", + Value: param.Value, + } + + if err := cfg.DB().Create(parameter).Error; err != nil { + return fmt.Errorf("failed to create parameter: %v", err) + } + } + } + + // 更新文档处理进度 + progress := result.AnalysisPercent + + if err := cfg.DB().Model(doc).Update("analysis_percent", progress).Error; err != nil { + da.logger.Printf("Failed to update progress: %v", err) + } + } + + // 如果没有更多API要处理,退出循环 + if !result.HasMore { + break + } + + // 使用chat history继续对话 + followUpPrompt := `请继续提取剩余的API信息,保持相同的输出格式。 + 请记住: + 1. 不要重复之前已输出的接口 + 2. 如果还有更多内容,添加 "has_more": true + 3. 如果已经输出完所有内容,添加 "has_more": false + 4. 请严格按照schema格式输出,确保是有效、完整的JSON格式。输出的JSON必须符合以上schema的规范,比如string类型的值要注意转义字符的使用。` + + history.Messages = append(history.Messages, openai.UserMessage(followUpPrompt)) + + // 重试逻辑 + for retry := 0; retry < maxRetries; retry++ { + responseBuilder.Reset() + stream := da.client.Chat.Completions.NewStreaming(ctx, openai.ChatCompletionNewParams{ + Messages: openai.F(history.Messages), + Model: openai.F("qwen-plus"), + }) + + for stream.Next() { + chunk := stream.Current() + for _, choice := range chunk.Choices { + responseBuilder.WriteString(choice.Delta.Content) + } + } + + if streamErr = stream.Err(); streamErr == nil { + response = responseBuilder.String() + history.Messages = append(history.Messages, openai.AssistantMessage(response)) + break + } + + da.logger.Printf("Attempt %d failed: %v, retrying...", retry+1, streamErr) + time.Sleep(time.Second * 30) + } + + if streamErr != nil { + return fmt.Errorf("failed to send follow-up message after %d retries: %v", maxRetries, streamErr) + } + } + + return nil +} + +func readPDFLedong(path string) (string, error) { + f, r, err := pdf.Open(path) + // remember close file + defer func() { + err := f.Close() + if err != nil { + return + } + }() + if err != nil { + return "", err + } + var buf bytes.Buffer + b, err := r.GetPlainText() + if err != nil { + return "", err + } + buf.ReadFrom(b) + return buf.String(), nil +} + +// DocProcessor handles document processing queue and analysis +type DocProcessor struct { + queue chan *DocTask + workers int + wg sync.WaitGroup + analyzer *DocumentAnalyzer + cancelTasks map[string]chan struct{} + taskMutex sync.RWMutex +} + +// DocTask represents a document processing task +type DocTask struct { + Doc *M.Doc + FilePath string +} + +var ( + processor *DocProcessor + processorOnce sync.Once +) + +// GetDocProcessor returns a singleton instance of DocProcessor +func GetDocProcessor() *DocProcessor { + processorOnce.Do(func() { + analyzer, err := NewDocumentAnalyzer() + if err != nil { + log.Fatalf("Failed to create document analyzer: %v", err) + } + processor = &DocProcessor{ + queue: make(chan *DocTask, 100), // Buffer size of 100 + workers: 1, // Number of concurrent workers + analyzer: analyzer, + cancelTasks: make(map[string]chan struct{}), + } + processor.Start() + }) + return processor +} + +func (p *DocProcessor) CancelDocProcessing(docID string) { + p.taskMutex.Lock() + defer p.taskMutex.Unlock() + + if cancel, exists := p.cancelTasks[docID]; exists { + close(cancel) + delete(p.cancelTasks, docID) + } +} + +// Start initializes the worker pool +func (p *DocProcessor) Start() { + for i := 0; i < p.workers; i++ { + p.wg.Add(1) + go p.worker() + } +} + +// AddTask adds a new document to the processing queue +func (p *DocProcessor) AddTask(doc *M.Doc, filePath string) { + p.taskMutex.Lock() + // 为这个任务创建新的取消通道 + cancelChan := make(chan struct{}) + p.cancelTasks[doc.ID] = cancelChan + p.taskMutex.Unlock() + + task := &DocTask{ + Doc: doc, + FilePath: filePath, + } + p.queue <- task +} + +// worker processes documents from the queue +func (p *DocProcessor) worker() { + defer p.wg.Done() + + for task := range p.queue { + err := p.processDocument(task) + if err != nil { + log.Printf("Error processing document %s: %v", task.Doc.ID, err) + // Update document status to error + cfg.DB().Model(task.Doc).Updates(map[string]interface{}{ + "analysis_completed": true, + "analysis_error": err.Error(), + }) + } + } +} + +// processDocument handles the actual document processing +func (p *DocProcessor) processDocument(task *DocTask) error { + defer func() { + p.taskMutex.Lock() + delete(p.cancelTasks, task.Doc.ID) + p.taskMutex.Unlock() + }() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { + p.taskMutex.RLock() + cancelChan := p.cancelTasks[task.Doc.ID] + p.taskMutex.RUnlock() + + select { + case <-cancelChan: + cancel() // 收到取消信号时取消上下文 + case <-ctx.Done(): + } + }() + + doc := task.Doc + + // Update initial status + if err := cfg.DB().Model(doc).Updates(map[string]interface{}{ + "analysis_completed": false, + "analysis_percent": 0, + }).Error; err != nil { + return fmt.Errorf("failed to update initial status: %v", err) + } + + // Read PDF content + content, err := readPDFLedong(task.FilePath) + if err != nil { + return fmt.Errorf("failed to read PDF: %v", err) + } + + // Extract and process APIs + err = p.analyzer.extractAPIs(ctx, content, doc) + if err != nil { + if ctx.Err() != nil { + // 如果是因为取消导致的错误,记录日志但不返回错误 + log.Printf("Document processing cancelled for doc ID: %s", task.Doc.ID) + return nil + } + return fmt.Errorf("failed to extract APIs: %v", err) + } + + // Update final status + return cfg.DB().Model(doc).Updates(map[string]interface{}{ + "analysis_completed": true, + "analysis_percent": 100, + }).Error +} diff --git a/api/doc/doc_processor_demand.go b/api/doc/doc_processor_demand.go new file mode 100644 index 0000000..b05f940 --- /dev/null +++ b/api/doc/doc_processor_demand.go @@ -0,0 +1,760 @@ +package doc + +import ( + "app/cfg" + M "app/models" + "bytes" + "context" + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/ledongthuc/pdf" + "github.com/openai/openai-go" + "github.com/openai/openai-go/option" + "github.com/xeipuuv/gojsonschema" + + "github.com/unidoc/unioffice/document" +) + +// DemandTree 表示一个完整的需求树 +type DemandTree struct { + Demands []DemandNode `json:"demands" jsonschema:"required,description=需求树列表"` + HasMore bool `json:"has_more" jsonschema:"required,description=是否还有更多需求需要继续在下一次输出中继续输出"` + AnalysisPercent int `json:"analysis_percent" jsonschema:"required,description=估计的分析进度百分比,范围是0-100,不一定递增,可以调整,比如发现增长过快的时候可以减少,只有全部解析完毕才可以到100"` +} +type DemandNode struct { + ReqID string `json:"id" jsonschema:"required,description=需求ID,格式为REQ-数字"` + Title string `json:"title" jsonschema:"required,description=需求标题"` + Description string `json:"description" jsonschema:"required,description=需求详细描述"` + Priority string `json:"priority" jsonschema:"enum=高,enum=中,enum=低,description=需求优先级"` + Type string `json:"type" jsonschema:"enum=功能需求,enum=非功能需求,enum=业务需求,description=需求类型"` + Status string `json:"status" jsonschema:"enum=待实现,enum=开发中,enum=已完成,enum=已验收,description=需求状态"` + ParentReqID string `json:"parent_req_id,omitempty" jsonschema:"description=父需求ID,如果是顶级需求则为空"` + Children []DemandNode `json:"children,omitempty" jsonschema:"description=子需求列表"` +} + +// 同样修改 SimpleDemandNode +type SimpleDemandNode struct { + ReqID string `json:"id" jsonschema:"required,description=需求ID,格式为REQ-数字"` + Title string `json:"title" jsonschema:"required,description=需求标题"` + Description string `json:"description" jsonschema:"required,description=需求详细描述"` + Priority string `json:"priority" jsonschema:"enum=高,enum=中,enum=低,description=需求优先级"` + Type string `json:"type" jsonschema:"enum=功能需求,enum=非功能需求,enum=业务需求,description=需求类型"` + Status string `json:"status" jsonschema:"enum=待实现,enum=开发中,enum=已完成,enum=已验收,description=需求状态"` + ParentReqID string `json:"parent_req_id,omitempty" jsonschema:"description=父需求ID,如果是顶级需求则为空"` + Children []map[string]string `json:"children,omitempty" jsonschema:"description=子需求列表"` +} + +// 简化的需求树结构,用于生成 Schema +type SimpleDemandTree struct { + Demands []SimpleDemandNode `json:"demands" jsonschema:"required,description=需求树列表"` + HasMore bool `json:"has_more" jsonschema:"required,description=是否还有更多需求需要继续在下一次输出中继续输出"` + AnalysisPercent int `json:"analysis_percent" jsonschema:"required,description=估计的分析进度百分比,范围是0-100,不一定递增,可以调整,比如发现增长过快的时候可以减少,只有全部解析完毕才可以到100"` +} + +// DemandChatHistory 表示与大模型的对话历史 +type DemandChatHistory struct { + Messages []openai.ChatCompletionMessageParamUnion + DocContent string + Schema string +} + +// DemandAnalyzer 处理需求文档分析 +type DemandAnalyzer struct { + client *openai.Client + logger *log.Logger +} + +// NewDemandAnalyzer 创建一个新的需求分析器实例 +func NewDemandAnalyzer() (*DemandAnalyzer, error) { + client := openai.NewClient( + option.WithAPIKey("sk-0213c70194624703a1d0d80e0f762b0e"), + option.WithBaseURL("https://dashscope.aliyuncs.com/compatible-mode/v1/"), + ) + fmt.Println("需求分析器初始化成功") + return &DemandAnalyzer{ + client: client, + logger: setupLogger(), + }, nil +} + +// generateDemandJSONSchema 生成需求树的JSON Schema +// func generateDemandJSONSchema() ([]byte, error) { +// reflector := jsonschema.Reflector{ +// RequiredFromJSONSchemaTags: true, +// AllowAdditionalProperties: true, +// DoNotReference: true, +// } +// schema := reflector.Reflect(&SimpleDemandTree{}) +// // schema := reflector.Reflect(&DemandTree{}) + +// return json.MarshalIndent(schema, "", " ") +// } +func generateDemandJSONSchema() ([]byte, error) { + // 使用预定义的 JSON Schema 字符串,手动处理递归引用 + schemaStr := `{ + "type": "object", + "properties": { + "demands": { + "type": "array", + "description": "需求树列表", + "items": { + "$ref": "#/definitions/demandNode" + } + }, + "has_more": { + "type": "boolean", + "description": "是否还有更多需求需要继续在下一次输出中继续输出" + }, + "analysis_percent": { + "type": "integer", + "description": "估计的分析进度百分比,范围是0-100,不一定递增,可以调整,比如发现增长过快的时候可以减少,只有全部解析完毕才可以到100" + } + }, + "required": ["demands", "has_more", "analysis_percent"], + "definitions": { + "demandNode": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "需求ID,格式为REQ-数字" + }, + "title": { + "type": "string", + "description": "需求标题" + }, + "description": { + "type": "string", + "description": "需求详细描述" + }, + "priority": { + "type": "string", + "enum": ["高", "中", "低"], + "description": "需求优先级" + }, + "type": { + "type": "string", + "enum": ["功能需求","性能需求","安全需求", "合规性需求", "可靠性需求"], + "description": "需求类型" + }, + "status": { + "type": "string", + "enum": ["待实现", "开发中", "已完成", "已测试"], + "description": "需求状态" + }, + "parent_req_id": { + "type": "string", + "description": "父需求ID,如果是顶级需求则为空" + }, + "children": { + "type": "array", + "description": "子需求列表,每个子需求也是一个demandNode,而且子需求也可能有自己的子需求列表,以此类推", + "items": { + "$ref": "#/definitions/demandNode" + } + } + }, + "required": ["id", "title", "description"] + } + } + }` + + // 解析 JSON 以确保格式正确 + var schema interface{} + if err := json.Unmarshal([]byte(schemaStr), &schema); err != nil { + return nil, fmt.Errorf("解析预定义 Schema 失败: %v", err) + } + + // 重新格式化为美观的 JSON + return json.MarshalIndent(schema, "", " ") +} + +// validateDemandJSON 验证JSON响应是否符合Schema +func validateDemandJSON(data []byte) error { + schema, err := generateDemandJSONSchema() + if err != nil { + return fmt.Errorf("生成Schema失败: %v", err) + } + + schemaLoader := gojsonschema.NewBytesLoader(schema) + documentLoader := gojsonschema.NewBytesLoader(data) + + result, err := gojsonschema.Validate(schemaLoader, documentLoader) + if err != nil { + return fmt.Errorf("验证错误: %v", err) + } + + if !result.Valid() { + var errors []string + for _, desc := range result.Errors() { + errors = append(errors, desc.String()) + } + return fmt.Errorf("无效的JSON: %v", errors) + } + + return nil +} + +// cleanDemandJSONResponse 清理并验证LLM响应 +func cleanDemandJSONResponse(response string) (string, error) { + // 查找第一个 { 和最后一个 } + start := 0 + end := len(response) + + for i := 0; i < len(response); i++ { + if response[i] == '{' { + start = i + break + } + } + + for i := len(response) - 1; i >= 0; i-- { + if response[i] == '}' { + end = i + 1 + break + } + } + + if start >= end { + return "", fmt.Errorf("无效的JSON结构") + } + + jsonStr := response[start:end] + + // 验证JSON + if err := validateDemandJSON([]byte(jsonStr)); err != nil { + return "", fmt.Errorf("JSON验证失败: %v", err) + } + + return jsonStr, nil +} + +// repairDemandJSON 修复格式错误的JSON +// 修改 repairDemandJSON 方法 +func (a *DemandAnalyzer) repairDemandJSON(ctx context.Context, response string, originalErr error) (string, error) { + prompt := fmt.Sprintf(`你是一个JSON修复专家。我有一个JSON字符串,但它存在一些问题无法解析。 + +原始JSON: +%s + +错误信息: +%v + +请修复这个JSON,使其符合以下要求: +1. 所有字段名必须使用双引号 +2. 字符串值必须使用双引号 +3. 不要添加或删除字段,只修复格式问题 +4. 如果parent_req_id是null,替换为空字符串"" +5. 确保demands是一个数组,即使为空也应为[] +6. 确保has_more是一个布尔值 +7. 结果必须是有效的JSON + +请只返回修复后的JSON,不要包含任何其他解释或评论。`, response, originalErr) + + stream := a.client.Chat.Completions.NewStreaming(ctx, openai.ChatCompletionNewParams{ + Messages: openai.F([]openai.ChatCompletionMessageParamUnion{ + openai.UserMessage(prompt), + }), + Model: openai.F("qwen-plus"), + }) + + var responseBuilder strings.Builder + for stream.Next() { + chunk := stream.Current() + for _, choice := range chunk.Choices { + responseBuilder.WriteString(choice.Delta.Content) + } + } + + if err := stream.Err(); err != nil { + return "", fmt.Errorf("JSON修复API调用失败: %v", err) + } + + fixed := responseBuilder.String() + + // 关键修复:移除可能的Markdown代码块标记 + fixed = strings.TrimSpace(fixed) + + // 检查并移除开头的Markdown标记 + if strings.HasPrefix(fixed, "```") { + // 找到第一行结束位置 + firstLineEnd := strings.Index(fixed, "\n") + if firstLineEnd != -1 { + // 跳过第一行(包含```json或```) + fixed = fixed[firstLineEnd+1:] + } else { + // 如果没有换行,可能整个字符串都是标记,返回错误 + return "", fmt.Errorf("修复后的JSON格式异常") + } + } + + // 检查并移除结尾的Markdown标记 + if strings.HasSuffix(fixed, "```") { + fixed = fixed[:len(fixed)-3] + } + + // 再次去除前后空白 + fixed = strings.TrimSpace(fixed) + + // 验证修复后的JSON是否有效 + var testObj map[string]interface{} + if err := json.Unmarshal([]byte(fixed), &testObj); err != nil { + return "", fmt.Errorf("修复后的JSON仍然无效: %v", err) + } + + return fixed, nil +} + +// func (da *DemandAnalyzer) repairDemandJSON(ctx context.Context, malformedJSON string, originalError error) (string, error) { +// prompt := fmt.Sprintf(`修复这个格式错误的JSON,错误信息: %v + +// 需要修复的JSON: +// %s + +// 只返回修复后的JSON,不要有任何解释。`, originalError, malformedJSON) + +// var responseBuilder strings.Builder + +// stream := da.client.Chat.Completions.NewStreaming(ctx, openai.ChatCompletionNewParams{ +// Messages: openai.F([]openai.ChatCompletionMessageParamUnion{ +// openai.UserMessage(prompt), +// }), +// Model: openai.F("qwen-plus"), +// }) + +// for stream.Next() { +// chunk := stream.Current() +// for _, choice := range chunk.Choices { +// responseBuilder.WriteString(choice.Delta.Content) +// } +// } + +// if err := stream.Err(); err != nil { +// return "", fmt.Errorf("发送消息失败: %v", err) +// } + +// fixed := responseBuilder.String() +// if fixed == "" { +// return "", fmt.Errorf("没有修复响应") +// } + +// return fixed, nil +// } + +// extractDemands 从文档内容中提取需求信息 +func (da *DemandAnalyzer) extractDemands(ctx context.Context, docContent string, doc *M.Doc) error { + schema, err := generateDemandJSONSchema() + if err != nil { + return fmt.Errorf("生成Schema失败: %v", err) + } + da.logger.Printf("需求JSON Schema:\n%s", schema) + + // 初始化对话历史 + history := &DemandChatHistory{ + Messages: make([]openai.ChatCompletionMessageParamUnion, 0), + } + + // 初始化对话,发送文档内容和schema + initialPrompt := fmt.Sprintf(`你是一个需求文档分析助手。你的任务是从文档中提取需求信息并构建需求树。 + 请从以下文档中提取所有需求信息,包括需求ID、标题、描述、优先级、类型和状态。 + 请确保完整提取每个需求的所有信息,并正确构建需求的层级关系。 + + 如果文档内容较多,你可以分批次输出,每次输出一部分需求信息: + 1. 如果还有更多需求未处理,设置 "has_more": true + 2. 在后续输出中,继续提取剩余的需求,不要重复之前已输出的需求 + 3. 当所有需求都输出完成时,设置 "has_more": false + 4. 每次输出时,设置 "analysis_percent" 表示估计的分析进度百分比 + + 需求应该按照层级结构组织,主需求包含子需求,子需求可能还有更深层次的子需求,以此类推,需求树的深度没有限制。 + 对于每个子需求,请设置其 "parent_req_id" 为父需求的ID,这样可以明确表示需求之间的层级关系。 + 你可以在后续输出中继续为前面已输出的需求添加子需求,只需正确设置 "parent_req_id" 即可。 + + 如果文档中包含多个独立的需求树或模块,请将它们作为独立的顶级需求节点输出。 + 每个需求都应该有一个唯一的req_id,格式为REQ-数字,例如REQ-001。 + 如果文档中没有明确的需求ID,请自动生成一个。 + + 文档内容: + %s + + JSON Schema: + %s + + 请严格按照schema格式输出,确保是有效的JSON格式。输出的JSON必须符合以上schema的规范。`, docContent, string(schema)) + + totalDemands := 0 // 用于跟踪总共处理的需求数量 + maxRetries := 10 + + // 添加初始消息到历史记录 + history.Messages = append(history.Messages, openai.UserMessage(initialPrompt)) + + maxInitRetries := 3 + var responseBuilder strings.Builder + var streamErr error + + // 发送初始消息并重试 + for i := 0; i < maxInitRetries; i++ { + responseBuilder.Reset() + stream := da.client.Chat.Completions.NewStreaming(ctx, openai.ChatCompletionNewParams{ + Messages: openai.F(history.Messages), + Model: openai.F("qwen-plus"), + }) + + for stream.Next() { + chunk := stream.Current() + for _, choice := range chunk.Choices { + responseBuilder.WriteString(choice.Delta.Content) + } + } + + if streamErr = stream.Err(); streamErr == nil { + break + } + time.Sleep(time.Second) + } + + if streamErr != nil { + da.logger.Printf("发送初始消息失败,已重试: %v", streamErr) + return fmt.Errorf("发送初始消息失败: %v", streamErr) + } + + response := responseBuilder.String() + // 添加模型的响应到历史记录 + history.Messages = append(history.Messages, openai.AssistantMessage(response)) + + for { + // 休眠30秒以避免速率限制 + time.Sleep(time.Second * 30) + + da.logger.Printf("LLM响应:\n%s", response) + + // 清理并验证响应 + cleanedJSON, err := cleanDemandJSONResponse(response) + if err != nil { + // 尝试修复JSON + da.logger.Printf("尝试修复JSON: %v", err) + fixed, repairErr := da.repairDemandJSON(ctx, response, err) + if repairErr != nil { + return fmt.Errorf("JSON修复失败: %v (原始错误: %v)", repairErr, err) + } + da.logger.Printf("JSON已修复:\n%s", fixed) + cleanedJSON, err = cleanDemandJSONResponse(fixed) + if err != nil { + return fmt.Errorf("JSON验证失败: %v", err) + } + } + // 解析响应 + var result DemandTree + if err := json.Unmarshal([]byte(cleanedJSON), &result); err != nil { + return fmt.Errorf("解析LLM响应失败: %v", err) + } + + // 处理这一批次的需求 + for _, demand := range result.Demands { + totalDemands += da.processDemandNode(demand, doc.ID, "") + } + + // 更新文档处理进度 + progress := result.AnalysisPercent + if err := cfg.DB().Model(doc).Update("analysis_percent", progress).Error; err != nil { + da.logger.Printf("更新进度失败: %v", err) + } + + // 如果没有更多需求要处理,退出循环 + if !result.HasMore { + break + } + + // 使用chat history继续对话 + followUpPrompt := `请继续提取剩余的需求信息,保持相同的输出格式。 + 请记住: + 1. 不要重复之前已输出的需求,如果你想给之前的需求添加子需求,你可以设置 "parent_req_id" 为父需求的ID + 2. 如果还有更多内容,添加 "has_more": true + 3. 如果已经输出完所有内容,添加 "has_more": false + 4. 请严格按照schema格式输出,确保是有效、完整的JSON格式。输出的JSON必须符合以上schema的规范,比如string类型的值要注意转义字符的使用。` + + history.Messages = append(history.Messages, openai.UserMessage(followUpPrompt)) + + // 重试逻辑 + for retry := 0; retry < maxRetries; retry++ { + responseBuilder.Reset() + stream := da.client.Chat.Completions.NewStreaming(ctx, openai.ChatCompletionNewParams{ + Messages: openai.F(history.Messages), + Model: openai.F("qwen-plus"), + }) + + for stream.Next() { + chunk := stream.Current() + for _, choice := range chunk.Choices { + responseBuilder.WriteString(choice.Delta.Content) + } + } + + if streamErr = stream.Err(); streamErr == nil { + response = responseBuilder.String() + history.Messages = append(history.Messages, openai.AssistantMessage(response)) + break + } + + da.logger.Printf("尝试 %d 失败: %v, 重试中...", retry+1, streamErr) + time.Sleep(time.Second * 30) + } + + if streamErr != nil { + return fmt.Errorf("发送后续消息失败,已重试 %d 次: %v", maxRetries, streamErr) + } + } + + return nil +} + +// processDemandNode 递归处理需求节点及其子节点 +func (da *DemandAnalyzer) processDemandNode(node DemandNode, docID string, parentID string) int { + count := 1 // 当前节点计数为1 + var existingDemand M.Demand + if err := cfg.DB().Where("req_id = ? AND doc_id = ?", node.ReqID, docID).First(&existingDemand).Error; err == nil { + // 如果需求已存在,直接使用现有需求处理子节点 + for _, child := range node.Children { + count += da.processDemandNode(child, docID, existingDemand.ID) + } + return count + } + + // 计算当前节点的层级 + level := 0 + if parentID != "" { + // 如果有父节点,查询父节点的层级并加1 + var parentDemand M.Demand + if err := cfg.DB().Where("id = ?", parentID).First(&parentDemand).Error; err == nil { + level = parentDemand.Level + 1 + } + } else if node.ParentReqID != "" { + // 如果没有直接的父节点ID但有父需求ID,尝试通过ReqID查找父节点 + var parentDemand M.Demand + if err := cfg.DB().Where("req_id = ? AND doc_id = ?", node.ParentReqID, docID).First(&parentDemand).Error; err == nil { + parentID = parentDemand.ID // 设置父节点ID + level = parentDemand.Level + 1 + } else { + da.logger.Printf("通过ReqID查找父节点失败: %v, ReqID: %s", err, node.ParentReqID) + } + } + + // 创建需求记录 + demand := &M.Demand{ + DocID: docID, + Description: node.Description, + ReqID: node.ReqID, + Priority: node.Priority, + Type: node.Type, + Status: node.Status, + ParentReqID: node.ParentReqID, // 添加父需求ID + Tree: M.Tree{ + Name: node.Title, + ParentID: parentID, + Level: level, + }, + } + + if err := cfg.DB().Create(demand).Error; err != nil { + da.logger.Printf("创建需求记录失败: %v", err) + return count + } + + // 递归处理子需求 + for _, child := range node.Children { + count += da.processDemandNode(child, docID, demand.ID) + } + + return count +} + +// readDocContent 从文件中读取文档内容 +func (da *DemandAnalyzer) readDocContent(filePath string) (string, error) { + ext := strings.ToLower(filepath.Ext(filePath)) + + switch ext { + case ".pdf": + return da.readPDF(filePath) + case ".txt": + return da.readTXT(filePath) + case ".docx": + return da.readDOCX(filePath) + default: + return "", fmt.Errorf("不支持的文件类型: %s", ext) + } +} + +// readPDF 读取PDF文件内容 +func (da *DemandAnalyzer) readPDF(filePath string) (string, error) { + f, r, err := pdf.Open(filePath) + if err != nil { + return "", fmt.Errorf("打开PDF文件失败: %v", err) + } + defer f.Close() + + var buf bytes.Buffer + b, err := r.GetPlainText() + if err != nil { + return "", fmt.Errorf("读取PDF文本失败: %v", err) + } + + buf.ReadFrom(b) + return buf.String(), nil +} + +// readTXT 读取TXT文件内容 +func (da *DemandAnalyzer) readTXT(filePath string) (string, error) { + content, err := os.ReadFile(filePath) + if err != nil { + return "", fmt.Errorf("读取TXT文件失败: %v", err) + } + return string(content), nil +} + +// readDOCX 读取DOCX文件内容 +func (da *DemandAnalyzer) readDOCX(filePath string) (string, error) { + // 使用 unidoc/unioffice 库读取 DOCX 文件 + doc, err := document.Open(filePath) + if err != nil { + return "", fmt.Errorf("打开DOCX文件失败: %v", err) + } + + var content strings.Builder + + // 遍历所有段落并提取文本 + for _, para := range doc.Paragraphs() { + for _, run := range para.Runs() { + content.WriteString(run.Text()) + } + content.WriteString("\n") // 段落结束添加换行 + } + + return content.String(), nil +} + +// DemandProcessor 处理需求文档的处理器 +type DemandProcessor struct { + analyzer *DemandAnalyzer + tasks map[string]*M.Doc + processingDoc map[string]context.CancelFunc + mutex sync.Mutex + logger *log.Logger +} + +var demandProcessor *DemandProcessor +var demandProcessorOnce sync.Once + +// GetDemandProcessor 获取需求处理器单例 +func GetDemandProcessor() *DemandProcessor { + demandProcessorOnce.Do(func() { + analyzer, err := NewDemandAnalyzer() + if err != nil { + log.Fatalf("初始化需求分析器失败: %v", err) + } + + demandProcessor = &DemandProcessor{ + analyzer: analyzer, + tasks: make(map[string]*M.Doc), + processingDoc: make(map[string]context.CancelFunc), + mutex: sync.Mutex{}, + logger: setupLogger(), + } + }) + return demandProcessor +} + +// AddTask 添加需求文档处理任务 +func (dp *DemandProcessor) AddTask(doc *M.Doc, filePath string) { + dp.mutex.Lock() + defer dp.mutex.Unlock() + + dp.tasks[doc.ID] = doc + go dp.processDoc(doc, filePath) +} + +// CancelDocProcessing 取消文档处理 +func (dp *DemandProcessor) CancelDocProcessing(docID string) { + dp.mutex.Lock() + defer dp.mutex.Unlock() + + if cancel, exists := dp.processingDoc[docID]; exists { + cancel() + delete(dp.processingDoc, docID) + } +} + +// processDoc 处理需求文档 +func (dp *DemandProcessor) processDoc(doc *M.Doc, filePath string) { + dp.logger.Printf("开始处理需求文档: %s", doc.Name) + + // 创建可取消的上下文 + ctx, cancel := context.WithCancel(context.Background()) + + dp.mutex.Lock() + dp.processingDoc[doc.ID] = cancel + dp.mutex.Unlock() + + defer func() { + dp.mutex.Lock() + delete(dp.processingDoc, doc.ID) + dp.mutex.Unlock() + }() + + // 更新文档状态为处理中 + if err := cfg.DB().Model(doc).Updates(map[string]interface{}{ + "analysis_completed": false, + "analysis_percent": 0, + "analysis_error": "", + }).Error; err != nil { + dp.logger.Printf("更新文档状态失败: %v", err) + return + } + + // 读取文档内容 + content, err := dp.analyzer.readDocContent(filePath) + if err != nil { + errMsg := fmt.Sprintf("读取文档内容失败: %v", err) + dp.logger.Printf(errMsg) + + // 更新文档状态为失败 + if dbErr := cfg.DB().Model(doc).Updates(map[string]interface{}{ + "analysis_completed": true, + "analysis_error": errMsg, + }).Error; dbErr != nil { + dp.logger.Printf("更新文档状态失败: %v", dbErr) + } + return + } + + // 提取需求 + if err := dp.analyzer.extractDemands(ctx, content, doc); err != nil { + errMsg := fmt.Sprintf("提取需求失败: %v", err) + dp.logger.Printf(errMsg) + + // 更新文档状态为失败 + if dbErr := cfg.DB().Model(doc).Updates(map[string]interface{}{ + "analysis_completed": true, + "analysis_error": errMsg, + }).Error; dbErr != nil { + dp.logger.Printf("更新文档状态失败: %v", dbErr) + } + return + } + + // 更新文档状态为完成 + if err := cfg.DB().Model(doc).Updates(map[string]interface{}{ + "analysis_completed": true, + "analysis_percent": 100, + }).Error; err != nil { + dp.logger.Printf("更新文档状态失败: %v", err) + return + } + + dp.logger.Printf("需求文档处理完成: %s", doc.Name) +} diff --git a/api/doc/doc_processor_demand_agentic.go b/api/doc/doc_processor_demand_agentic.go new file mode 100644 index 0000000..16301c0 --- /dev/null +++ b/api/doc/doc_processor_demand_agentic.go @@ -0,0 +1,997 @@ +package doc + +import ( + "context" + "encoding/json" + "fmt" + "log" + "regexp" + "strings" + "sync" + "time" + + "app/cfg" + M "app/models" + + "github.com/openai/openai-go" +) + +// TODO 另一种思路是先粗糙生成一些需求再建立联系 +type AgenticDemandProcessor struct { + analyzer *DemandAnalyzer + tasks map[string]*M.Doc + processingDoc map[string]context.CancelFunc + mutex sync.Mutex + logger *log.Logger +} + +var agenticDemandProcessor *AgenticDemandProcessor +var agenticDemandProcessorOnce sync.Once + +func GetAgenticDemandProcessor() *AgenticDemandProcessor { + agenticDemandProcessorOnce.Do(func() { + analyzer, err := NewDemandAnalyzer() + if err != nil { + log.Fatalf("初始化需求分析器失败: %v", err) + } + + agenticDemandProcessor = &AgenticDemandProcessor{ + analyzer: analyzer, + tasks: make(map[string]*M.Doc), + processingDoc: make(map[string]context.CancelFunc), + mutex: sync.Mutex{}, + logger: setupLogger(), + } + }) + return agenticDemandProcessor +} + +// AddTask 添加需求文档处理任务 +func (dp *AgenticDemandProcessor) AddTask(doc *M.Doc, filePath string) { + dp.mutex.Lock() + defer dp.mutex.Unlock() + + dp.tasks[doc.ID] = doc + go dp.processDoc(doc, filePath) +} + +// CancelDocProcessing 取消文档处理 +func (dp *AgenticDemandProcessor) CancelDocProcessing(docID string) { + dp.mutex.Lock() + defer dp.mutex.Unlock() + + if cancel, exists := dp.processingDoc[docID]; exists { + cancel() + delete(dp.processingDoc, docID) + } +} + +func (dp *AgenticDemandProcessor) splitTextIntoChunks(content string) ([]string, error) { + // 直接在Go中实现文本分块,不再调用Python脚本 + // 设置每个块的最大和最小字符数 + maxChunkSize := 4000 + minChunkSize := 500 // 设置最小块大小为500字符 + + // 定义标点符号正则表达式,包括中英文标点 + punctuationPattern := "[。!?.!?;;]" + re := regexp.MustCompile(punctuationPattern) + + // 优化的分块方法:从maxChunkSize位置向前查找最近的标点 + var chunks []string + remaining := content + + for len(remaining) > 0 { + if len(remaining) <= maxChunkSize { + // 如果剩余内容不超过最大块大小,直接添加 + chunks = append(chunks, remaining) + break + } + + // 确定切分位置:从maxChunkSize位置向前查找最近的标点 + cutPos := maxChunkSize + if cutPos > len(remaining) { + cutPos = len(remaining) + } + + // 在maxChunkSize范围内查找最后一个标点 + searchEnd := cutPos + searchStart := cutPos - 100 // 向前查找100个字符范围内的标点 + if searchStart < 0 { + searchStart = 0 + } + + // 在指定范围内查找最后一个标点 + searchText := remaining[searchStart:searchEnd] + allMatches := re.FindAllStringIndex(searchText, -1) + + if len(allMatches) > 0 { + // 找到了标点,使用最后一个标点作为切分点 + lastMatch := allMatches[len(allMatches)-1] + cutPos = searchStart + lastMatch[1] // 使用标点后的位置作为切分点 + + // 检查切分后的块是否太小 + if cutPos < minChunkSize { + // 如果太小,直接使用maxChunkSize作为切分点 + cutPos = maxChunkSize + } + } else if searchStart > 0 { + // 如果在100字符范围内没找到,扩大搜索范围到整个maxChunkSize + searchText = remaining[:searchEnd] + allMatches = re.FindAllStringIndex(searchText, -1) + if len(allMatches) > 0 { + lastMatch := allMatches[len(allMatches)-1] + cutPos = lastMatch[1] // 使用标点后的位置作为切分点 + + // 检查切分后的块是否太小 + if cutPos < minChunkSize { + // 如果太小,直接使用maxChunkSize作为切分点 + cutPos = maxChunkSize + } + } else { + // 如果仍然没找到标点,就使用maxChunkSize作为切分点 + cutPos = maxChunkSize + } + } else { + // 如果无法向前查找(已经在文本开头),直接使用maxChunkSize + cutPos = maxChunkSize + } + + // 添加当前块并继续处理剩余内容 + chunks = append(chunks, remaining[:cutPos]) + remaining = remaining[cutPos:] + } + + return chunks, nil +} + +// 修改 processDoc 方法 +func (dp *AgenticDemandProcessor) processDoc(doc *M.Doc, filePath string) { + dp.logger.Printf("开始处理需求文档: %s", doc.Name) + + // 创建可取消的上下文 + ctx, cancel := context.WithCancel(context.Background()) + + dp.mutex.Lock() + dp.processingDoc[doc.ID] = cancel + dp.mutex.Unlock() + + defer func() { + dp.mutex.Lock() + delete(dp.processingDoc, doc.ID) + dp.mutex.Unlock() + }() + + // 更新文档状态为处理中 + if err := cfg.DB().Model(doc).Updates(map[string]interface{}{ + "analysis_completed": false, + "analysis_percent": 0, + "analysis_error": "", + }).Error; err != nil { + dp.logger.Printf("更新文档状态失败: %v", err) + return + } + + // 读取文档内容 + content, err := dp.analyzer.readDocContent(filePath) + if err != nil { + errMsg := fmt.Sprintf("读取文档内容失败: %v", err) + dp.logger.Printf(errMsg) + dp.updateDocErrorStatus(doc, errMsg) + return + } + + // 将文档内容分块 + chunks, err := dp.splitTextIntoChunks(content) + if err != nil { + errMsg := fmt.Sprintf("文档分块失败: %v", err) + dp.logger.Printf(errMsg) + dp.updateDocErrorStatus(doc, errMsg) + return + } + + // 用于跟踪所有已保存的需求 + var allSavedDemands []*M.Demand + totalChunks := len(chunks) + + // 逐块处理文本 + for i, chunk := range chunks { + // 检查是否被取消 + select { + case <-ctx.Done(): + dp.logger.Printf("文档处理被取消: %s", doc.Name) + return + default: + } + + // 更新处理进度 + progress := float64(i) / float64(totalChunks) * 100 + if err := cfg.DB().Model(doc).Update("analysis_percent", progress).Error; err != nil { + dp.logger.Printf("更新进度失败: %v", err) + } + + // 提取当前块的需求,同时考虑已有的需求树 + newDemands, err := dp.extractDemandsFromChunk(ctx, chunk, allSavedDemands) + if err != nil { + errMsg := fmt.Sprintf("处理文本块 %d/%d 失败: %v", i+1, totalChunks, err) + dp.logger.Printf(errMsg) + dp.updateDocErrorStatus(doc, errMsg) + return + } + + dp.logger.Printf("文本块 %d/%d 提取了 %d 个需求", i+1, totalChunks, len(newDemands)) + + if len(newDemands) > 0 { + // 立即保存这个块的需求 + if err := dp.saveDemands(doc, newDemands); err != nil { + errMsg := fmt.Sprintf("保存文本块 %d/%d 的需求失败: %v", i+1, totalChunks, err) + dp.logger.Printf(errMsg) + dp.updateDocErrorStatus(doc, errMsg) + return + } + + dp.logger.Printf("文本块 %d/%d 的需求已保存到数据库", i+1, totalChunks) + + // 更新已保存的需求列表 + // 首先从数据库获取完整的需求列表,确保包含所有已保存的需求及其关系 + var updatedSavedDemands []*M.Demand + if err := cfg.DB().Where("doc_id = ?", doc.ID).Find(&updatedSavedDemands).Error; err != nil { + dp.logger.Printf("获取已保存需求列表失败: %v", err) + // 即使获取失败,也继续使用当前已知的需求列表 + allSavedDemands = append(allSavedDemands, newDemands...) + } else { + allSavedDemands = updatedSavedDemands + } + } else { + dp.logger.Printf("文本块 %d/%d 没有提取到新需求", i+1, totalChunks) + } + } + + // 更新文档状态为完成 + if err := cfg.DB().Model(doc).Updates(map[string]interface{}{ + "analysis_completed": true, + "analysis_percent": 100, + }).Error; err != nil { + dp.logger.Printf("更新文档状态失败: %v", err) + return + } + + dp.logger.Printf("需求文档处理完成: %s,总共提取并保存了 %d 个需求", doc.Name, len(allSavedDemands)) +} + +// 添加一个辅助方法来处理错误状态更新 +func (dp *AgenticDemandProcessor) updateDocErrorStatus(doc *M.Doc, errMsg string) { + if dbErr := cfg.DB().Model(doc).Updates(map[string]interface{}{ + "analysis_completed": true, + "analysis_error": errMsg, + }).Error; dbErr != nil { + dp.logger.Printf("更新文档错误状态失败: %v", dbErr) + } +} + +// extractDemandsFromChunk 从文本块中提取需求 +// extractDemandsFromChunk 从文本块中提取需求 +func (dp *AgenticDemandProcessor) extractDemandsFromChunk(ctx context.Context, chunk string, previousBlockDemands []*M.Demand) ([]*M.Demand, error) { + // 用于跟踪当前文本块所有提取的需求 + var currentBlockDemands []DemandNode + + // 初始提取时,没有当前块的需求 + var currentDemandModels []*M.Demand + + // 最大尝试次数,避免无限循环 + maxAttempts := 10 + attemptCount := 0 + hasMore := true + + // 初始化对话历史 + history := []openai.ChatCompletionMessageParamUnion{} + // && attemptCount < maxAttempts + // 循环直到所有需求提取完毕或达到最大尝试次数 + for hasMore { + // 是否是后续提取 + isFollowUp := attemptCount > 0 + + // 构建提示词 + prompt := dp.buildPrompt(chunk, previousBlockDemands, currentDemandModels, isFollowUp) + + // 添加到对话历史 + history = append(history, openai.UserMessage(prompt)) + + // 调用LLM API + dp.logger.Printf("发送文本块到LLM进行需求提取 (尝试 %d/%d)", attemptCount+1, maxAttempts) + + // 创建流式响应 + var responseBuilder strings.Builder + var err error + maxRetries := 3 + dp.logger.Printf("发送的提示词: %s", prompt) + for retry := 0; retry < maxRetries; retry++ { + responseBuilder.Reset() + stream := dp.analyzer.client.Chat.Completions.NewStreaming(ctx, openai.ChatCompletionNewParams{ + Messages: openai.F(history), + Model: openai.F("qwen-plus"), + }) + + // 读取流式响应 + for stream.Next() { + chunk := stream.Current() + for _, choice := range chunk.Choices { + responseBuilder.WriteString(choice.Delta.Content) + } + } + + if err = stream.Err(); err == nil { + break + } + + dp.logger.Printf("尝试 %d 失败: %v, 重试中...", retry+1, err) + time.Sleep(time.Second * 5) + } + + if err != nil { + return nil, fmt.Errorf("调用LLM API失败,已重试%d次: %v", maxRetries, err) + } + + response := responseBuilder.String() + dp.logger.Printf("LLM响应:\n%s", response) + history = append(history, openai.AssistantMessage(response)) + + // 清理并验证响应 + cleanedJSON, err := cleanDemandJSONResponse(response) + if err != nil { + // 尝试修复JSON + dp.logger.Printf("尝试修复JSON: %v", err) + fixed, repairErr := dp.analyzer.repairDemandJSON(ctx, response, err) + if repairErr != nil { + return nil, fmt.Errorf("JSON修复失败: %v (原始错误: %v)", repairErr, err) + } + dp.logger.Printf("JSON已修复:\n%s", fixed) + cleanedJSON = fixed + } + + // 解析响应 + var result DemandTree + if err := json.Unmarshal([]byte(cleanedJSON), &result); err != nil { + return nil, fmt.Errorf("解析LLM响应失败: %v", err) + } + + // 将新提取的需求添加到当前文本块的需求中 + currentBlockDemands = append(currentBlockDemands, result.Demands...) + + // 将当前所有需求转换为模型格式,用于下一次提示 + currentDemandModels, err = dp.parseDemandsToModel(currentBlockDemands) + if err != nil { + return nil, fmt.Errorf("转换需求格式失败: %v", err) + } + + // 更新循环条件 + hasMore = result.HasMore + attemptCount++ + + dp.logger.Printf("需求提取进度: 已提取 %d 个需求,hasMore=%v", len(currentBlockDemands), hasMore) + + // 如果已经是最后一次尝试且仍有更多需求,记录警告 + if attemptCount == maxAttempts && hasMore { + dp.logger.Printf("警告:达到最大尝试次数 (%d),但模型表示还有更多需求未提取", maxAttempts) + } + } + + // 解析为我们的模型格式 + demands, err := dp.parseDemandsToModel(currentBlockDemands) + if err != nil { + return nil, fmt.Errorf("转换需求格式失败: %v", err) + } + + return demands, nil +} + +// handleHasMoreFollowUp 处理有更多需求的情况 +func (dp *AgenticDemandProcessor) handleHasMoreFollowUp(ctx context.Context, chunk string, existingDemands []*M.Demand, history []openai.ChatCompletionMessageParamUnion, currentDemands []DemandNode) ([]*M.Demand, error) { + var allDemands []DemandNode + allDemands = append(allDemands, currentDemands...) + + // 最多连续请求5次,避免无限循环 + maxFollowUpAttempts := 5 + + for attempt := 0; attempt < maxFollowUpAttempts; attempt++ { + // 将已提取的需求转换为M.Demand格式 + extractedSoFar, err := dp.parseDemandsToModel(allDemands) + if err != nil { + return nil, fmt.Errorf("转换已提取需求失败: %v", err) + } + + // 构建后续提示,明确区分两类需求 + followUpPrompt := dp.buildPrompt(chunk, existingDemands, extractedSoFar, true) + + // 添加后续提示到历史记录 + history = append(history, openai.UserMessage(followUpPrompt)) + + // 创建流式响应 + var responseBuilder strings.Builder + dp.logger.Printf("发送后续请求到LLM (尝试 %d/%d)", attempt+1, maxFollowUpAttempts) + dp.logger.Printf("发送的提示词: %s", followUpPrompt) + stream := dp.analyzer.client.Chat.Completions.NewStreaming(ctx, openai.ChatCompletionNewParams{ + Messages: openai.F(history), + Model: openai.F("qwen-plus"), + }) + + // 读取流式响应 + for stream.Next() { + chunk := stream.Current() + for _, choice := range chunk.Choices { + responseBuilder.WriteString(choice.Delta.Content) + } + } + + if err = stream.Err(); err != nil { + return nil, fmt.Errorf("后续请求失败: %v", err) + } + + response := responseBuilder.String() + dp.logger.Printf("收到的响应: %s", response) + history = append(history, openai.AssistantMessage(response)) + + // 清理并验证响应 + cleanedJSON, err := cleanDemandJSONResponse(response) + if err != nil { + fixed, repairErr := dp.analyzer.repairDemandJSON(ctx, response, err) + if repairErr != nil { + return nil, fmt.Errorf("JSON修复失败: %v", repairErr) + } + cleanedJSON = fixed + } + + // 解析响应 + var result DemandTree + if err := json.Unmarshal([]byte(cleanedJSON), &result); err != nil { + return nil, fmt.Errorf("解析后续响应失败: %v", err) + } + + // 合并需求 + allDemands = append(allDemands, result.Demands...) + + // 如果没有更多需求,退出循环 + if !result.HasMore { + break + } + + // 如果这是最后一次尝试并且仍然has_more=true,记录警告 + if attempt == maxFollowUpAttempts-1 && result.HasMore { + dp.logger.Printf("警告:达到最大后续请求次数(%d),可能还有未提取的需求", maxFollowUpAttempts) + } + } + + // 解析为我们的模型格式 + demands, err := dp.parseDemandsToModel(allDemands) + if err != nil { + return nil, fmt.Errorf("转换需求格式失败: %v", err) + } + + return demands, nil +} + +// 新增方法:将DemandNode转换为M.Demand +func (dp *AgenticDemandProcessor) parseDemandsToModel(nodes []DemandNode) ([]*M.Demand, error) { + var demands []*M.Demand + + // 递归处理需求及其子需求 + var processDemand func(node DemandNode, parentReqID string) *M.Demand + processDemand = func(node DemandNode, parentReqID string) *M.Demand { + // 设置默认值 + priority := node.Priority + if priority == "" { + priority = "中" + } + + demandType := node.Type + if demandType == "" { + demandType = "功能需求" + } + + status := node.Status + if status == "" { + status = "待实现" + } + + demand := &M.Demand{ + Tree: M.Tree{ + Name: node.Title, + }, + Description: node.Description, + ReqID: node.ReqID, + Priority: priority, + Type: demandType, + Status: status, + ParentReqID: parentReqID, + } + + demands = append(demands, demand) + + // 处理子需求 + for _, child := range node.Children { + childDemand := processDemand(child, node.ReqID) + // 子需求的父需求ID直接设置为当前需求的ID + childDemand.ParentReqID = node.ReqID + } + + return demand + } + + // 处理所有顶级需求 + for _, node := range nodes { + processDemand(node, node.ParentReqID) + } + + return demands, nil +} + +// buildPrompt 构建提示词,用于初始提取或后续提取 +func (dp *AgenticDemandProcessor) buildPrompt(chunk string, previousDemands []*M.Demand, currentChunkDemands []*M.Demand, isFollowUp bool) string { + // 获取JSON Schema - 复用成熟版本的函数 + schema, err := generateDemandJSONSchema() + if err != nil { + dp.logger.Printf("生成Schema失败: %v", err) + return "" + } + + var sb strings.Builder + + // 设置标题和基本介绍 + if isFollowUp { + sb.WriteString("# 继续提取需求\n\n") + sb.WriteString("请继续从当前文本块中提取剩余的需求信息,保持相同的输出格式。\n\n") + } else { + sb.WriteString("# 需求文档分析任务\n\n") + sb.WriteString("你是一个需求文档分析助手。你的任务是从需求规格说明书中提取需求信息并构建需求树,需求包含功能需求和非功能需求等。\n\n") + } + + // 显示先前文本块提取的需求 + if len(previousDemands) > 0 { + sb.WriteString("## 先前文本块中提取的需求\n\n") + sb.WriteString("以下是从先前文本块中提取的需求。你可以更新这些需求或建立与它们的关系:\n\n") + + for i, demand := range previousDemands { + sb.WriteString(fmt.Sprintf("%d. **%s** (ID: %s)\n", i+1, demand.Name, demand.ReqID)) + sb.WriteString(fmt.Sprintf(" - 描述: %s\n", demand.Description)) + sb.WriteString(fmt.Sprintf(" - 优先级: %s\n", demand.Priority)) + sb.WriteString(fmt.Sprintf(" - 类型: %s\n", demand.Type)) + sb.WriteString(fmt.Sprintf(" - 状态: %s\n", demand.Status)) + if demand.ParentReqID != "" { + sb.WriteString(fmt.Sprintf(" - 父需求ID: %s\n", demand.ParentReqID)) + } + sb.WriteString("\n") + } + } + + // 显示当前文本块已提取的需求(在has_more=true的情况下) + if len(currentChunkDemands) > 0 { + sb.WriteString("## 当前文本块已提取的需求\n\n") + sb.WriteString("以下是从当前文本块中已经提取的需求。请不要重复这些需求,继续提取未捕获的需求:\n\n") + + for i, demand := range currentChunkDemands { + sb.WriteString(fmt.Sprintf("%d. **%s** (ID: %s)\n", i+1, demand.Name, demand.ReqID)) + sb.WriteString(fmt.Sprintf(" - 描述: %s\n", demand.Description)) + sb.WriteString(fmt.Sprintf(" - 优先级: %s\n", demand.Priority)) + sb.WriteString(fmt.Sprintf(" - 类型: %s\n", demand.Type)) + sb.WriteString(fmt.Sprintf(" - 状态: %s\n", demand.Status)) + if demand.ParentReqID != "" { + sb.WriteString(fmt.Sprintf(" - 父需求ID: %s\n", demand.ParentReqID)) + } + sb.WriteString("\n") + } + } + + // 当前任务说明 + sb.WriteString("## 当前任务\n\n") + if len(previousDemands) == 0 && !isFollowUp { + sb.WriteString("这是文档的第一个文本块,请提取所有需求信息,包括需求ID、标题、描述、优先级、类型和状态。\n") + sb.WriteString("请确保完整提取每个需求的所有信息,并正确构建需求的层级关系。\n\n") + } else { + sb.WriteString("你需要对当前文本块执行以下操作:\n") + sb.WriteString("1. 提取此文本块中的**新需求**\n") + if len(previousDemands) > 0 { + sb.WriteString("2. **更新已有需求**:如果发现对先前文本块需求的补充信息,请使用相同的reqID更新它们\n") + sb.WriteString("3. 建立需求之间的关系:如果发现已有需求的子需求,请正确设置父子关系\n") + } + sb.WriteString("4. 所有需求必须有唯一的ID、标题、描述、优先级、类型和状态\n") + sb.WriteString("5. 如果还有更多需求要提取但未能在当前输出中包含,设置 has_more 为 true\n\n") + + if len(previousDemands) > 0 { + sb.WriteString("注意:当你更新已有需求时,请保持相同的req_id,这样我们就知道它是对已有需求的更新而不是新需求。\n\n") + } + } + if !isFollowUp { + sb.WriteString(`注意你要总结、提取需求规格说明书中的需求而不是自己创造需求,所以如果是无关内容(比如作者、单位等),你直接输出空数组,并且has_more设置成false就可以 +注意has_more为true表示的是当前给你提供的文档中还有需求没有提取出来,而不是完整文档中还有内容没有提取出来,当前待分析文档中没有更多需求的时候,你要把has_more设置成false +`) + sb.WriteString("优先级、状态、ID等、描述等信息不是必须的,只要你能总结出名称就可以算一个需求") + // sb.WriteString("如果文本内容是目录,你也可以提取出来需求,因为之后给你提供详细内容的时候,你可以使用相同的req_id给它补充详细内容\n\n") + } + + // 待分析文本 + sb.WriteString("## 待分析文本\n\n") + sb.WriteString(chunk) + + // 输出要求 + sb.WriteString("\n\n## 输出要求\n\n") + sb.WriteString("请以JSON格式输出需求列表,严格遵循以下JSON Schema:\n\n") + sb.WriteString(string(schema)) + sb.WriteString("\n\n请注意:\n") + sb.WriteString("1. 对于新需求,请分配新的req_id\n") + if len(previousDemands) > 0 { + sb.WriteString("2. 对于已有需求的更新,请保持相同的req_id\n") + } + sb.WriteString("3. 确保输出的JSON符合上述schema格式\n") + if len(previousDemands) > 0 { + sb.WriteString("4. 如果此文本块包含已有需求的补充信息,请使用相同的req_id并提供完整的更新后的需求\n") + } + sb.WriteString("5. 如果当前文本块还有更多需求未处理完,设置 \"has_more\": true;否则设置 \"has_more\": false\n") + + return sb.String() +} + +// saveDemands 保存需求到数据库 +func (dp *AgenticDemandProcessor) saveDemands(doc *M.Doc, demands []*M.Demand) error { + dp.logger.Printf("开始保存需求到数据库,文档ID: %s,需求数量: %d", doc.ID, len(demands)) + + if len(demands) == 0 { + dp.logger.Printf("警告:没有需求需要保存") + return nil + } + + // 开始事务 + tx := cfg.DB().Begin() + dp.logger.Printf("数据库事务已开始") + + defer func() { + if r := recover(); r != nil { + dp.logger.Printf("保存过程中发生panic: %v", r) + tx.Rollback() + } + }() + + // 先获取当前文档中的所有已存在需求,用于去重和更新 + var existingDemands []*M.Demand + if err := tx.Where("doc_id = ?", doc.ID).Find(&existingDemands).Error; err != nil { + tx.Rollback() + return fmt.Errorf("获取现有需求失败: %v", err) + } + + // 创建req_id到数据库需求的映射,用于快速查找 + existingReqIDMap := make(map[string]*M.Demand) + for _, d := range existingDemands { + existingReqIDMap[d.ReqID] = d + } + + // 为了处理父子关系,先进行两轮处理: + // 1. 第一轮:更新/插入所有需求,记录新旧ID对应关系 + // 2. 第二轮:更新父子关系 + + // 记录req_id到数据库ID的映射 + reqIDToDBID := make(map[string]string) + for _, ed := range existingDemands { + reqIDToDBID[ed.ReqID] = ed.ID + } + + // 第一轮:更新或插入需求 + for i, demand := range demands { + demand.DocID = doc.ID + + // 设置默认值 + if demand.Status == "" { + demand.Status = "待实现" + } + if demand.Type == "" { + demand.Type = "功能需求" + } + if demand.Priority == "" { + demand.Priority = "中" + } + + // 检查是否已存在相同req_id的需求 + if existingDemand, exists := existingReqIDMap[demand.ReqID]; exists { + dp.logger.Printf("需求 #%d (reqID=%s) 已存在,进行更新", i+1, demand.ReqID) + + // 保留原始ID和创建时间,更新其他字段 + demand.ID = existingDemand.ID + demand.CreatedAt = existingDemand.CreatedAt + + // 临时清除父子关系字段,稍后单独处理 + tempParentReqID := demand.ParentReqID + demand.ParentReqID = "" + demand.Tree.ParentID = "" + + // 更新需求 + if err := tx.Model(existingDemand).Updates(demand).Error; err != nil { + dp.logger.Printf("更新需求失败: %v", err) + tx.Rollback() + return fmt.Errorf("更新需求失败: %v", err) + } + + // 恢复父需求ID以便第二轮处理 + demand.ParentReqID = tempParentReqID + + dp.logger.Printf("需求 #%d 更新成功", i+1) + } else { + dp.logger.Printf("需求 #%d (reqID=%s) 是新需求,创建记录", i+1, demand.ReqID) + + // 临时清除父子关系字段,稍后单独处理 + tempParentReqID := demand.ParentReqID + demand.ParentReqID = "" + demand.Tree.ParentID = "" + + // 创建新需求 + if err := tx.Create(demand).Error; err != nil { + dp.logger.Printf("创建需求失败: %v", err) + tx.Rollback() + return fmt.Errorf("保存需求失败: %v", err) + } + + // 恢复父需求ID以便第二轮处理 + demand.ParentReqID = tempParentReqID + + dp.logger.Printf("需求 #%d 创建成功,ID: %s", i+1, demand.ID) + + // 更新映射 + reqIDToDBID[demand.ReqID] = demand.ID + } + } + + // 第二轮:更新父子关系 + for i, demand := range demands { + if demand.ParentReqID != "" { + // 查找父需求的数据库ID + parentDBID, exists := reqIDToDBID[demand.ParentReqID] + if !exists { + dp.logger.Printf("警告:需求 #%d 的父需求 (reqID=%s) 未找到", i+1, demand.ParentReqID) + continue + } + + // 更新父子关系 + updateFields := map[string]interface{}{ + "parent_req_id": demand.ParentReqID, + "parent_id": parentDBID, + "level": demand.Tree.Level, + } + + if err := tx.Model(&M.Demand{}).Where("id = ?", reqIDToDBID[demand.ReqID]).Updates(updateFields).Error; err != nil { + dp.logger.Printf("更新需求 #%d 的父子关系失败: %v", i+1, err) + tx.Rollback() + return fmt.Errorf("更新父子关系失败: %v", err) + } + + dp.logger.Printf("需求 #%d 的父子关系更新成功", i+1) + } + } + + // 提交事务 + dp.logger.Printf("所有需求保存完成,提交事务") + if err := tx.Commit().Error; err != nil { + dp.logger.Printf("提交事务失败: %v", err) + return fmt.Errorf("提交事务失败: %v", err) + } + + dp.logger.Printf("事务提交成功,总共处理了 %d 个需求", len(demands)) + return nil +} + +// func (dp *AgenticDemandProcessor) saveDemands(doc *M.Doc, demands []*M.Demand) error { +// // 开始事务 +// tx := cfg.DB().Begin() +// defer func() { +// if r := recover(); r != nil { +// tx.Rollback() +// } +// }() + +// // 为每个需求设置文档ID并保存 +// for _, demand := range demands { +// demand.DocID = doc.ID + +// // 设置默认值 +// if demand.Status == "" { +// demand.Status = "待实现" +// } +// if demand.Type == "" { +// demand.Type = "功能需求" +// } +// if demand.Priority == "" { +// demand.Priority = "中" +// } + +// // 需要添加:计算当前节点的层级 +// level := 0 +// var parentID string + +// // 如果有父需求ID,查找父需求并设置正确的ParentID和Level +// if demand.ParentReqID != "" { +// var parentDemand M.Demand +// if err := tx.Where("req_id = ? AND doc_id = ?", demand.ParentReqID, doc.ID).First(&parentDemand).Error; err == nil { +// parentID = parentDemand.ID +// level = parentDemand.Level + 1 +// } +// } + +// // 设置正确的Tree结构 +// demand.Tree.ParentID = parentID +// demand.Tree.Level = level + +// if err := tx.Create(demand).Error; err != nil { +// tx.Rollback() +// return fmt.Errorf("保存需求失败: %v", err) +// } +// } + +// // 提交事务 +// if err := tx.Commit().Error; err != nil { +// return fmt.Errorf("提交事务失败: %v", err) +// } + +// return nil +// } + +// mergeDemands 合并新旧需求树,处理重复和补充关系 +// mergeDemands 合并新旧需求树,处理重复和补充关系 +func (dp *AgenticDemandProcessor) mergeDemands(oldDemands, newDemands []*M.Demand) []*M.Demand { + result := make([]*M.Demand, len(oldDemands)) + copy(result, oldDemands) + + // 创建reqID到需求索引的映射,用于快速查找 + reqIDToIndex := make(map[string]int) + for i, demand := range result { + reqIDToIndex[demand.ReqID] = i + } + + // 遍历新需求 + for _, newDemand := range newDemands { + merged := false + + // 首先检查是否有相同的reqID(优先使用reqID进行匹配) + if idx, exists := reqIDToIndex[newDemand.ReqID]; exists { + // 找到相同reqID的需求,进行合并 + dp.logger.Printf("找到相同reqID的需求: %s,进行更新", newDemand.ReqID) + + // 合并描述(如果新描述更详细) + if len(newDemand.Description) > len(result[idx].Description) { + result[idx].Description = newDemand.Description + } + + // 更新标题(如果提供了更详细的标题) + if len(newDemand.Name) > len(result[idx].Name) { + result[idx].Name = newDemand.Name + } + + // 更新优先级(如果有新的优先级) + if newDemand.Priority != "" && newDemand.Priority != "中" { + result[idx].Priority = newDemand.Priority + } + + // 更新类型(如果有新的类型) + if newDemand.Type != "" && newDemand.Type != "功能需求" { + result[idx].Type = newDemand.Type + } + + // 更新状态(如果有新的状态) + if newDemand.Status != "" && newDemand.Status != "待实现" { + result[idx].Status = newDemand.Status + } + + // 更新父需求ID(如果有新的父需求ID) + if newDemand.ParentReqID != "" { + result[idx].ParentReqID = newDemand.ParentReqID + } + + merged = true + } else { + // 如果没有相同的reqID,检查标题相似度 + for i, oldDemand := range result { + if dp.isSimilarTitle(oldDemand.Name, newDemand.Name) { + dp.logger.Printf("找到标题相似的需求: %s 和 %s,进行合并", oldDemand.Name, newDemand.Name) + + // 合并描述(如果新描述更详细) + if len(newDemand.Description) > len(oldDemand.Description) { + result[i].Description = newDemand.Description + } + + // 更新优先级(如果有新的优先级) + if newDemand.Priority != "" && newDemand.Priority != "中" { + result[i].Priority = newDemand.Priority + } + + // 更新类型(如果有新的类型) + if newDemand.Type != "" && newDemand.Type != "功能需求" { + result[i].Type = newDemand.Type + } + + // 更新状态(如果有新的状态) + if newDemand.Status != "" && newDemand.Status != "待实现" { + result[i].Status = newDemand.Status + } + + // 更新父需求ID(如果有新的父需求ID) + if newDemand.ParentReqID != "" { + result[i].ParentReqID = newDemand.ParentReqID + } + + merged = true + break + } + } + } + + // 如果是新需求,添加到结果中 + if !merged { + dp.logger.Printf("添加新需求: %s (ID: %s)", newDemand.Name, newDemand.ReqID) + result = append(result, newDemand) + // 更新索引映射 + reqIDToIndex[newDemand.ReqID] = len(result) - 1 + } + } + + // 建立需求间的关系 + dp.establishRelationships(result) + + return result +} + +// isSimilarTitle 判断两个标题是否相似 +func (dp *AgenticDemandProcessor) isSimilarTitle(title1, title2 string) bool { + // 简单实现:如果标题包含关系或相似度高于阈值,则认为相似 + // 可以使用更复杂的算法,如编辑距离、词向量相似度等 + title1 = strings.ToLower(strings.TrimSpace(title1)) + title2 = strings.ToLower(strings.TrimSpace(title2)) + + // 检查包含关系 + if strings.Contains(title1, title2) || strings.Contains(title2, title1) { + return true + } + + // TODO: 实现更复杂的相似度算法 + return false +} + +// establishRelationships 建立需求间的关系 +func (dp *AgenticDemandProcessor) establishRelationships(demands []*M.Demand) { + // 创建标题到需求的映射,用于快速查找 + titleToReqID := make(map[string]string) + for _, demand := range demands { + titleToReqID[demand.Name] = demand.ReqID + } + + // 遍历临时结构中的需求,设置父需求ID + for i, demand := range demands { + // 尝试从parseDemandsFromResponse中的临时结构获取父需求标题 + // 这里假设我们有一个方式获取父需求标题,例如通过额外字段或解析描述 + // 在实际实现中,可能需要调整这部分逻辑 + + // 示例:从描述中提取父需求信息 + parentTitle := extractParentTitleFromDescription(demand.Description) + if parentTitle != "" { + if parentReqID, exists := titleToReqID[parentTitle]; exists { + demands[i].ParentReqID = parentReqID + } + } + } +} + +// extractParentTitleFromDescription 从描述中提取父需求标题 +func extractParentTitleFromDescription(description string) string { + // 这里实现一个简单的逻辑,从描述中提取父需求信息 + // 例如,如果描述中包含"父需求:"或"Parent:"等标记 + + // 简单示例实现 + parentPrefixes := []string{"父需求:", "Parent:", "父级需求:", "上级需求:"} + lines := strings.Split(description, "\n") + + for _, line := range lines { + line = strings.TrimSpace(line) + for _, prefix := range parentPrefixes { + if strings.HasPrefix(line, prefix) { + return strings.TrimSpace(line[len(prefix):]) + } + } + } + + return "" +} + +// 以下是直接复用成熟版本的函数 diff --git a/api/doc/handler.go b/api/doc/handler.go new file mode 100644 index 0000000..8abfdc5 --- /dev/null +++ b/api/doc/handler.go @@ -0,0 +1,250 @@ +package doc + +import ( + "app/cfg" + "app/models" + "app/utils" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/veypi/OneBD/rest" + "gorm.io/gorm" +) + +var _ = Router.Get("/:id", getHandle) +var _ = Router.Get("/:id/project", getProjectIDHandle) +var _ = Router.Get("", getHandle) + +// 获取文档对应的项目ID +func getProjectIDHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + + // 获取文档ID + docID, ok := x.Params.Get("id") + if !ok { + return nil, errors.New("缺少文档ID") + } + + // 查询文档信息 + var doc models.Doc + if err := cfg.DB().Where("id = ?", docID).First(&doc).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("文档不存在") + } + return nil, fmt.Errorf("查询文档失败: %v", err) + } + + return map[string]interface{}{ + "doc_id": docID, + "project_id": doc.ProjectID, + }, nil +} + +// 获取文档 +func getHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + id, ok := x.Params.Get("id") + // fmt.Println("getHandle", id) + if !ok { + // 获取所有文档 + var docs []models.Doc + if err := cfg.DB().Find(&docs).Error; err != nil { + return nil, err + } + return docs, nil + } + + // 获取特定文档 + var docs []models.Doc + if err := cfg.DB().Where("project_id = ?", id).Find(&docs).Error; err != nil { + return nil, err + } + // fmt.Println("getHandle", doc) + return docs, nil +} + +var _ = Router.Post("", postHandle) + +// 创建文档 +// 创建文档 +func postHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + + // 解析表单数据 + if err := x.Request.ParseMultipartForm(10 << 20); err != nil { // 10 MB + return nil, fmt.Errorf("解析表单失败: %v", err) + } + + // 获取项目ID + projectID := x.Request.FormValue("project_id") + if projectID == "" { + return nil, errors.New("缺少project_id参数") + } + + // 获取文档名称 + name := x.Request.FormValue("name") + if name == "" { + return nil, errors.New("缺少name参数") + } + + // 获取文档类型 + docType := x.Request.FormValue("type") + if docType == "" { + docType = "other" // 默认类型 + } + + // 获取文档描述 + description := x.Request.FormValue("description") + + // 获取上传的文件 + file, header, err := x.Request.FormFile("file") + if err != nil { + return nil, fmt.Errorf("获取文件失败: %v", err) + } + defer file.Close() + + // 验证文件类型 + fileExt := strings.ToLower(filepath.Ext(header.Filename)) + allowedTypes := map[string]bool{ + ".docx": true, + ".txt": true, + ".pdf": true, + } + if !allowedTypes[fileExt] { + return nil, fmt.Errorf("不支持的文件类型: %s", fileExt) + } + + // 创建上传目录 + uploadDir := filepath.Join("uploads", "docs", projectID) + if err := os.MkdirAll(uploadDir, 0755); err != nil { + return nil, fmt.Errorf("创建上传目录失败: %v", err) + } + + // 生成唯一文件名 + fileName := fmt.Sprintf("%d_%s", time.Now().UnixNano(), header.Filename) + filePath := filepath.Join(uploadDir, fileName) + + // 保存文件 + dst, err := os.Create(filePath) + if err != nil { + return nil, fmt.Errorf("创建目标文件失败: %v", err) + } + defer dst.Close() + + size, err := io.Copy(dst, file) + if err != nil { + os.Remove(filePath) // 清理失败的文件 + return nil, fmt.Errorf("保存文件失败: %v", err) + } + + // 创建文档记录 + doc := &models.Doc{ + ProjectID: projectID, + Name: name, + Description: description, + FilePath: filePath, + FileType: fileExt[1:], // 移除点号 + FileSize: size, + FileName: header.Filename, + AnalysisCompleted: false, + AnalysisPercent: 0, + AnalysisError: "", + Merged: false, + } + + if err := cfg.DB().Create(doc).Error; err != nil { + os.Remove(filePath) // 清理文件 + return nil, fmt.Errorf("创建文档记录失败: %v", err) + } + + // 将文档添加到处理队列 + // GetDemandProcessor().AddTask(doc, filePath) + GetAgenticDemandProcessor().AddTask(doc, filePath) + + return doc, nil +} + +var _ = Router.Patch("/:id", patchHandle) + +// 更新文档 +func patchHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + // 从URL获取ID + body, err := io.ReadAll(x.Request.Body) + if err != nil { + return nil, fmt.Errorf("读取请求体失败: %v", err) + } + + id, ok := x.Params.Get("id") + if !ok { + return nil, errors.New("缺少id") + } + var updateMap map[string]interface{} + if err := json.Unmarshal(body, &updateMap); err != nil { + return nil, fmt.Errorf("解析JSON失败: %v", err) + } + + // 先检查文档是否存在 + var doc models.Doc + if err := cfg.DB().Where("id = ?", id).First(&doc).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("文档不存在") + } + return nil, err + } + + // 删除不允许更新的字段 + delete(updateMap, "id") + + // 只更新提供的字段 + if len(updateMap) > 0 { + if err := cfg.DB().Model(&doc).Updates(updateMap).Error; err != nil { + return nil, err + } + } + + // 重新查询更新后的完整数据 + if err := cfg.DB().Where("id = ?", id).First(&doc).Error; err != nil { + return nil, err + } + + return doc, nil +} + +var _ = Router.Delete("/:id", deleteHandle) + +// 删除文档 +func deleteHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + id, ok := x.Params.Get("id") + if !ok { + return nil, errors.New("缺少id") + } + + var doc models.Doc + if err := cfg.DB().Where("id = ?", id).First(&doc).Error; err != nil { + return nil, err + } + + // 删除文档 + if err := cfg.DB().Delete(&doc).Error; err != nil { + return nil, err + } + + return map[string]interface{}{ + "msg": "删除成功", + }, nil +} + +var _ = Router.Any("/*", anyHandle) + +func anyHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + return nil, nil +} diff --git a/api/doc/init.go b/api/doc/init.go new file mode 100644 index 0000000..617710d --- /dev/null +++ b/api/doc/init.go @@ -0,0 +1,11 @@ +package doc + +import ( + "github.com/veypi/OneBD/rest" +) + +var Router = rest.NewRouter() + +func init() { + // 初始化路由 +} \ No newline at end of file diff --git a/api/doc/llm_service.py b/api/doc/llm_service.py new file mode 100644 index 0000000..e69de29 diff --git a/api/endpoint/handler.go b/api/endpoint/handler.go new file mode 100644 index 0000000..6552c23 --- /dev/null +++ b/api/endpoint/handler.go @@ -0,0 +1,170 @@ +package endpoint + +import ( + "app/cfg" + "app/models" + "app/utils" + "encoding/json" + "errors" + "fmt" + "io" + + "github.com/google/uuid" + "github.com/veypi/OneBD/rest" + "gorm.io/gorm" +) + +var _ = Router.Get("/:id", getHandle) +var _ = Router.Get("", getHandle) + +// 获取接口 +func getHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + id, ok := x.Params.Get("id") + + if !ok { + // 获取所有接口 + var endpoints []models.Endpoint + if err := cfg.DB().Find(&endpoints).Error; err != nil { + return nil, err + } + return endpoints, nil + } + + // 获取特定接口 + var endpoint models.Endpoint + if err := cfg.DB().Where("id = ?", id).First(&endpoint).Error; err != nil { + return nil, err + } + + // 获取接口的参数和响应 + var parameters []models.Parameter + if err := cfg.DB().Where("endpoint_id = ?", endpoint.ID).Find(¶meters).Error; err != nil { + return nil, err + } + + var responses []models.Response + if err := cfg.DB().Where("endpoint_id = ?", endpoint.ID).Find(&responses).Error; err != nil { + return nil, err + } + + return map[string]interface{}{ + "endpoint": endpoint, + "parameters": parameters, + "responses": responses, + }, nil +} + +var _ = Router.Post("", postHandle) + +// 创建接口 +func postHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + endpoint := &models.Endpoint{} + if err := json.NewDecoder(x.Request.Body).Decode(&endpoint); err != nil { + return nil, err + } + endpoint.ID = uuid.New().String()[0:32] + + if err := cfg.DB().Create(&endpoint).Error; err != nil { + return nil, err + } + return endpoint, nil +} + +var _ = Router.Patch("/:id", patchHandle) + +// 更新接口 +func patchHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + // 从URL获取ID + body, err := io.ReadAll(x.Request.Body) + if err != nil { + return nil, fmt.Errorf("读取请求体失败: %v", err) + } + + id, ok := x.Params.Get("id") + if !ok { + return nil, errors.New("缺少id") + } + var updateMap map[string]interface{} + if err := json.Unmarshal(body, &updateMap); err != nil { + return nil, fmt.Errorf("解析JSON失败: %v", err) + } + + // 先检查接口是否存在 + var endpoint models.Endpoint + if err := cfg.DB().Where("id = ?", id).First(&endpoint).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("接口不存在") + } + return nil, err + } + + // 删除不允许更新的字段 + delete(updateMap, "id") + + // 只更新提供的字段 + if len(updateMap) > 0 { + if err := cfg.DB().Model(&endpoint).Updates(updateMap).Error; err != nil { + return nil, err + } + } + + // 重新查询更新后的完整数据 + if err := cfg.DB().Where("id = ?", id).First(&endpoint).Error; err != nil { + return nil, err + } + + return endpoint, nil +} + +var _ = Router.Delete("/:id", deleteHandle) + +// 删除接口 +func deleteHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + id, ok := x.Params.Get("id") + if !ok { + return nil, errors.New("缺少id") + } + + var endpoint models.Endpoint + if err := cfg.DB().Where("id = ?", id).First(&endpoint).Error; err != nil { + return nil, err + } + + // 开始事务 + tx := cfg.DB().Begin() + + // 删除关联的参数 + if err := tx.Where("endpoint_id = ?", id).Delete(&models.Parameter{}).Error; err != nil { + tx.Rollback() + return nil, err + } + + // 删除关联的响应 + if err := tx.Where("endpoint_id = ?", id).Delete(&models.Response{}).Error; err != nil { + tx.Rollback() + return nil, err + } + + // 删除接口 + if err := tx.Delete(&endpoint).Error; err != nil { + tx.Rollback() + return nil, err + } + + tx.Commit() + + return map[string]interface{}{ + "msg": "删除成功", + }, nil +} + +var _ = Router.Any("/*", anyHandle) + +func anyHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + return nil, nil +} diff --git a/api/endpoint/init.go b/api/endpoint/init.go new file mode 100644 index 0000000..581a7ff --- /dev/null +++ b/api/endpoint/init.go @@ -0,0 +1,11 @@ +package endpoint + +import ( + "github.com/veypi/OneBD/rest" +) + +var Router = rest.NewRouter() + +func init() { + // 初始化路由 +} \ No newline at end of file diff --git a/api/graph/handler.go b/api/graph/handler.go new file mode 100644 index 0000000..1548139 --- /dev/null +++ b/api/graph/handler.go @@ -0,0 +1,121 @@ +package graph + +import ( + "app/cfg" + "app/models" + "app/utils" + "errors" + "fmt" + + "github.com/veypi/OneBD/rest" +) + +var _ = Router.Get("", getHandle) +var _ = Router.Get("/:stage_id/", getHandle) + +// getHandle 获取完整的图形数据,包括节点和连接 +func getHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + stageID, ok := x.Params.Get("stage_id") + if !ok { + return nil, errors.New("缺少stage_id") + } + + // 验证阶段是否存在 + var stage models.Stage + if err := cfg.DB().Where("id = ?", stageID).First(&stage).Error; err != nil { + return nil, errors.New("阶段不存在") + } + + // 获取该阶段的所有节点 + var nodes []models.GraphNode + if err := cfg.DB().Where("stage_id = ?", stageID).Find(&nodes).Error; err != nil { + return nil, err + } + + // 获取该阶段的所有连接 + var links []models.GraphLink + if err := cfg.DB().Where("stage_id = ?", stageID).Find(&links).Error; err != nil { + return nil, err + } + + // 返回完整的图形数据 + return map[string]interface{}{ + "stage": stage, + "nodes": nodes, + "links": links, + }, nil +} + +var _ = Router.Post("/:stage_id/", saveHandle) + +func saveHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + fmt.Println("saveHandle") + stageID, ok := x.Params.Get("stage_id") + if !ok { + return nil, errors.New("缺少stage_id") + } + + // 验证阶段是否存在 + var stage models.Stage + if err := cfg.DB().Where("id = ?", stageID).First(&stage).Error; err != nil { + return nil, errors.New("阶段不存在") + } + + // 解析请求体 + var data struct { + Nodes []models.GraphNode `json:"nodes"` + Links []models.GraphLink `json:"links"` + } + if err := x.Parse(&data); err != nil { + return nil, err + } + // fmt.Printf("%+v\n", data) + // 开始事务 + tx := cfg.DB().Begin() + + // 删除原有的节点和连接 + if err := tx.Where("stage_id = ?", stageID).Delete(&models.GraphNode{}).Error; err != nil { + tx.Rollback() + return nil, err + } + if err := tx.Where("stage_id = ?", stageID).Delete(&models.GraphLink{}).Error; err != nil { + tx.Rollback() + return nil, err + } + + // 保存新的节点和连接 + for _, node := range data.Nodes { + node.StageID = stageID + if err := tx.Create(&node).Error; err != nil { + tx.Rollback() + return nil, err + } + } + + for _, link := range data.Links { + link.StageID = stageID + if err := tx.Create(&link).Error; err != nil { + tx.Rollback() + return nil, err + } + } + + // 提交事务 + if err := tx.Commit().Error; err != nil { + return nil, err + } + + return map[string]interface{}{ + "message": "保存成功", + }, nil +} + +var _ = Router.Any("/*", anyHandle) + +// anyHandle 处理其他请求 +func anyHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + return nil, nil +} diff --git a/api/graph/init.go b/api/graph/init.go new file mode 100644 index 0000000..0e57ce7 --- /dev/null +++ b/api/graph/init.go @@ -0,0 +1,7 @@ +package graph + +import ( + "github.com/veypi/OneBD/rest" +) + +var Router = rest.NewRouter() \ No newline at end of file diff --git a/api/graph_link/handler.go b/api/graph_link/handler.go new file mode 100644 index 0000000..12cc56a --- /dev/null +++ b/api/graph_link/handler.go @@ -0,0 +1,236 @@ +package graph_link + +import ( + "app/cfg" + "app/models" + "app/utils" + "encoding/json" + "errors" + "fmt" + "io" + + "github.com/google/uuid" + "github.com/veypi/OneBD/rest" + "gorm.io/gorm" +) +var _ = Router.Get("/:id/", getHandle)//TODO +var _ = Router.Get("", getHandle) + +func getHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + stageID, ok := x.Params.Get("id") + if !ok { + return nil, errors.New("缺少stage_id") + } + + var links []models.GraphLink + if err := cfg.DB().Where("stage_id = ?", stageID).Find(&links).Error; err != nil { + return nil, err + } + return links, nil +} + +// 获取单个连接详情 +func getOneHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + id, ok := x.Params.Get("id") + if !ok { + return nil, errors.New("缺少id") + } + + var link models.GraphLink + if err := cfg.DB().Where("id = ?", id).First(&link).Error; err != nil { + return nil, err + } + + sourceNode, _ := link.SourceNode(cfg.DB()) + targetNode, _ := link.TargetNode(cfg.DB()) + + return map[string]interface{}{ + "link": link, + "source_node": sourceNode, + "target_node": targetNode, + }, nil +} +var _ = Router.Post("", postHandle) + +// 创建图形连接 +func postHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + link := &models.GraphLink{} + if err := json.NewDecoder(x.Request.Body).Decode(&link); err != nil { + return nil, err + } + link.ID = uuid.New().String()[0:32] + + // 如果没有提供LinkID,则生成一个 + if link.LinkID == "" { + link.LinkID = fmt.Sprintf("%s-%s", link.SourceID, link.TargetID) + } + + // 验证源节点是否存在 + var sourceNode models.GraphNode + if err := cfg.DB().Where("instance_id = ?", link.SourceID).First(&sourceNode).Error; err != nil { + return nil, errors.New("源节点不存在") + } + + // 验证目标节点是否存在 + var targetNode models.GraphNode + if err := cfg.DB().Where("instance_id = ?", link.TargetID).First(&targetNode).Error; err != nil { + return nil, errors.New("目标节点不存在") + } + + // 验证两个节点是否在同一个阶段 + if sourceNode.StageID != targetNode.StageID { + return nil, errors.New("源节点和目标节点必须在同一个阶段") + } + + // 设置阶段ID + link.StageID = sourceNode.StageID + + // 验证是否已存在相同的连接 + var count int64 + if err := cfg.DB().Model(&models.GraphLink{}).Where("source_id = ? AND target_id = ?", link.SourceID, link.TargetID).Count(&count).Error; err != nil { + return nil, err + } + if count > 0 { + return nil, errors.New("已存在相同的连接") + } + + if err := cfg.DB().Create(&link).Error; err != nil { + return nil, err + } + return link, nil +} +var _ = Router.Patch("/:id/", patchHandle) +// 更新图形连接 +func patchHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + // 从URL获取ID + body, err := io.ReadAll(x.Request.Body) + if err != nil { + return nil, fmt.Errorf("读取请求体失败: %v", err) + } + + id, ok := x.Params.Get("id") + if !ok { + return nil, errors.New("缺少id") + } + var updateMap map[string]interface{} + if err := json.Unmarshal(body, &updateMap); err != nil { + return nil, fmt.Errorf("解析JSON失败: %v", err) + } + + // 先检查连接是否存在 + var link models.GraphLink + if err := cfg.DB().Where("id = ?", id).First(&link).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("连接不存在") + } + return nil, err + } + + // 如果更新包含source_id或target_id,需要验证节点是否存在和是否在同一阶段 + sourceID, hasSourceID := updateMap["source_id"] + targetID, hasTargetID := updateMap["target_id"] + + if hasSourceID || hasTargetID { + newSourceID := link.SourceID + newTargetID := link.TargetID + + if hasSourceID { + var ok bool + newSourceID, ok = sourceID.(string) + if !ok { + return nil, errors.New("source_id 必须是字符串类型") + } + } + + if hasTargetID { + var ok bool + newTargetID, ok = targetID.(string) + if !ok { + return nil, errors.New("target_id 必须是字符串类型") + } + } + + // 验证源节点是否存在 + var sourceNode models.GraphNode + if err := cfg.DB().Where("instance_id = ?", newSourceID).First(&sourceNode).Error; err != nil { + return nil, errors.New("源节点不存在") + } + + // 验证目标节点是否存在 + var targetNode models.GraphNode + if err := cfg.DB().Where("instance_id = ?", newTargetID).First(&targetNode).Error; err != nil { + return nil, errors.New("目标节点不存在") + } + + // 验证两个节点是否在同一个阶段 + if sourceNode.StageID != targetNode.StageID { + return nil, errors.New("源节点和目标节点必须在同一个阶段") + } + + // 验证是否已存在相同的连接 + var count int64 + if err := cfg.DB().Model(&models.GraphLink{}).Where("id != ? AND source_id = ? AND target_id = ?", id, newSourceID, newTargetID).Count(&count).Error; err != nil { + return nil, err + } + if count > 0 { + return nil, errors.New("已存在相同的连接") + } + + // 更新阶段ID + updateMap["stage_id"] = sourceNode.StageID + + // 更新LinkID + updateMap["link_id"] = fmt.Sprintf("%s-%s", newSourceID, newTargetID) + } + + // 删除不允许更新的字段 + delete(updateMap, "id") + + // 只更新提供的字段 + if len(updateMap) > 0 { + if err := cfg.DB().Model(&link).Updates(updateMap).Error; err != nil { + return nil, err + } + } + + // 重新查询更新后的完整数据 + if err := cfg.DB().Where("id = ?", id).First(&link).Error; err != nil { + return nil, err + } + + return link, nil +} +var _ = Router.Delete("/:id/", deleteHandle) + +// 删除图形连接 +func deleteHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + id, ok := x.Params.Get("id") + if !ok { + return nil, errors.New("缺少id") + } + + var link models.GraphLink + if err := cfg.DB().Where("id = ?", id).First(&link).Error; err != nil { + return nil, err + } + + // 删除连接 + if err := cfg.DB().Delete(&link).Error; err != nil { + return nil, err + } + + return map[string]interface{}{ + "msg": "删除成功", + }, nil +} +var _ = Router.Any("/*", anyHandle) + +func anyHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + return nil, nil +} diff --git a/api/graph_link/init.go b/api/graph_link/init.go new file mode 100644 index 0000000..390e2d0 --- /dev/null +++ b/api/graph_link/init.go @@ -0,0 +1,7 @@ +package graph_link + +import ( + "github.com/veypi/OneBD/rest" +) + +var Router = rest.NewRouter() \ No newline at end of file diff --git a/api/graph_node/handler.go b/api/graph_node/handler.go new file mode 100644 index 0000000..c817158 --- /dev/null +++ b/api/graph_node/handler.go @@ -0,0 +1,241 @@ +package graph_node + +import ( + "app/cfg" + "app/models" + "app/utils" + "encoding/json" + "errors" + "fmt" + "io" + + "github.com/google/uuid" + "github.com/veypi/OneBD/rest" + "gorm.io/gorm" +) +var _ = Router.Get("/:id/", getHandle) //TODO第一个命名生效 +var _ = Router.Get("", getHandle) +func getHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + stageID, ok := x.Params.Get("id") + if !ok { + return nil, errors.New("缺少stage_id") + } + + var nodes []models.GraphNode + if err := cfg.DB().Where("stage_id = ?", stageID).Find(&nodes).Error; err != nil { + return nil, err + } + return nodes, nil +} + +// 获取单个节点详情 +func getOneHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + id, ok := x.Params.Get("id") + if !ok { + return nil, errors.New("缺少id") + } + + var node models.GraphNode + if err := cfg.DB().Where("id = ?", id).First(&node).Error; err != nil { + return nil, err + } + + tree, _ := node.Tree(cfg.DB()) + links, _ := node.Links(cfg.DB()) + + return map[string]interface{}{ + "node": node, + "tree": tree, + "links": links, + }, nil +} +var _ = Router.Post("", postHandle) + +// 创建图形节点 +func postHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + node := &models.GraphNode{} + if err := json.NewDecoder(x.Request.Body).Decode(&node); err != nil { + return nil, err + } + node.ID = uuid.New().String()[0:32] + + // 验证树节点是否存在 + var tree models.Tree + if err := cfg.DB().Where("id = ?", node.TreeID).First(&tree).Error; err != nil { + return nil, errors.New("树节点不存在") + } + + // 验证阶段是否存在 + var stage models.Stage + if err := cfg.DB().Where("id = ?", node.StageID).First(&stage).Error; err != nil { + return nil, errors.New("阶段不存在") + } + + // 验证树节点是否是阶段指定树节点的子孙节点 + if stage.TreeID != "" && stage.TreeID != "root" { + var stageTree models.Tree + if err := cfg.DB().Where("id = ?", stage.TreeID).First(&stageTree).Error; err != nil { + return nil, errors.New("阶段指定的树节点不存在") + } + + // 获取阶段树节点的所有子孙节点 + descendants, err := stageTree.Descendants(cfg.DB()) + if err != nil { + return nil, err + } + + // 检查当前树节点是否是阶段树节点或其子孙节点 + isValid := tree.ID == stageTree.ID + for _, d := range descendants { + if tree.ID == d.ID { + isValid = true + break + } + } + + if !isValid { + return nil, errors.New("树节点不满足阶段要求") + } + } + + if err := cfg.DB().Create(&node).Error; err != nil { + return nil, err + } + return node, nil +} +var _ = Router.Patch("/:id/", patchHandle) + +// 更新图形节点 +func patchHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + // 从URL获取ID + body, err := io.ReadAll(x.Request.Body) + if err != nil { + return nil, fmt.Errorf("读取请求体失败: %v", err) + } + + id, ok := x.Params.Get("id") + if !ok { + return nil, errors.New("缺少id") + } + var updateMap map[string]interface{} + if err := json.Unmarshal(body, &updateMap); err != nil { + return nil, fmt.Errorf("解析JSON失败: %v", err) + } + + // 先检查节点是否存在 + var node models.GraphNode + if err := cfg.DB().Where("id = ?", id).First(&node).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("节点不存在") + } + return nil, err + } + + // 如果更新包含tree_id,需要验证树节点是否存在和是否符合阶段要求 + if treeID, ok := updateMap["tree_id"]; ok { + newTreeID, ok := treeID.(string) + if !ok { + return nil, errors.New("tree_id 必须是字符串类型") + } + + var tree models.Tree + if err := cfg.DB().Where("id = ?", newTreeID).First(&tree).Error; err != nil { + return nil, errors.New("树节点不存在") + } + + var stage models.Stage + if err := cfg.DB().Where("id = ?", node.StageID).First(&stage).Error; err != nil { + return nil, errors.New("阶段不存在") + } + + // 验证树节点是否是阶段指定树节点的子孙节点 + if stage.TreeID != "" && stage.TreeID != "root" { + var stageTree models.Tree + if err := cfg.DB().Where("id = ?", stage.TreeID).First(&stageTree).Error; err != nil { + return nil, errors.New("阶段指定的树节点不存在") + } + + // 获取阶段树节点的所有子孙节点 + descendants, err := stageTree.Descendants(cfg.DB()) + if err != nil { + return nil, err + } + + // 检查当前树节点是否是阶段树节点或其子孙节点 + isValid := tree.ID == stageTree.ID + for _, d := range descendants { + if tree.ID == d.ID { + isValid = true + break + } + } + + if !isValid { + return nil, errors.New("树节点不满足阶段要求") + } + } + } + + // 删除不允许更新的字段 + delete(updateMap, "id") + + // 只更新提供的字段 + if len(updateMap) > 0 { + if err := cfg.DB().Model(&node).Updates(updateMap).Error; err != nil { + return nil, err + } + } + + // 重新查询更新后的完整数据 + if err := cfg.DB().Where("id = ?", id).First(&node).Error; err != nil { + return nil, err + } + + return node, nil +} +var _ = Router.Delete("/:id/", deleteHandle) + +// 删除图形节点 +func deleteHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + id, ok := x.Params.Get("id") + if !ok { + return nil, errors.New("缺少id") + } + + var node models.GraphNode + if err := cfg.DB().Where("id = ?", id).First(&node).Error; err != nil { + return nil, err + } + + // 开始事务 + tx := cfg.DB().Begin() + + // 删除与该节点关联的所有连接 + if err := tx.Where("prev_id = ? OR next_id = ?", id, id).Delete(&models.GraphLink{}).Error; err != nil { + tx.Rollback() + return nil, err + } + + // 删除节点 + if err := tx.Delete(&node).Error; err != nil { + tx.Rollback() + return nil, err + } + + tx.Commit() + + return map[string]interface{}{ + "msg": "删除成功", + }, nil +} +var _ = Router.Any("/*", anyHandle) + +func anyHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + return nil, nil +} diff --git a/api/graph_node/init.go b/api/graph_node/init.go new file mode 100644 index 0000000..733d688 --- /dev/null +++ b/api/graph_node/init.go @@ -0,0 +1,7 @@ +package graph_node + +import ( + "github.com/veypi/OneBD/rest" +) + +var Router = rest.NewRouter() \ No newline at end of file diff --git a/api/init.go b/api/init.go new file mode 100644 index 0000000..5324af3 --- /dev/null +++ b/api/init.go @@ -0,0 +1,63 @@ +// +// Copyright (C) 2024 veypi +// 2025-02-27 19:32:09 +// Distributed under terms of the MIT license. +// +// Auto generated by OneBD. DO NOT EDIT + +package api + +import ( + "app/api/graph_link" + "app/api/graph_node" + "app/api/graph" + "app/api/stage" + "app/api/tree" + "app/api/demand" + "app/api/project" + pd "app/api/project/demand" + ps "app/api/project/stage" + psg "app/api/project/stage/graph" + psgl "app/api/project/stage/graph_link" + psgn "app/api/project/stage/graph_node" + pst "app/api/project/stage/tools" + pste "app/api/project/stage/tools/endpoint" + "app/api/doc" + "app/api/endpoint" + "fmt" + + "github.com/veypi/OneBD/rest" + "github.com/veypi/OneBD/rest/middlewares" +) + +var Router = rest.NewRouter().UseAfter(middlewares.JsonResponse, middlewares.JsonErrorResponse) + +var _ = Router.Get("hello", hello) + +func hello(x *rest.X) any { + return "hello" +} +var _ = Router.Extend("tree", tree.Router) +var _ = Router.Extend("stage", stage.Router) +var _ = Router.Extend("graph_node", graph_node.Router) +var _ = Router.Extend("graph_link", graph_link.Router) +var _ = Router.Extend("graph", graph.Router) +var _ = Router.Extend("project", project.Router).Extend("demand", pd.Router) +var _ = Router.Extend("project", project.Router).Extend("stage", ps.Router) + +var _ = Router.Extend("project", project.Router).Extend("stage", ps.Router).Extend("graph", psg.Router) +var _ = Router.Extend("project", project.Router).Extend("stage", ps.Router).Extend("graph_link", psgl.Router) +var _ = Router.Extend("project", project.Router).Extend("stage", ps.Router).Extend("graph_node", psgn.Router) +var _ = Router.Extend("project", project.Router).Extend("stage", ps.Router).Extend("tools", pst.Router).Extend("endpoint", pste.Router) +var _ = Router.Extend("doc", doc.Router) +var _ = Router.Extend("endpoint", endpoint.Router) +var _ = Router.Extend("demand", demand.Router) + + +var _ = Router.Any("*", api404) + +func api404(x *rest.X) error { + return fmt.Errorf("404") +} + + diff --git a/api/project/demand/handler.go b/api/project/demand/handler.go new file mode 100644 index 0000000..fd47e24 --- /dev/null +++ b/api/project/demand/handler.go @@ -0,0 +1,611 @@ +package demand + +import ( + "app/cfg" + "app/models" + "app/models/project" + "app/utils" + "encoding/json" + "errors" + "fmt" + "io" + "sort" + + "github.com/veypi/OneBD/rest" + "gorm.io/gorm" +) + +var _ = Router.Get("/:id/:min_level/", getHandle) +var _ = Router.Get("/:id/", getHandle) +var _ = Router.Get("", getHandle) + +// 获取需求 +func getHandle(x *rest.X) (any, error) { + // fmt.Println("getHandle") + utils.SetCORSHeaders(x) + id, ok := x.Params.Get("id") + minLevel, hasMinLevel := x.Params.Get("min_level") + project_id := id + // fmt.Println(project_id) + + // 获取所有根节点需求 + var demands []project.Demand + query := cfg.DB() + + // 如果指定了最小级别,添加筛选条件 + if hasMinLevel { + minLevelInt := 0 + fmt.Sscanf(minLevel, "%d", &minLevelInt) + query = query.Where("level >= ?", minLevelInt) + } + if !ok || id == "-1" { + if err := query.Find(&demands).Error; err != nil { + fmt.Println(err) + + return nil, err + } + // fmt.Println(demands) + + return demands, nil + } + // fmt.Println(project_id) + if err := query.Where("project_id = ?", project_id).Find(&demands).Error; err != nil { + fmt.Println(err) + return nil, err + } + + // 1. 先按照level从小到大排序 + sort.Slice(demands, func(i, j int) bool { + return demands[i].Level < demands[j].Level + }) + + // 2. 进行拓扑排序 + // 构建节点映射和依赖关系图 + nodeMap := make(map[string]*project.Demand) + for i := range demands { + nodeMap[demands[i].ID] = &demands[i] + } + + // 构建邻接表 + graph := make(map[string][]string) + for _, node := range demands { + if node.ParentID != "-1" && node.ParentID != "" { + // 父节点是子节点的依赖 + if _, exists := nodeMap[node.ParentID]; exists { + graph[node.ID] = append(graph[node.ID], node.ParentID) + } + } + } + + // 拓扑排序 + var result []project.Demand + visited := make(map[string]bool) + temp := make(map[string]bool) + + var visit func(string) bool + visit = func(id string) bool { + if temp[id] { + // 检测到循环依赖,跳过 + return false + } + if visited[id] { + return true + } + temp[id] = true + for _, dep := range graph[id] { + if !visit(dep) { + return false + } + } + temp[id] = false + visited[id] = true + result = append(result, *nodeMap[id]) + return true + } + + // 对每个节点执行拓扑排序 + for _, node := range demands { + if !visited[node.ID] { + visit(node.ID) + } + } + + // 如果拓扑排序成功,使用排序后的结果 + if len(result) == len(demands) { + demands = result + } + // 否则保持按level排序的结果 + + return demands, nil + +} + +var _ = Router.Get("/:id/descendants", getDescendantsHandle) + +// 获取所有后代节点 +func getDescendantsHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + id, ok := x.Params.Get("id") + if !ok { + return nil, errors.New("缺少id") + } + + // 获取特定节点 + var demand project.Demand + if err := cfg.DB().Where("id = ?", id).First(&demand).Error; err != nil { + return nil, err + } + + // 获取所有子孙节点 + descendants, err := demand.Descendants(cfg.DB()) + if err != nil { + return nil, err + } + + return map[string]interface{}{ + "node": demand, + "descendants": descendants, + }, nil +} + +// 创建需求 +var _ = Router.Post("/:id/", postHandle) + +func postHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + projectID, hasprojectID := x.Params.Get("id") + if !hasprojectID { + return nil, errors.New("缺少project_id") + } + + demand := &project.Demand{} + if err := json.NewDecoder(x.Request.Body).Decode(&demand); err != nil { + return nil, err + } + fmt.Printf("demand: %v\n", demand) + fmt.Printf("demand.parentid: %v\n", demand.ParentID) + // 设置文档ID和UUID + demand.ProjectID = projectID + // demand.ID = uuid.New().String()[0:32] + + // 设置级别 + if demand.ParentID == "-1" || demand.ParentID == "" { + demand.Level = 0 + } else { + var parent project.Demand + if err := cfg.DB().Where("id = ? AND project_id = ?", demand.ParentID, projectID).First(&parent).Error; err != nil { + return nil, fmt.Errorf("父节点不存在或不属于当前文档: %v", err) + } + demand.Level = parent.Level + 1 + } + + if err := cfg.DB().Create(&demand).Error; err != nil { + return nil, err + } + return demand, nil +} + +var _ = Router.Patch("/:id/", patchHandle) + +// 更新需求 +func patchHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + body, err := io.ReadAll(x.Request.Body) + if err != nil { + return nil, fmt.Errorf("读取请求体失败: %v", err) + } + + id, ok := x.Params.Get("id") + if !ok { + return nil, errors.New("缺少id") + } + var updateMap map[string]interface{} + if err := json.Unmarshal(body, &updateMap); err != nil { + return nil, fmt.Errorf("解析JSON失败: %v", err) + } + + // 先检查需求是否存在 + var demand project.Demand + if err := cfg.DB().Where("id = ?", id).First(&demand).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("需求不存在") + } + return nil, err + } + + // 如果更新包含parent_id,需要特殊处理层级 + if parentID, ok := updateMap["parent_id"]; ok { + newParentID, ok := parentID.(string) + if !ok { + return nil, errors.New("parent_id 必须是字符串类型") + } + + // 检查是否形成循环引用 + if newParentID == demand.ID { + return nil, errors.New("不能将节点的父节点设置为自身") + } + + if newParentID != demand.ParentID { + var newLevel int + // 检查新的父节点是否存在(除非是设置为根节点) + if newParentID != "-1" && newParentID != "" { + var parent project.Demand + if err := cfg.DB().Where("id = ?", newParentID).First(&parent).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("父节点不存在") + } + return nil, err + } + + // 检查新父节点是否是当前节点的子节点 + children, err := demand.Children(cfg.DB()) + if err != nil { + return nil, err + } + for _, child := range children { + if child.ID == newParentID { + return nil, errors.New("不能将子节点设置为父节点") + } + } + + newLevel = parent.Level + 1 + } else { + newLevel = 0 + } + + updateMap["level"] = newLevel + } + } + + // 删除不允许更新的字段 + delete(updateMap, "id") + delete(updateMap, "project_id") + + // 只更新提供的字段 + if len(updateMap) > 0 { + if err := cfg.DB().Model(&demand).Updates(updateMap).Error; err != nil { + return nil, err + } + } + + // 重新查询更新后的完整数据 + if err := cfg.DB().Where("id = ?", id).First(&demand).Error; err != nil { + return nil, err + } + + return demand, nil +} + +var _ = Router.Delete("/:id/", deleteHandle) + +// 删除需求 +func deleteHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + id, ok := x.Params.Get("id") + if !ok { + return nil, errors.New("缺少id") + } + + var demand project.Demand + if err := cfg.DB().Where("id = ?", id).First(&demand).Error; err != nil { + return nil, err + } + + // 删除需求及其所有子需求 + descendants, err := demand.Descendants(cfg.DB()) + if err != nil { + return nil, err + } + + tx := cfg.DB().Begin() + + // 删除所有子需求 + for _, desc := range descendants { + if err := tx.Delete(&desc).Error; err != nil { + tx.Rollback() + return nil, err + } + } + + // 删除当前需求 + if err := tx.Delete(&demand).Error; err != nil { + tx.Rollback() + return nil, err + } + + tx.Commit() + + return map[string]interface{}{ + "msg": "删除成功", + }, nil +} + +// 在现有代码后添加新的接口处理函数 + +var _ = Router.Post("/:id/merge", mergeNodesHandle) + +// 合并多个节点到目标节点 +func mergeNodesHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + + // 获取目标项目ID + docID, hasDocID := x.Params.Get("id") + if !hasDocID { + return nil, errors.New("缺少文档ID") + } + + // 通过文档ID查询项目ID + var doc models.Doc + if err := cfg.DB().Where("id = ?", docID).First(&doc).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("文档不存在") + } + return nil, fmt.Errorf("查询文档失败: %v", err) + } + + // 获取项目ID + projectID := doc.ProjectID + + // 解析请求体 + var mergeRequest struct { + TargetNodeID string `json:"target_node_id"` // 目标节点ID + SourceNodeIDs []string `json:"source_node_ids"` // 要合并的源节点ID列表 + } + + if err := json.NewDecoder(x.Request.Body).Decode(&mergeRequest); err != nil { + return nil, fmt.Errorf("解析请求体失败: %v", err) + } + + // 验证请求参数 + if mergeRequest.TargetNodeID == "" { + return nil, errors.New("缺少目标节点ID") + } + + if len(mergeRequest.SourceNodeIDs) == 0 { + return nil, errors.New("缺少源节点ID列表") + } + + // 验证目标节点是否存在且属于当前项目 + var targetNode project.Demand + if mergeRequest.TargetNodeID != "-1" { // 如果不是根节点 + if err := cfg.DB().Where("id = ? AND project_id = ?", mergeRequest.TargetNodeID, projectID).First(&targetNode).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("目标节点不存在或不属于当前项目") + } + return nil, err + } + } + + // 开始事务 + tx := cfg.DB().Begin() + + // 1. 获取所有选中的节点及其关系 + sourceNodes := make(map[string]project.Demand) + for _, sourceID := range mergeRequest.SourceNodeIDs { + var origin models.Demand + if err := tx.Where("id = ?", sourceID).First(&origin).Error; err != nil { + tx.Rollback() + return nil, fmt.Errorf("源节点 %s 不存在: %v", sourceID, err) + } + sourceNodes[sourceID] = project.Demand{ + Tree: models.Tree{ + Name: origin.Name, + ParentID: origin.ParentID, + Level: origin.Level, + }, + ProjectID: projectID, // 使用当前项目ID + Description: origin.Description, + ReqID: origin.ReqID, + ParentReqID: origin.ParentReqID, + Priority: origin.Priority, + Type: origin.Type, + Status: origin.Status, + } + } + + // 2. 构建节点间的依赖关系图 + dependencies := make(map[string][]string) // 子节点 -> 父节点 + for id, node := range sourceNodes { + if node.ParentID != "-1" && node.ParentID != "" { + // 如果父节点也在选中列表中,记录依赖关系 + if _, exists := sourceNodes[node.ParentID]; exists { + dependencies[id] = append(dependencies[id], node.ParentID) + } + } + } + + // 3. 拓扑排序 + var sortedIDs []string + visited := make(map[string]bool) + temp := make(map[string]bool) + + var visit func(string) error + visit = func(id string) error { + if temp[id] { + return fmt.Errorf("检测到循环依赖") + } + if visited[id] { + return nil + } + temp[id] = true + for _, dep := range dependencies[id] { + if err := visit(dep); err != nil { + return err + } + } + temp[id] = false + visited[id] = true + sortedIDs = append(sortedIDs, id) + return nil + } + + // 对每个节点执行拓扑排序 + for id := range sourceNodes { + if !visited[id] { + if err := visit(id); err != nil { + tx.Rollback() + return nil, err + } + } + } + + // 4. 创建新节点映射表,用于跟踪旧ID到新ID的映射 + idMapping := make(map[string]string) + + // 5. 按照拓扑排序的顺序创建节点 + var processedNodes []project.Demand + for _, sourceID := range sortedIDs { + sourceNode := sourceNodes[sourceID] + + // 创建新节点 + newNode := project.Demand{ + Description: sourceNode.Description, + ReqID: sourceNode.ReqID, + ParentReqID: sourceNode.ParentReqID, + Priority: sourceNode.Priority, + Type: sourceNode.Type, + Status: sourceNode.Status, + ProjectID: projectID, + Tree: models.Tree{ + Name: sourceNode.Name, + }, + } + + // 设置父节点ID + if sourceNode.ParentID == "-1" || sourceNode.ParentID == "" { + // 如果原节点是根节点 + if mergeRequest.TargetNodeID == "-1" { + newNode.ParentID = "-1" + newNode.Level = 0 + } else { + newNode.ParentID = mergeRequest.TargetNodeID + newNode.Level = targetNode.Level + 1 + } + } else { + // 检查父节点是否在选中列表中 + _, parentExists := sourceNodes[sourceNode.ParentID] + if !parentExists { + // 如果父节点不在选中列表中 + if mergeRequest.TargetNodeID == "-1" { + newNode.ParentID = "-1" + newNode.Level = 0 + } else { + newNode.ParentID = mergeRequest.TargetNodeID + newNode.Level = targetNode.Level + 1 + } + } else { + // 如果原节点的父节点在选中列表中,使用新的父节点ID + newParentID, exists := idMapping[sourceNode.ParentID] + if !exists { + tx.Rollback() + return nil, fmt.Errorf("找不到节点 %s 的新父节点ID", sourceID) + } + newNode.ParentID = newParentID + + // 查找新父节点的级别 + var parentNode project.Demand + if err := tx.Where("id = ?", newParentID).First(&parentNode).Error; err != nil { + tx.Rollback() + return nil, fmt.Errorf("找不到新父节点: %v", err) + } + newNode.Level = parentNode.Level + 1 + } + } + + // 保存新节点 + if err := tx.Create(&newNode).Error; err != nil { + tx.Rollback() + return nil, fmt.Errorf("创建新节点失败: %v", err) + } + + // 记录ID映射 + idMapping[sourceID] = newNode.ID + processedNodes = append(processedNodes, newNode) + } + // 6. 处理未选中的子节点 + for _, sourceID := range sortedIDs { + // 获取新节点ID + newParentID := idMapping[sourceID] + + // 获取原节点的所有子节点 + var children []project.Demand + if err := tx.Where("parent_id = ?", sourceID).Find(&children).Error; err != nil { + tx.Rollback() + return nil, fmt.Errorf("获取子节点失败: %v", err) + } + + // 处理未被选中的子节点 + for _, child := range children { + if _, selected := sourceNodes[child.ID]; !selected { + // 递归复制未选中的子树 + if err := copySubtree(tx, child, newParentID, projectID); err != nil { + tx.Rollback() + return nil, err + } + } + } + } + + // 提交事务 + if err := tx.Commit().Error; err != nil { + return nil, fmt.Errorf("提交事务失败: %v", err) + } + + return map[string]interface{}{ + "message": fmt.Sprintf("成功合并 %d 个节点", len(mergeRequest.SourceNodeIDs)), + "nodes": processedNodes, + }, nil +} + +// 复制子树 +func copySubtree(tx *gorm.DB, node project.Demand, newParentID, projectID string) error { + // 创建新节点 + newNode := project.Demand{ + Description: node.Description, + ReqID: node.ReqID, + ParentReqID: node.ParentReqID, + Priority: node.Priority, + Type: node.Type, + Status: node.Status, + ProjectID: projectID, + Tree: models.Tree{ + ParentID: newParentID, + Name: node.Name, + }, + } + + // 查找新父节点的级别 + var parentNode project.Demand + if err := tx.Where("id = ?", newParentID).First(&parentNode).Error; err != nil { + return fmt.Errorf("找不到新父节点: %v", err) + } + newNode.Level = parentNode.Level + 1 + + // 保存新节点 + if err := tx.Create(&newNode).Error; err != nil { + return fmt.Errorf("创建节点失败: %v", err) + } + + // 获取原节点的所有子节点 + var children []project.Demand + if err := tx.Where("parent_id = ?", node.ID).Find(&children).Error; err != nil { + return fmt.Errorf("获取子节点失败: %v", err) + } + + // 递归处理子节点 + for _, child := range children { + if err := copySubtree(tx, child, newNode.ID, projectID); err != nil { + return err + } + } + + return nil +} + +var _ = Router.Any("/*", anyHandle) + +func anyHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + return nil, nil + +} diff --git a/api/project/demand/init.go b/api/project/demand/init.go new file mode 100644 index 0000000..4da98b8 --- /dev/null +++ b/api/project/demand/init.go @@ -0,0 +1,11 @@ +package demand + +import ( + "github.com/veypi/OneBD/rest" +) + +var Router = rest.NewRouter() + +func init() { + // 初始化路由 +} \ No newline at end of file diff --git a/api/project/handler.go b/api/project/handler.go new file mode 100644 index 0000000..d102890 --- /dev/null +++ b/api/project/handler.go @@ -0,0 +1,152 @@ +package project + +import ( + "app/cfg" + "app/models" + "app/models/project" + "app/utils" + "encoding/json" + "errors" + "fmt" + "io" + + "github.com/google/uuid" + "github.com/veypi/OneBD/rest" + "gorm.io/gorm" +) + +var _ = Router.Get("/:id", getHandle) +var _ = Router.Get("", getHandle) + +// 获取项目 +func getHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + id, ok := x.Params.Get("id") + + if !ok { + // 获取所有项目 + var projects []models.Project + if err := cfg.DB().Find(&projects).Error; err != nil { + return nil, err + } + return projects, nil + } + + // 获取特定项目 + var project models.Project + if err := cfg.DB().Where("id = ?", id).First(&project).Error; err != nil { + return nil, err + } + + return project, nil +} + +var _ = Router.Post("", postHandle) + +// 创建项目 +func postHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + projectOBJ := &models.Project{} + if err := json.NewDecoder(x.Request.Body).Decode(&projectOBJ); err != nil { + return nil, err + } + projectOBJ.ID = uuid.New().String()[0:32] + + if err := cfg.DB().Create(&projectOBJ).Error; err != nil { + return nil, err + } + // 创建根需求节点(确保使用project.ID) + demandOBJ := &project.Demand{ + Tree: models.Tree{ + Name: "root", + ParentID: "", + Level: 0, + }, + DocID: "", + ProjectID: projectOBJ.ID, + Description: "项目根需求节点", + } + if err := cfg.DB().Create(demandOBJ).Error; err != nil { + return nil, err + } + return projectOBJ, nil +} + +var _ = Router.Patch("/:id", patchHandle) + +// 更新项目 +func patchHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + // 从URL获取ID + body, err := io.ReadAll(x.Request.Body) + if err != nil { + return nil, fmt.Errorf("读取请求体失败: %v", err) + } + + id, ok := x.Params.Get("id") + if !ok { + return nil, errors.New("缺少id") + } + var updateMap map[string]interface{} + if err := json.Unmarshal(body, &updateMap); err != nil { + return nil, fmt.Errorf("解析JSON失败: %v", err) + } + + // 先检查项目是否存在 + var project models.Project + if err := cfg.DB().Where("id = ?", id).First(&project).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("项目不存在") + } + return nil, err + } + + // 删除不允许更新的字段 + delete(updateMap, "id") + + // 只更新提供的字段 + if len(updateMap) > 0 { + if err := cfg.DB().Model(&project).Updates(updateMap).Error; err != nil { + return nil, err + } + } + + // 重新查询更新后的完整数据 + if err := cfg.DB().Where("id = ?", id).First(&project).Error; err != nil { + return nil, err + } + + return project, nil +} + +var _ = Router.Delete("/:id", deleteHandle) + +// 删除项目 +func deleteHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + id, ok := x.Params.Get("id") + if !ok { + return nil, errors.New("缺少id") + } + + var project models.Project + if err := cfg.DB().Where("id = ?", id).First(&project).Error; err != nil { + return nil, err + } + + // 删除项目 + if err := cfg.DB().Delete(&project).Error; err != nil { + return nil, err + } + + return map[string]interface{}{ + "msg": "删除成功", + }, nil +} + +var _ = Router.Any("/*", anyHandle) + +func anyHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + return nil, nil +} diff --git a/api/project/init.go b/api/project/init.go new file mode 100644 index 0000000..e00b3d0 --- /dev/null +++ b/api/project/init.go @@ -0,0 +1,7 @@ +package project + +import ( + "github.com/veypi/OneBD/rest" +) + +var Router = rest.NewRouter() diff --git a/api/project/stage/graph/handler.go b/api/project/stage/graph/handler.go new file mode 100644 index 0000000..01b62e5 --- /dev/null +++ b/api/project/stage/graph/handler.go @@ -0,0 +1,129 @@ +package graph + +import ( + "app/cfg" + "app/models/project" + "app/models/project/stage" + "app/utils" + "errors" + "fmt" + + "github.com/veypi/OneBD/rest" +) + +var _ = Router.Get("", getHandle) +var _ = Router.Get("/:stage_id/", getHandle) + +// getHandle 获取完整的图形数据,包括节点和连接 +func getHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + stageID, ok := x.Params.Get("stage_id") + if !ok { + return nil, errors.New("缺少stage_id") + } + + // 验证阶段是否存在 + var stageOBJ project.Stage + if err := cfg.DB().Where("id = ?", stageID).First(&stageOBJ).Error; err != nil { + return nil, errors.New("阶段不存在") + } + + // 获取该阶段的所有节点 + var nodes []stage.GraphNode + if err := cfg.DB().Where("stage_id = ?", stageID).Find(&nodes).Error; err != nil { + return nil, err + } + // 获取该阶段的所有连接 + var links []stage.GraphLink + if err := cfg.DB().Where("stage_id = ?", stageID).Find(&links).Error; err != nil { + return nil, err + } + + // 返回完整的图形数据 + return map[string]interface{}{ + "stage": stageOBJ, + "nodes": nodes, + "links": links, + }, nil +} + +var _ = Router.Post("/:stage_id/", saveHandle) + +func saveHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + fmt.Println("saveHandle") + stageID, ok := x.Params.Get("stage_id") + if !ok { + return nil, errors.New("缺少stage_id") + } + + // 验证阶段是否存在 + var stageOBJ project.Stage + if err := cfg.DB().Where("id = ?", stageID).First(&stageOBJ).Error; err != nil { + return nil, errors.New("阶段不存在") + } + + // 解析请求体 + var data struct { + Nodes []stage.GraphNode `json:"nodes"` + Links []stage.GraphLink `json:"links"` + } + if err := x.Parse(&data); err != nil { + return nil, err + } + // fmt.Printf("%+v\n", data) + // 开始事务 + tx := cfg.DB().Begin() + + // 删除原有的节点和连接 + if err := tx.Where("stage_id = ?", stageID).Delete(&stage.GraphNode{}).Error; err != nil { + tx.Rollback() + return nil, err + } + if err := tx.Where("stage_id = ?", stageID).Delete(&stage.GraphLink{}).Error; err != nil { + tx.Rollback() + return nil, err + } + + // 保存新的节点和连接 + for _, node := range data.Nodes { + node.StageID = stageID + if err := tx.Create(&node).Error; err != nil { + tx.Rollback() + return nil, err + } + } + + for _, link := range data.Links { + link.StageID = stageID + if err := tx.Create(&link).Error; err != nil { + tx.Rollback() + return nil, err + } + } + + // 提交事务 + if err := tx.Commit().Error; err != nil { + return nil, err + } + + return map[string]interface{}{ + "message": "保存成功", + }, nil +} + +var _ = Router.Post("/:stage_id/generate", generateHandle) + +func generateHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + + return nil, nil +} + +var _ = Router.Any("/*", anyHandle) + +// anyHandle 处理其他请求 +func anyHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + return nil, nil +} diff --git a/api/project/stage/graph/init.go b/api/project/stage/graph/init.go new file mode 100644 index 0000000..0e57ce7 --- /dev/null +++ b/api/project/stage/graph/init.go @@ -0,0 +1,7 @@ +package graph + +import ( + "github.com/veypi/OneBD/rest" +) + +var Router = rest.NewRouter() \ No newline at end of file diff --git a/api/project/stage/graphGenerater.go b/api/project/stage/graphGenerater.go new file mode 100644 index 0000000..8e139be --- /dev/null +++ b/api/project/stage/graphGenerater.go @@ -0,0 +1,742 @@ +// 文件: app/stage/graph_processor.go +package stage // 确认包名是 stage + +import ( + M "app/models" // 确认 M 是 models 包 + "context" + "encoding/json" + "errors" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "sync" + "time" + + // --- 项目内引用 --- + "app/cfg" // 对应你的 cfg + "app/models/project" // 对应你的 project 模型 + "app/models/project/stage" // GraphNode/Link 定义在此 + + // --- 第三方库 --- + + "github.com/google/uuid" + "github.com/openai/openai-go" // 主 SDK 包 + "github.com/openai/openai-go/option" // !! 确保这个导入存在 !! + "github.com/xeipuuv/gojsonschema" + "gorm.io/gorm" +) + +// --- 配置常量 --- +const ( + llmAPIKeyEnvVar = "sk-0213c70194624703a1d0d80e0f762b0e" + llmBaseURLEnvVar = "https://dashscope.aliyuncs.com/compatible-mode/v1/" + llmModelName = "qwen-plus" // 保持与你的 doc 包一致 + toolNodesFilePath = "cfg/tool_node_definations.json" + llmRequestTimeout = 120 * time.Second + defaultQueueSize = 100 + workerCount = 2 +) + +// --- 任务定义 --- +type graphGenerationTask struct { + StageID string +} + +// --- LLM 输出解析结构体 --- + +// --- 处理器定义 --- +type GraphProcessor struct { + taskQueue chan graphGenerationTask + llmClient *openai.Client + db *gorm.DB + logger *log.Logger // 主日志 logger + llmIOLogger *log.Logger + toolNodeDefinitionsJSON string + initialized bool + initMutex sync.Mutex +} + +var ( + processorInstance *GraphProcessor + processorOnce sync.Once +) + +func setupLogger() (*log.Logger, error) { // 返回 error 以便处理创建失败的情况 + logDir := filepath.Join("logs", "graph") // 在 logs 目录下创建 graph 子目录 + if err := os.MkdirAll(logDir, 0755); err != nil { + log.Printf("错误:无法创建日志目录 %s: %v\n", logDir, err) + return nil, fmt.Errorf("无法创建日志目录 %s: %w", logDir, err) + } + + // 注意:日志文件名应包含 'graph' 以区分 + timestamp := time.Now().Format("20060102_150405") + logFilename := filepath.Join(logDir, fmt.Sprintf("graph_llm_io_%s.log", timestamp)) + + file, err := os.OpenFile(logFilename, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + if err != nil { + log.Printf("错误:无法打开日志文件 %s: %v\n", logFilename, err) + return nil, fmt.Errorf("无法打开日志文件 %s: %w", logFilename, err) + } + + // 返回一个新的 logger 实例,写入到特定文件 + // 使用不同的前缀区分日志条目类型 + return log.New(file, "[LLM_IO] ", log.LstdFlags), nil +} + +// InitGraphProcessor 初始化 GraphProcessor 单例及其依赖 (导出) +func InitGraphProcessor() error { + var initErr error + processorOnce.Do(func() { + instance := &GraphProcessor{ + logger: log.New(log.Writer(), "[StageGraphProcessor] ", log.LstdFlags), + } + instance.initMutex.Lock() + defer instance.initMutex.Unlock() + if instance.initialized { + processorInstance = instance + return + } + // 1. 加载工具定义 + toolDefs, err := instance.loadToolDefinitions(toolNodesFilePath) + if err != nil { + initErr = fmt.Errorf("无法加载工具定义: %w", err) + return + } + instance.toolNodeDefinitionsJSON = toolDefs + // 2. 初始化 LLM Client (严格按照你的 doc 包示例) + client, err := instance.initLLMClient() // 使用修正后的初始化 + if err != nil { + initErr = fmt.Errorf("无法初始化 LLM 客户端: %w", err) + return + } + instance.llmClient = client + + llmLogger, err := setupLogger() + if err != nil { + // 如果 LLM 日志创建失败,可以选择继续运行但发出警告,或直接失败 + instance.logger.Printf("警告:无法创建 LLM I/O 日志文件,将不记录 LLM 交互细节: %v", err) + // initErr = fmt.Errorf("无法创建 LLM I/O 日志: %w", err) + // return + } + instance.llmIOLogger = llmLogger + + // 3. 获取数据库连接 + dbConn := cfg.DB() + if dbConn == nil { + initErr = errors.New("数据库连接不可用") + return + } + instance.db = dbConn + // 4. 创建任务队列 + instance.taskQueue = make(chan graphGenerationTask, defaultQueueSize) + // 5. 启动 Workers + for i := 0; i < workerCount; i++ { + go instance.worker(i + 1) + } + instance.initialized = true + instance.logger.Println("Graph processor 初始化成功,启动", workerCount, "个 worker。") + processorInstance = instance + }) + return initErr +} + +// GetGraphProcessor 获取 GraphProcessor 单例 (导出) +func GetGraphProcessor() (*GraphProcessor, error) { + if processorInstance == nil || !processorInstance.initialized { + return nil, errors.New("GraphProcessor 未初始化") + } + return processorInstance, nil +} + +// loadToolDefinitions 加载工具定义 (未导出) +func (p *GraphProcessor) loadToolDefinitions(filePath string) (string, error) { + content, err := os.ReadFile(filePath) + if err != nil { + return "", fmt.Errorf("读取工具定义文件 %s 失败: %w", filePath, err) + } + var js json.RawMessage + if err := json.Unmarshal(content, &js); err != nil { + return "", fmt.Errorf("工具定义文件 %s 不是有效的 JSON: %w", filePath, err) + } + p.logger.Printf("成功从 %s 加载工具定义。", filePath) + return string(content), nil +} + +// initLLMClient 初始化 OpenAI 客户端 (参考你的 doc 包) (未导出) +func (p *GraphProcessor) initLLMClient() (*openai.Client, error) { + apiKey := llmAPIKeyEnvVar + baseURL := llmBaseURLEnvVar + + // 必须提供 Key 和 URL (根据你的 doc 包 NewDemandAnalyzer 逻辑) + if apiKey == "" { + p.logger.Printf("错误: 环境变量 %s 未设置。", llmAPIKeyEnvVar) + return nil, fmt.Errorf("环境变量 %s 未设置", llmAPIKeyEnvVar) + } + if baseURL == "" { + p.logger.Printf("错误: 环境变量 %s 未设置。", llmBaseURLEnvVar) + return nil, fmt.Errorf("环境变量 %s 未设置", llmBaseURLEnvVar) + } + + // 直接使用 option.WithXXX 作为参数 + client := openai.NewClient( + option.WithAPIKey(apiKey), + option.WithBaseURL(baseURL), + // 你可以在这里添加其他固定的 option, 如 option.WithTimeout(60*time.Second) + ) + p.logger.Printf("LLM 客户端已初始化 (Base URL: %s)", baseURL) // 不打印 Key + return client, nil +} + +// AddTask 添加任务到队列 (导出) +func (p *GraphProcessor) AddTask(task graphGenerationTask) error { + if !p.initialized { + return errors.New("GraphProcessor 未初始化") + } + select { + case p.taskQueue <- task: + p.logger.Printf("任务已添加到队列 (StageID: %s)", task.StageID) + return nil + case <-time.After(2 * time.Second): + p.logger.Printf("错误: 添加任务超时 (StageID %s): 队列已满或无响应", task.StageID) + return errors.New("添加任务超时,队列可能已满") + } +} + +// worker Go 协程,处理任务 (未导出) +func (p *GraphProcessor) worker(id int) { + p.logger.Printf("Worker %d 启动,等待任务...", id) + for task := range p.taskQueue { + p.logger.Printf("Worker %d: 开始处理任务 (StageID: %s)", id, task.StageID) + ctx, cancel := context.WithTimeout(context.Background(), llmRequestTimeout+60*time.Second) + err := p.processTask(ctx, task) + if err != nil { + p.logger.Printf("Worker %d: 处理任务失败 (StageID %s): %v", id, task.StageID, err) + p.updateStageStatus(task.StageID, "failed", err.Error()) + } else { + p.logger.Printf("Worker %d: 成功处理任务 (StageID: %s)", id, task.StageID) + p.updateStageStatus(task.StageID, "completed", "") + } + cancel() + } + p.logger.Printf("Worker %d 停止。", id) +} + +// updateStageStatus 更新 Stage 状态 (未导出) +func (p *GraphProcessor) updateStageStatus(stageID, status, errMsg string) { + if len(errMsg) > 1000 { + errMsg = errMsg[:1000] + "..." + } + updates := map[string]interface{}{"graph_status": status, "graph_error": errMsg} + db := cfg.DB() + if db == nil { + p.logger.Println("!!! 无法更新阶段状态:数据库连接不可用。") + return + } + if err := db.Model(&project.Stage{}).Where("id = ?", stageID).Updates(updates).Error; err != nil { + p.logger.Printf("!!! 更新阶段状态失败 (StageID %s -> %s): %v", stageID, status, err) + } else { + p.logger.Printf("阶段状态已更新 (StageID %s -> %s)", stageID, status) + } +} + +// processTask 核心处理逻辑 (未导出, 使用流式 + 中文 Prompt + 不含根节点) +func (p *GraphProcessor) processTask(ctx context.Context, task graphGenerationTask) (errResult error) { + stageID := task.StageID + p.logger.Printf("开始 processTask (StageID: %s)", stageID) + defer func() { + if r := recover(); r != nil { + p.logger.Printf("!!! 处理任务时发生 Panic (StageID: %s): %v", stageID, r) + errResult = fmt.Errorf("处理任务时发生内部错误: %v", r) + // 确保状态被更新为 failed + p.updateStageStatus(stageID, "failed", fmt.Sprintf("Panic: %v", r)) + } + }() + + // 0. 更新状态为 processing + p.updateStageStatus(stageID, "processing", "") + + // --- 数据库操作 --- + dbCtx, dbCancel := context.WithTimeout(ctx, 30*time.Second) + defer dbCancel() + db := cfg.DB().WithContext(dbCtx) // 使用 cfg 获取 DB 连接 + + // 1. 获取 Stage, RootDemand, Descendants + var stageObj project.Stage + if err := db.Where("id = ?", stageID).First(&stageObj).Error; err != nil { + return fmt.Errorf("获取阶段失败 (StageID: %s): %w", stageID, err) + } + if stageObj.DemandID == "" { + return fmt.Errorf("阶段 '%s' 未关联根需求 ID", stageID) + } + rootDemandID := stageObj.DemandID + var rootDemand project.Demand + if err := db.Where("id = ?", rootDemandID).First(&rootDemand).Error; err != nil { + return fmt.Errorf("获取根需求节点失败 (DemandID: %s): %w", rootDemandID, err) + } + descendants, err := rootDemand.Descendants(db) + if err != nil { + return fmt.Errorf("获取子孙需求节点失败 (Root DemandID: %s): %w", rootDemandID, err) + } + p.logger.Printf("Stage %s: 获取到 %d 个子孙节点", stageID, len(descendants)) + + // 4. 准备 Prompt + rootDemandJSON, _ := json.MarshalIndent(rootDemand, "", " ") + descendantInfo := make([]map[string]string, 0, len(descendants)) + for _, d := range descendants { + descendantInfo = append(descendantInfo, map[string]string{ + "id": d.ID, + "req_id": d.ReqID, + "name": d.Name, + "description": d.Description, + "parent_req_id": d.ParentReqID, + "parent_id": d.ParentID, + }) + } + descendantsJSON, _ := json.MarshalIndent(descendantInfo, "", " ") + graphSchema, err := p.generateGraphJSONSchema() + if err != nil { + return fmt.Errorf("内部错误:生成 Schema 失败: %w", err) + } + prompt := fmt.Sprintf(` +你是一位专业的软件测试流程设计专家。 + +**任务目标:** 请根据下面提供的需求信息和可用的工具节点,生成一个 JSON 格式的软件测试流程图。这个流程图需要清晰地展示针对“待验证节点”的测试步骤的以及逻辑顺序。 + +**待验证节点:** + +1. **待验证节点 (这是需要验证测试的节点,请勿包含在输出的图中):** + %s + +2. **子需求节点列表 (这些是需要包含在图中的需求节点):** + %s + +3. **可用的工具节点类型 (用于构建流程,以及通过这些工具进行多种测试,类型定义如下):** + %s + +**指令:** + +1. 创建一个 JSON 对象,包含 "nodes" 和 "links" 两个键。 +2. **关于 "nodes" (节点列表):** + * 将“子需求节点列表”中的每个需求表示为 type: "demand" 的节点。在这些节点的 original_demand_id 字段中填入该需求对应的数据库主键 ID (即上述列表中的 "id" 字段值)。 + * “子需求节点列表”中的每个需求节点代表一个测试流图,这个测试节点内部的测试逻辑就不在当前的图中体现了,这个图中的测试工具是针对当前的待验证节点或者子需求节点的组合的。 + * **请不要在最终输出的 "nodes" 列表中包含上面第 1 点中提到的“待验证节点”本身。** + * 从“可用的工具节点类型”中选择合适的节点(如 "start", "end", "httpRequest", "condition" 等)来构建测试流程的逻辑结构(例如起点、终点、判断、并行、测试操作等)。 + * “可用的工具节点类型”中的各种测试工具节能添加的最好加上,让测试完备一些 + * 为生成的**每一个**节点(包括需求节点和工具节点)分配一个唯一的字符串 temp_id。 + * 为每个节点提供一个有意义的 name。 + * 设置每个节点正确的 type。 + * 根据工具节点的定义,在节点的 meta 字段中包含必要的配置信息(必须是 JSON 字符串格式,例如 httpRequest 节点需要包含方法、路径等)。 + * 为节点提供建议的布局坐标 x 和 y (数字类型)。通常 "start" 节点可以放在 (350, 150) 附近,其他节点按逻辑排布。 +3. **关于 "links" (连接列表):** + * 使用 links 在逻辑上连接你创建的节点。 + * 每个 link 必须包含 source_temp_id 和 target_temp_id,引用你创建的节点的 temp_id。 + * 可以为 link 添加 meta 字段(JSON 字符串格式)来提供额外信息,例如条件分支的标签。 + * 适合并行的测试可以考虑并行,不一定完全串行。 +4. **输出格式:** 最终的输出**必须**是一个**完整且有效**的 JSON 对象,严格符合下方指定的 JSON Schema。不要在 JSON 对象前后包含任何额外的文字或解释。 + +**要求的 JSON 输出 Schema:** +%s + +请根据以上信息,生成测试流程图的 JSON: +`, string(rootDemandJSON), string(descendantsJSON), p.toolNodeDefinitionsJSON, graphSchema) + p.logger.Printf("Stage %s: Prompt 已准备。", stageID) + if p.llmIOLogger != nil { + p.llmIOLogger.Printf("StageID: %s\n--- LLM 输入 (Prompt) ---\n%s\n--- Prompt 结束 ---", stageID, prompt) + } else { + p.logger.Printf("Stage %s: LLM I/O logger 未初始化,无法记录 Prompt。", stageID) // fallback 到主 logger + } + + // 5. 调用 LLM (严格按照 doc 包的流式风格) + llmCtx, llmCancel := context.WithTimeout(ctx, llmRequestTimeout) + defer llmCancel() + p.logger.Printf("Stage %s: 发送流式请求到 LLM (模型: %s)...", stageID, llmModelName) + + // --- 使用 NewStreaming,无 ResponseFormat,单返回值 --- + stream := p.llmClient.Chat.Completions.NewStreaming(llmCtx, openai.ChatCompletionNewParams{ + Model: openai.F(llmModelName), + Messages: openai.F([]openai.ChatCompletionMessageParamUnion{ + openai.SystemMessage("你是一位专业的软件测试流程设计专家,负责生成 JSON 格式的测试流程图。"), // 可以考虑 system prompt 是否需要 + openai.UserMessage(prompt), + }), + // !! 移除 ResponseFormat !! + }) + // --- 修正结束 --- + + // --- 处理流式响应 (与你的 doc 包一致) --- + var responseBuilder strings.Builder + p.logger.Printf("Stage %s: Reading LLM stream...", stageID) + streamErr := (error)(nil) // 用于接收 stream.Err() + for stream.Next() { + chunk := stream.Current() + // 不再检查 FinishReason,直接拼接 + if len(chunk.Choices) > 0 { + responseBuilder.WriteString(chunk.Choices[0].Delta.Content) + } + } + // 在循环结束后检查 stream.Err() + streamErr = stream.Err() + stream.Close() // 确保关闭 + + if streamErr != nil { + // 检查 context 错误 + if errors.Is(streamErr, context.Canceled) { + return fmt.Errorf("LLM stream canceled: %w", streamErr) + } + if errors.Is(streamErr, context.DeadlineExceeded) { + return fmt.Errorf("LLM stream timed out: %w", streamErr) + } + p.logger.Printf("Stage %s: LLM stream error: %v", stageID, streamErr) + return fmt.Errorf("LLM stream error: %w", streamErr) + } + llmOutputRaw := responseBuilder.String() + if llmOutputRaw == "" { + return errors.New("LLM stream returned empty content") + } + p.logger.Printf("Stage %s: LLM stream finished successfully.", stageID) + // --- 流式处理结束 --- + if p.llmIOLogger != nil { + p.llmIOLogger.Printf("StageID: %s\n--- LLM 输出 (Raw) ---\n%s\n--- 输出结束 ---", stageID, llmOutputRaw) + } else { + p.logger.Printf("Stage %s: LLM I/O logger 未初始化,无法记录原始输出。", stageID) // fallback 到主 logger + } + // 6. 清理和验证 LLM 输出 + llmOutputJSON, err := p.cleanLLMResponse(llmOutputRaw) // 使用包内方法 + if err != nil { + p.logger.Printf("Stage %s: 首次清理 LLM 响应失败: %v。将尝试调用 LLM 进行修复...", stageID, err) + + // --- 调用修复逻辑 --- + // 传递原始的 context (或者可以创建一个带新超时的子 context) + repairedJSON, repairErr := p.repairGraphJSON(ctx, llmOutputRaw, err) + if repairErr != nil { + // 修复失败,记录两个错误,然后返回失败 + p.logger.Printf("Stage %s: JSON 修复尝试失败: %v。原始清理错误: %v。原始响应: %s", stageID, repairErr, err, llmOutputRaw) + // 将原始错误和修复错误合并返回可能更有信息量 + return fmt.Errorf("清理 LLM 响应失败 (%v) 且后续修复尝试也失败 (%v)", err, repairErr) + } + // 修复成功 + p.logger.Printf("Stage %s: LLM 自动修复 JSON 成功。", stageID) + llmOutputJSON = repairedJSON // 使用修复后的 JSON 继续后续流程 + // --- 修复逻辑结束 --- + } + // 【注意】验证 Schema 的函数需要适配图的 Schema,而不是需求的 Schema + // 假设 validateGraphJSON 是适配过的 + if err := p.validateGraphJSON([]byte(llmOutputJSON)); err != nil { + // 【可选】尝试修复 + p.logger.Printf("Stage %s: LLM 响应验证失败: %v. JSON: %s", stageID, err, llmOutputJSON) + return fmt.Errorf("LLM 响应验证失败: %w", err) + } + p.logger.Printf("Stage %s: LLM 响应已清理并验证。", stageID) + + // 7. 解析 JSON + var llmGraph llmGraphOutput + if err := json.Unmarshal([]byte(llmOutputJSON), &llmGraph); err != nil { + return fmt.Errorf("解析 LLM JSON 失败: %w", err) + } + p.logger.Printf("Stage %s: 解析到 %d 个节点和 %d 个连接。", stageID, len(llmGraph.Nodes), len(llmGraph.Links)) + + // 8. 处理和转换数据 (数据库模型) + // ... (省略,与上一轮回复中 processTask 内的代码相同:创建 finalNodes, finalLinks, 检查根节点, 映射 ID) ... + finalNodes := make([]stage.GraphNode, 0, len(llmGraph.Nodes)) + finalLinks := make([]stage.GraphLink, 0, len(llmGraph.Links)) + tempIDToInstanceID := make(map[string]string) + for _, llmNode := range llmGraph.Nodes { + // 【检查】确保 LLM 没有错误地包含根节点 + if llmNode.Type == "demand" && llmNode.OriginalDemandID == rootDemandID { + p.logger.Printf("Stage %s: Warning - LLM included root node (DemandID: %s) despite instructions. Skipping it.", stageID, rootDemandID) + continue + } + + instanceID := uuid.New().String()[0:32] + if _, exists := tempIDToInstanceID[llmNode.TempID]; exists { + p.logger.Printf("Stage %s: Warning - duplicate temp_id '%s' from LLM, skipping node '%s'.", stageID, llmNode.TempID, llmNode.Name) + continue + } + tempIDToInstanceID[llmNode.TempID] = instanceID + + dbNode := stage.GraphNode{ + BaseModel: M.BaseModel{ID: uuid.New().String()[0:32]}, + StageID: stageID, + InstanceID: instanceID, + Name: llmNode.Name, + Type: llmNode.Type, + Meta: llmNode.Meta, + X: llmNode.X, + Y: llmNode.Y, + FX: llmNode.X, FY: llmNode.Y, Level: 0, ParentID: "", + } + // 设置 DemandID + if llmNode.Type == "demand" { + if llmNode.OriginalDemandID != "" { + dbNode.DemandID = llmNode.OriginalDemandID + } else { + p.logger.Printf("Stage %s: Warning - LLM demand node '%s' (temp_id: %s) missing original_demand_id.", stageID, llmNode.Name, llmNode.TempID) + dbNode.DemandID = "" // 明确设为空 + } + } else { + // 工具节点关联到 Stage 的根需求 ID + dbNode.DemandID = rootDemandID + } + finalNodes = append(finalNodes, dbNode) + } + // 处理 Links + for _, llmLink := range llmGraph.Links { + sourceInstanceID, sourceOk := tempIDToInstanceID[llmLink.SourceTempID] + targetInstanceID, targetOk := tempIDToInstanceID[llmLink.TargetTempID] + if !sourceOk || !targetOk || sourceInstanceID == targetInstanceID { + p.logger.Printf("Stage %s: Warning - Skipping invalid link from LLM: %s -> %s", stageID, llmLink.SourceTempID, llmLink.TargetTempID) + continue + } + dbLink := stage.GraphLink{ + BaseModel: M.BaseModel{ID: uuid.New().String()[0:32]}, + StageID: stageID, + SourceID: sourceInstanceID, + TargetID: targetInstanceID, + LinkID: uuid.New().String()[0:32], + Meta: llmLink.Meta, + } + finalLinks = append(finalLinks, dbLink) + } + p.logger.Printf("Stage %s: 处理完成 %d 个节点和 %d 个连接。", stageID, len(finalNodes), len(finalLinks)) + + // 9. 保存到数据库 (事务) + + tx := p.db.Begin() + if tx.Error != nil { + return fmt.Errorf("启动数据库事务失败: %w", tx.Error) + } + p.logger.Printf("Stage %s: 事务已启动。", stageID) + err = func(tx *gorm.DB) error { // 事务内操作 + if err := tx.Where("stage_id = ?", stageID).Delete(&stage.GraphNode{}).Error; err != nil { + return fmt.Errorf("删除旧节点失败: %w", err) + } + if err := tx.Where("stage_id = ?", stageID).Delete(&stage.GraphLink{}).Error; err != nil { + return fmt.Errorf("删除旧连接失败: %w", err) + } + if len(finalNodes) > 0 { + if err := tx.Create(&finalNodes).Error; err != nil { + return fmt.Errorf("创建新节点失败: %w", err) + } + } + if len(finalLinks) > 0 { + if err := tx.Create(&finalLinks).Error; err != nil { + return fmt.Errorf("创建新连接失败: %w", err) + } + } + p.logger.Printf("Stage %s: 事务内数据操作完成。", stageID) + return nil + }(tx) + if err != nil { + tx.Rollback() + p.logger.Printf("Stage %s: 事务已回滚: %v", stageID, err) + return err + } + if err := tx.Commit().Error; err != nil { + p.logger.Printf("Stage %s: 事务提交失败: %v", stageID, err) + return fmt.Errorf("数据库事务提交失败: %w", err) + } + p.logger.Printf("Stage %s: 事务已成功提交。", stageID) + + return nil // 任务成功 +} +func (p *GraphProcessor) repairGraphJSON(ctx context.Context, malformedJSON string, originalError error) (string, error) { + p.logger.Printf("Stage (unknown): Attempting to repair JSON with LLM. Original Error: %v", originalError) + + // --- 修正 Prompt 定义 --- + // 使用 Go 的原始字符串字面量 `` + // 将内部的 (如 ```json) 修改为 (例如 "```json") 或其他不含反引号的描述 + repairPrompt := fmt.Sprintf(`你是一个 JSON 修复专家。我收到了一个来自 LLM 的响应,但它似乎不是一个有效的 JSON 字符串,导致了解析或清理错误。 + +原始的、格式可能错误的响应文本: +--- +%s +--- + +遇到的错误信息: +%v + +请仔细检查上面的原始响应文本,并尽力将其修复成一个结构良好、语法正确的 JSON 对象。 +修复要求: +1. 确保所有键名和字符串值都使用双引号。 +2. 确保括号、逗号、冒号等都使用正确。 +3. 尽量保留原始数据和结构,只修正格式错误。 +4. 最终结果必须是一个单独的、完整的 JSON 对象。 + +请只返回修复后的 JSON 字符串,不要包含其他无关文本。`, malformedJSON, originalError) // <-- 修改了这里 + + // --- 修正结束 --- + + repairCtx, cancel := context.WithTimeout(ctx, 60*time.Second) // 为修复设置一个独立的超时 + defer cancel() + + // 使用流式调用进行修复 (与你的 doc 包类似) + stream := p.llmClient.Chat.Completions.NewStreaming(repairCtx, openai.ChatCompletionNewParams{ + Model: openai.F(llmModelName), // 可以考虑用更强大的模型进行修复? + Messages: openai.F([]openai.ChatCompletionMessageParamUnion{ + openai.UserMessage(repairPrompt), + }), + // Temperature 设低一点可能有助于精确修复? (例如 0.3) + // Temperature: openai.F(float32(0.3)), + }) + + var responseBuilder strings.Builder + streamErr := (error)(nil) + for stream.Next() { + chunk := stream.Current() + if len(chunk.Choices) > 0 { + responseBuilder.WriteString(chunk.Choices[0].Delta.Content) + } + } + streamErr = stream.Err() + stream.Close() + + if streamErr != nil { + p.logger.Printf("Stage (unknown): JSON 修复 LLM 调用失败: %v", streamErr) + return "", fmt.Errorf("调用 LLM 修复 JSON 失败: %w", streamErr) + } + + fixedJSON := responseBuilder.String() + if fixedJSON == "" { + p.logger.Println("Stage (unknown): LLM 返回了空的修复结果。") + return "", errors.New("LLM 返回了空的修复结果") + } + + p.logger.Println("Stage (unknown): LLM 返回了修复后的 JSON,将进行基本验证。") + + // 对修复后的结果进行最基本的清理和验证 + // 这里可以复用部分的 cleanLLMResponse 逻辑,或者简化验证 + fixedJSON = strings.TrimSpace(fixedJSON) + // 移除可能的 ```json ``` + if strings.HasPrefix(fixedJSON, "```json") { + fixedJSON = strings.TrimPrefix(fixedJSON, "```json") + } + if strings.HasPrefix(fixedJSON, "```") { + fixedJSON = strings.TrimPrefix(fixedJSON, "```") + } + if strings.HasSuffix(fixedJSON, "```") { + fixedJSON = strings.TrimSuffix(fixedJSON, "```") + } + fixedJSON = strings.TrimSpace(fixedJSON) + + if !strings.HasPrefix(fixedJSON, "{") || !strings.HasSuffix(fixedJSON, "}") { + p.logger.Printf("Stage (unknown): 修复后的 JSON 仍然缺少对象边界: %s", fixedJSON) + return "", fmt.Errorf("修复后的 JSON 仍然缺少对象边界 '{...}'") + } + + var checker interface{} + if err := json.Unmarshal([]byte(fixedJSON), &checker); err != nil { + p.logger.Printf("Stage (unknown): 修复后的 JSON 仍然无效: %v. Repaired: %s", err, fixedJSON) + return "", fmt.Errorf("修复后的 JSON 仍然无法解析: %w", err) + } + + p.logger.Println("Stage (unknown): 修复后的 JSON 通过了基本验证。") + + return fixedJSON, nil // 返回修复结果或错误 +} + +// --- 包内辅助函数 --- +func (p *GraphProcessor) generateGraphJSONSchema() (string, error) { + // ... (省略 Schema 字符串,同上) ... + schemaStr := `{ + "type": "object", + "properties": { + "nodes": { + "type": "array", + "description": "List of graph nodes", + "items": { "$ref": "#/definitions/graphNode" } + }, + "links": { + "type": "array", + "description": "List of graph links connecting nodes", + "items": { "$ref": "#/definitions/graphLink" } + } + }, + "required": ["nodes", "links"], + "definitions": { + "graphNode": { + "type": "object", + "properties": { + "temp_id": { "type": "string", "description": "Unique temporary string ID for this node within the response" }, + "name": { "type": "string", "description": "Display name of the node" }, + "type": { "type": "string", "description": "Type of the node (e.g., 'start', 'end', 'demand', 'httpRequest', 'condition')" }, + "original_demand_id": { "type": "string", "description": "The database ID of the original demand node if type is 'demand', otherwise null or empty" }, + "meta": { "type": "string", "description": "JSON string containing node-specific metadata (e.g., '{\"method\":\"GET\"}' for httpRequest)" }, + "x": { "type": "number", "description": "Suggested X coordinate for layout" }, + "y": { "type": "number", "description": "Suggested Y coordinate for layout" } + }, + "required": ["temp_id", "name", "type", "meta", "x", "y"] + }, + "graphLink": { + "type": "object", + "properties": { + "source_temp_id": { "type": "string", "description": "Temporary ID of the source node" }, + "target_temp_id": { "type": "string", "description": "Temporary ID of the target node" }, + "meta": { "type": "string", "description": "Optional JSON string for link metadata (e.g., condition label)" } + }, + "required": ["source_temp_id", "target_temp_id"] + } + } + }` + var testSchema interface{} + if err := json.Unmarshal([]byte(schemaStr), &testSchema); err != nil { + return "", fmt.Errorf("内部错误: 图 JSON schema 无效: %v", err) + } + prettySchemaBytes, _ := json.MarshalIndent(testSchema, "", " ") + return string(prettySchemaBytes), nil +} +func (p *GraphProcessor) validateGraphJSON(jsonData []byte) error { + // ... (省略验证逻辑,同上) ... + schemaStr, err := p.generateGraphJSONSchema() + if err != nil { + return fmt.Errorf("无法生成图 Schema 用于验证: %v", err) + } + schemaLoader := gojsonschema.NewStringLoader(schemaStr) + documentLoader := gojsonschema.NewBytesLoader(jsonData) + result, err := gojsonschema.Validate(schemaLoader, documentLoader) + if err != nil { + return fmt.Errorf("JSON Schema 验证器错误: %v", err) + } + if !result.Valid() { /* ... 构造错误信息 ... */ + return fmt.Errorf("LLM 输出未通过 JSON Schema 验证: ...") + } + return nil +} +func (p *GraphProcessor) cleanLLMResponse(response string) (string, error) { + // ... (省略清理逻辑,同上) ... + response = strings.TrimSpace(response) + // ... (移除 ```json, ``` 等) ... + if !strings.HasPrefix(response, "{") || !strings.HasSuffix(response, "}") { + start := strings.Index(response, "{") + end := strings.LastIndex(response, "}") + if start == -1 || end == -1 || start >= end { + return "", fmt.Errorf("无法在响应中找到有效的 JSON 对象边界 '{...}'") + } + response = response[start : end+1] + } + var checker interface{} + if err := json.Unmarshal([]byte(response), &checker); err != nil { + return "", fmt.Errorf("清理后的内容不是有效的 JSON: %w", err) + } + return response, nil +} + +// 确保 llmGraphNode, llmGraphLink, llmGraphOutput 定义完整 +type llmGraphNode struct { + TempID string `json:"temp_id"` + Name string `json:"name"` + Type string `json:"type"` + Meta string `json:"meta"` + OriginalDemandID string `json:"original_demand_id,omitempty"` + X float64 `json:"x"` + Y float64 `json:"y"` +} +type llmGraphLink struct { + SourceTempID string `json:"source_temp_id"` + TargetTempID string `json:"target_temp_id"` + Meta string `json:"meta,omitempty"` +} +type llmGraphOutput struct { + Nodes []llmGraphNode `json:"nodes"` + Links []llmGraphLink `json:"links"` +} diff --git a/api/project/stage/graph_link/handler.go b/api/project/stage/graph_link/handler.go new file mode 100644 index 0000000..680a605 --- /dev/null +++ b/api/project/stage/graph_link/handler.go @@ -0,0 +1,242 @@ +package graph_link + +import ( + "app/cfg" + "app/models/project/stage" + "app/utils" + "encoding/json" + "errors" + "fmt" + "io" + + "github.com/google/uuid" + "github.com/veypi/OneBD/rest" + "gorm.io/gorm" +) + +var _ = Router.Get("/:id/", getHandle) //TODO +var _ = Router.Get("", getHandle) + +func getHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + stageID, ok := x.Params.Get("id") + if !ok { + return nil, errors.New("缺少stage_id") + } + + var links []stage.GraphLink + if err := cfg.DB().Where("stage_id = ?", stageID).Find(&links).Error; err != nil { + return nil, err + } + return links, nil +} + +// 获取单个连接详情 +func getOneHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + id, ok := x.Params.Get("id") + if !ok { + return nil, errors.New("缺少id") + } + + var link stage.GraphLink + if err := cfg.DB().Where("id = ?", id).First(&link).Error; err != nil { + return nil, err + } + + sourceNode, _ := link.SourceNode(cfg.DB()) + targetNode, _ := link.TargetNode(cfg.DB()) + + return map[string]interface{}{ + "link": link, + "source_node": sourceNode, + "target_node": targetNode, + }, nil +} + +var _ = Router.Post("", postHandle) + +// 创建图形连接 +func postHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + link := &stage.GraphLink{} + if err := json.NewDecoder(x.Request.Body).Decode(&link); err != nil { + return nil, err + } + link.ID = uuid.New().String()[0:32] + + // 如果没有提供LinkID,则生成一个 + if link.LinkID == "" { + link.LinkID = fmt.Sprintf("%s-%s", link.SourceID, link.TargetID) + } + + // 验证源节点是否存在 + var sourceNode stage.GraphNode + if err := cfg.DB().Where("instance_id = ?", link.SourceID).First(&sourceNode).Error; err != nil { + return nil, errors.New("源节点不存在") + } + + // 验证目标节点是否存在 + var targetNode stage.GraphNode + if err := cfg.DB().Where("instance_id = ?", link.TargetID).First(&targetNode).Error; err != nil { + return nil, errors.New("目标节点不存在") + } + + // 验证两个节点是否在同一个阶段 + if sourceNode.StageID != targetNode.StageID { + return nil, errors.New("源节点和目标节点必须在同一个阶段") + } + + // 设置阶段ID + link.StageID = sourceNode.StageID + + // 验证是否已存在相同的连接 + var count int64 + if err := cfg.DB().Model(&stage.GraphLink{}).Where("source_id = ? AND target_id = ?", link.SourceID, link.TargetID).Count(&count).Error; err != nil { + return nil, err + } + if count > 0 { + return nil, errors.New("已存在相同的连接") + } + + if err := cfg.DB().Create(&link).Error; err != nil { + return nil, err + } + return link, nil +} + +var _ = Router.Patch("/:id/", patchHandle) + +// 更新图形连接 +func patchHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + // 从URL获取ID + body, err := io.ReadAll(x.Request.Body) + if err != nil { + return nil, fmt.Errorf("读取请求体失败: %v", err) + } + + id, ok := x.Params.Get("id") + if !ok { + return nil, errors.New("缺少id") + } + var updateMap map[string]interface{} + if err := json.Unmarshal(body, &updateMap); err != nil { + return nil, fmt.Errorf("解析JSON失败: %v", err) + } + + // 先检查连接是否存在 + var link stage.GraphLink + if err := cfg.DB().Where("id = ?", id).First(&link).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("连接不存在") + } + return nil, err + } + + // 如果更新包含source_id或target_id,需要验证节点是否存在和是否在同一阶段 + sourceID, hasSourceID := updateMap["source_id"] + targetID, hasTargetID := updateMap["target_id"] + + if hasSourceID || hasTargetID { + newSourceID := link.SourceID + newTargetID := link.TargetID + + if hasSourceID { + var ok bool + newSourceID, ok = sourceID.(string) + if !ok { + return nil, errors.New("source_id 必须是字符串类型") + } + } + + if hasTargetID { + var ok bool + newTargetID, ok = targetID.(string) + if !ok { + return nil, errors.New("target_id 必须是字符串类型") + } + } + + // 验证源节点是否存在 + var sourceNode stage.GraphNode + if err := cfg.DB().Where("instance_id = ?", newSourceID).First(&sourceNode).Error; err != nil { + return nil, errors.New("源节点不存在") + } + + // 验证目标节点是否存在 + var targetNode stage.GraphNode + if err := cfg.DB().Where("instance_id = ?", newTargetID).First(&targetNode).Error; err != nil { + return nil, errors.New("目标节点不存在") + } + + // 验证两个节点是否在同一个阶段 + if sourceNode.StageID != targetNode.StageID { + return nil, errors.New("源节点和目标节点必须在同一个阶段") + } + + // 验证是否已存在相同的连接 + var count int64 + if err := cfg.DB().Model(&stage.GraphLink{}).Where("id != ? AND source_id = ? AND target_id = ?", id, newSourceID, newTargetID).Count(&count).Error; err != nil { + return nil, err + } + if count > 0 { + return nil, errors.New("已存在相同的连接") + } + + // 更新阶段ID + updateMap["stage_id"] = sourceNode.StageID + + // 更新LinkID + updateMap["link_id"] = fmt.Sprintf("%s-%s", newSourceID, newTargetID) + } + + // 删除不允许更新的字段 + delete(updateMap, "id") + + // 只更新提供的字段 + if len(updateMap) > 0 { + if err := cfg.DB().Model(&link).Updates(updateMap).Error; err != nil { + return nil, err + } + } + + // 重新查询更新后的完整数据 + if err := cfg.DB().Where("id = ?", id).First(&link).Error; err != nil { + return nil, err + } + + return link, nil +} + +var _ = Router.Delete("/:id/", deleteHandle) + +// 删除图形连接 +func deleteHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + id, ok := x.Params.Get("id") + if !ok { + return nil, errors.New("缺少id") + } + + var link stage.GraphLink + if err := cfg.DB().Where("id = ?", id).First(&link).Error; err != nil { + return nil, err + } + + // 删除连接 + if err := cfg.DB().Delete(&link).Error; err != nil { + return nil, err + } + + return map[string]interface{}{ + "msg": "删除成功", + }, nil +} + +var _ = Router.Any("/*", anyHandle) + +func anyHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + return nil, nil +} diff --git a/api/project/stage/graph_link/init.go b/api/project/stage/graph_link/init.go new file mode 100644 index 0000000..390e2d0 --- /dev/null +++ b/api/project/stage/graph_link/init.go @@ -0,0 +1,7 @@ +package graph_link + +import ( + "github.com/veypi/OneBD/rest" +) + +var Router = rest.NewRouter() \ No newline at end of file diff --git a/api/project/stage/graph_node/handler.go b/api/project/stage/graph_node/handler.go new file mode 100644 index 0000000..ffedb23 --- /dev/null +++ b/api/project/stage/graph_node/handler.go @@ -0,0 +1,251 @@ + + +package graph_node + +import ( + "app/cfg" + "app/models" + "app/models/project" + "app/models/project/stage" + "app/utils" + "encoding/json" + "errors" + "fmt" + "io" + + "github.com/google/uuid" + "github.com/veypi/OneBD/rest" + "gorm.io/gorm" +) + +var _ = Router.Get("/:id/", getHandle) //TODO第一个命名生效 +var _ = Router.Get("", getHandle) + +func getHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + stageID, ok := x.Params.Get("id") + if !ok { + return nil, errors.New("缺少stage_id") + } + + var nodes []stage.GraphNode + if err := cfg.DB().Where("stage_id = ?", stageID).Find(&nodes).Error; err != nil { + return nil, err + } + return nodes, nil +} + +// 获取单个节点详情 +func getOneHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + id, ok := x.Params.Get("id") + if !ok { + return nil, errors.New("缺少id") + } + + var node stage.GraphNode + if err := cfg.DB().Where("id = ?", id).First(&node).Error; err != nil { + return nil, err + } + + tree, _ := node.Tree(cfg.DB()) + links, _ := node.Links(cfg.DB()) + + return map[string]interface{}{ + "node": node, + "tree": tree, + "links": links, + }, nil +} + +var _ = Router.Post("", postHandle) + +// 创建图形节点 +func postHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + node := &stage.GraphNode{} + if err := json.NewDecoder(x.Request.Body).Decode(&node); err != nil { + return nil, err + } + node.ID = uuid.New().String()[0:32] + + // 验证树节点是否存在 + var tree project.Demand + if err := cfg.DB().Where("id = ?", node.DemandID).First(&tree).Error; err != nil { + return nil, errors.New("树节点不存在") + } + + // 验证阶段是否存在 + var stage project.Stage + if err := cfg.DB().Where("id = ?", node.StageID).First(&stage).Error; err != nil { + return nil, errors.New("stage不存在") + } + + // 验证树节点是否是阶段指定树节点的子孙节点 + if stage.DemandID != "" && stage.DemandID != "root" { + var stageTree models.Demand + if err := cfg.DB().Where("id = ?", stage.DemandID).First(&stageTree).Error; err != nil { + return nil, errors.New("阶段指定的树节点不存在") + } + + // 获取阶段树节点的所有子孙节点 + descendants, err := stageTree.Descendants(cfg.DB()) + if err != nil { + return nil, err + } + + // 检查当前树节点是否是阶段树节点或其子孙节点 + isValid := tree.ID == stageTree.ID + for _, d := range descendants { + if tree.ID == d.ID { + isValid = true + break + } + } + + if !isValid { + return nil, errors.New("树节点不满足阶段要求") + } + } + + if err := cfg.DB().Create(&node).Error; err != nil { + return nil, err + } + return node, nil +} + +var _ = Router.Patch("/:id/", patchHandle) + +// 更新图形节点 +func patchHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + // 从URL获取ID + body, err := io.ReadAll(x.Request.Body) + if err != nil { + return nil, fmt.Errorf("读取请求体失败: %v", err) + } + + id, ok := x.Params.Get("id") + if !ok { + return nil, errors.New("缺少id") + } + var updateMap map[string]interface{} + if err := json.Unmarshal(body, &updateMap); err != nil { + return nil, fmt.Errorf("解析JSON失败: %v", err) + } + + // 先检查节点是否存在 + var node stage.GraphNode + if err := cfg.DB().Where("id = ?", id).First(&node).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("节点不存在") + } + return nil, err + } + + // 如果更新包含tree_id,需要验证树节点是否存在和是否符合阶段要求 + if treeID, ok := updateMap["tree_id"]; ok { + newDemandID, ok := treeID.(string) + if !ok { + return nil, errors.New("tree_id 必须是字符串类型") + } + + var tree models.Tree + if err := cfg.DB().Where("id = ?", newDemandID).First(&tree).Error; err != nil { + return nil, errors.New("树节点不存在") + } + + var stage models.Stage + if err := cfg.DB().Where("id = ?", node.StageID).First(&stage).Error; err != nil { + return nil, errors.New("阶段不存在") + } + + // 验证树节点是否是阶段指定树节点的子孙节点 + if stage.DemandID != "" && stage.DemandID != "root" { + var stageTree models.Tree + if err := cfg.DB().Where("id = ?", stage.DemandID).First(&stageTree).Error; err != nil { + return nil, errors.New("阶段指定的树节点不存在") + } + + // 获取阶段树节点的所有子孙节点 + descendants, err := stageTree.Descendants(cfg.DB()) + if err != nil { + return nil, err + } + + // 检查当前树节点是否是阶段树节点或其子孙节点 + isValid := tree.ID == stageTree.ID + for _, d := range descendants { + if tree.ID == d.ID { + isValid = true + break + } + } + + if !isValid { + return nil, errors.New("树节点不满足阶段要求") + } + } + } + + // 删除不允许更新的字段 + delete(updateMap, "id") + + // 只更新提供的字段 + if len(updateMap) > 0 { + if err := cfg.DB().Model(&node).Updates(updateMap).Error; err != nil { + return nil, err + } + } + + // 重新查询更新后的完整数据 + if err := cfg.DB().Where("id = ?", id).First(&node).Error; err != nil { + return nil, err + } + + return node, nil +} + +var _ = Router.Delete("/:id/", deleteHandle) + +// 删除图形节点 +func deleteHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + id, ok := x.Params.Get("id") + if !ok { + return nil, errors.New("缺少id") + } + + var node stage.GraphNode + if err := cfg.DB().Where("id = ?", id).First(&node).Error; err != nil { + return nil, err + } + + // 开始事务 + tx := cfg.DB().Begin() + + // 删除与该节点关联的所有连接 + if err := tx.Where("prev_id = ? OR next_id = ?", id, id).Delete(&stage.GraphLink{}).Error; err != nil { + tx.Rollback() + return nil, err + } + + // 删除节点 + if err := tx.Delete(&node).Error; err != nil { + tx.Rollback() + return nil, err + } + + tx.Commit() + + return map[string]interface{}{ + "msg": "删除成功", + }, nil +} + +var _ = Router.Any("/*", anyHandle) + +func anyHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + return nil, nil +} diff --git a/api/project/stage/graph_node/init.go b/api/project/stage/graph_node/init.go new file mode 100644 index 0000000..733d688 --- /dev/null +++ b/api/project/stage/graph_node/init.go @@ -0,0 +1,7 @@ +package graph_node + +import ( + "github.com/veypi/OneBD/rest" +) + +var Router = rest.NewRouter() \ No newline at end of file diff --git a/api/project/stage/handler.go b/api/project/stage/handler.go new file mode 100644 index 0000000..eb77a2d --- /dev/null +++ b/api/project/stage/handler.go @@ -0,0 +1,229 @@ +package stage + +import ( + "app/cfg" + "app/models/project" + "app/models/project/stage" + "app/utils" + "encoding/json" + "errors" + "fmt" + "io" + "log" + + "github.com/google/uuid" + "github.com/veypi/OneBD/rest" + "gorm.io/gorm" +) + +var _ = Router.Get("/:project_id/", getHandle) +var _ = Router.Get("/:project_id/:id", getHandle) +var _ = Router.Get("", getHandle) + +// 获取阶段 +func getHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + project_id, project_ok := x.Params.Get("project_id") + + id, ok := x.Params.Get("id") + if !project_ok { + return nil, errors.New("缺少project_id") + } + + if !ok { + // 获取所有阶段 + var stages []project.Stage + if err := cfg.DB().Where("project_id = ?", project_id).Find(&stages).Error; err != nil { + return nil, err + } + return stages, nil + } + + // 获取特定阶段 + var stage project.Stage + if err := cfg.DB().Where("id = ? and project_id =?", id, project_id).First(&stage).Error; err != nil { + return nil, err + } + + // 获取可用的树节点 + availableNodes, _ := stage.AvailableNodes(cfg.DB()) + var res = map[string]interface{}{ + "stage": stage, + "available_nodes": availableNodes, + } + + return res, nil +} + +var _ = Router.Post("", postHandle) + +// 创建阶段 +func postHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + stage := &project.Stage{} + if err := json.NewDecoder(x.Request.Body).Decode(&stage); err != nil { + return nil, err + } + stage.ID = uuid.New().String()[0:32] + + if err := cfg.DB().Create(&stage).Error; err != nil { + return nil, err + } + return stage, nil +} + +var _ = Router.Patch("/:project_id/:id", patchHandle) + +// TODO + +// 更新阶段 +func patchHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + // 从URL获取ID + body, err := io.ReadAll(x.Request.Body) + if err != nil { + return nil, fmt.Errorf("读取请求体失败: %v", err) + } + + id, ok := x.Params.Get("id") + if !ok { + return nil, errors.New("缺少id") + } + var updateMap map[string]interface{} + if err := json.Unmarshal(body, &updateMap); err != nil { + return nil, fmt.Errorf("解析JSON失败: %v", err) + } + + // 先检查阶段是否存在 + var stage project.Stage + if err := cfg.DB().Where("id = ?", id).First(&stage).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("阶段不存在") + } + return nil, err + } + + // 删除不允许更新的字段 + delete(updateMap, "id") + + // 只更新提供的字段 + if len(updateMap) > 0 { + if err := cfg.DB().Model(&stage).Updates(updateMap).Error; err != nil { + return nil, err + } + } + + // 重新查询更新后的完整数据 + if err := cfg.DB().Where("id = ?", id).First(&stage).Error; err != nil { + return nil, err + } + + return stage, nil +} + +var _ = Router.Delete("/:project_id/:id", deleteHandle) + +// todo + +// 删除阶段 +func deleteHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + id, ok := x.Params.Get("id") + if !ok { + return nil, errors.New("缺少id") + } + + var stageOBJ project.Stage + if err := cfg.DB().Where("id = ?", id).First(&stageOBJ).Error; err != nil { + return nil, err + } + + // 开始事务 + tx := cfg.DB().Begin() + + // 删除与该阶段关联的所有图形节点和连接 + if err := tx.Where("stage_id = ?", id).Delete(&stage.GraphNode{}).Error; err != nil { + tx.Rollback() + return nil, err + } + + if err := tx.Where("stage_id = ?", id).Delete(&stage.GraphLink{}).Error; err != nil { + tx.Rollback() + return nil, err + } + + // 删除阶段 + if err := tx.Delete(&stageOBJ).Error; err != nil { + tx.Rollback() + return nil, err + } + + tx.Commit() + + return map[string]interface{}{ + "msg": "删除成功", + }, nil +} + +var _ = Router.Post("/:project_id/:id/generate", generateGraphHandle) + +func generateGraphHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + log.Println("generateGraphHandle called (async trigger)") + + stageID, ok := x.Params.Get("id") + if !ok { + log.Println("错误: 请求路径缺少 'stage_id'") + return nil, errors.New("请求路径缺少 'stage_id'") + } + log.Printf("收到图生成请求 (StageID: %s)", stageID) + + // --- 基本验证 --- + db := cfg.DB() + if db == nil { + log.Println("错误: 数据库连接不可用") + return nil, errors.New("服务器内部错误 (数据库不可用)") + } + var stageObj project.Stage + err := db.Select("id, demand_id, graph_status").Where("id = ?", stageID).First(&stageObj).Error + if err != nil { /* ... 错误处理,返回 404 或 500 ... */ + } + if stageObj.DemandID == "" { /* ... 错误处理,返回 400 ... */ + } + if stageObj.GraphStatus == "processing" { /* ... 返回任务进行中 ... */ + } + + // --- 更新状态为 pending --- + updateData := map[string]interface{}{"graph_status": "pending", "graph_error": ""} + if err := db.Model(&project.Stage{}).Where("id = ?", stageID).Updates(updateData).Error; err != nil { + log.Printf("警告: 更新阶段状态为 pending 失败 (StageID %s): %v", stageID, err) + } + + // --- 创建任务并添加到队列 --- + task := graphGenerationTask{StageID: stageID} + processor, err := GetGraphProcessor() // 获取同包内的单例 + if err != nil { + log.Printf("错误: 无法获取 GraphProcessor: %v", err) + return nil, errors.New("服务器内部错误 (处理器未就绪)") + } + if err := processor.AddTask(task); err != nil { + log.Printf("添加任务失败 (StageID %s): %v", stageID, err) + return nil, fmt.Errorf("无法添加任务到队列: %w", err) + } + + log.Printf("任务已添加 (StageID %s).", stageID) + + // --- 返回 202 Accepted 对应的消息 --- + return map[string]string{ + "message": fmt.Sprintf("图生成任务已加入队列 (Stage ID: %s)", stageID), + "stage_id": stageID, + "status": "pending", + }, nil +} + +var _ = Router.Any("/*", anyHandle) + +func anyHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + return nil, nil +} diff --git a/api/project/stage/init.go b/api/project/stage/init.go new file mode 100644 index 0000000..c57e812 --- /dev/null +++ b/api/project/stage/init.go @@ -0,0 +1,7 @@ +package stage + +import ( + "github.com/veypi/OneBD/rest" +) + +var Router = rest.NewRouter() \ No newline at end of file diff --git a/api/project/stage/tools/endpoint/handler.go b/api/project/stage/tools/endpoint/handler.go new file mode 100644 index 0000000..7a387a3 --- /dev/null +++ b/api/project/stage/tools/endpoint/handler.go @@ -0,0 +1,108 @@ +package endpoint + +import ( + "app/cfg" + "app/utils" + "encoding/json" + + "github.com/veypi/OneBD/rest" + + M "app/models/project/stage/tools" +) + +var _ = Router.Post("", postHandle) + +func postHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + var endpoint M.Endpoint + if err := json.NewDecoder(x.Request.Body).Decode(&endpoint); err != nil { + return nil, err + } + + if err := cfg.DB().Create(&endpoint).Error; err != nil { + return nil, err + } + return endpoint, nil +} + +var _ = Router.Get("/:id/", getDetail) + +func getDetail(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + id, _ := x.Params.Get("id") + var endpoint M.Endpoint + + if err := cfg.DB().First(&endpoint, "id = ?", id).Error; err != nil { + return nil, rest.ErrNotFound + } + return endpoint, nil +} + +var _ = Router.Get("/list/:project_id/:stage_id/", getList) + +func getList(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + var endpoints []M.Endpoint + stageID, stageOK := x.Params.Get("stage_id") + projectID, projectOK := x.Params.Get("project_id") + + db := cfg.DB() + if stageOK { + db = db.Where("stage_id = ?", stageID) + } + if projectOK { + db = db.Where("project_id = ?", projectID) + } + + if err := db.Find(&endpoints).Error; err != nil { + return nil, err + } + return endpoints, nil +} + +var _ = Router.Patch("/:id/", patchUpdate) + +func patchUpdate(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + id, _ := x.Params.Get("id") + var endpoint M.Endpoint + + if err := cfg.DB().First(&endpoint, "id = ?", id).Error; err != nil { + return nil, rest.ErrNotFound + } + + if err := json.NewDecoder(x.Request.Body).Decode(&endpoint); err != nil { + return nil, err + } + + if err := cfg.DB().Save(&endpoint).Error; err != nil { + return nil, err + } + return endpoint, nil +} + +var _ = Router.Delete("/:id/", deleteHandle) + +func deleteHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + id, _ := x.Params.Get("id") + var endpoint M.Endpoint + + if err := cfg.DB().First(&endpoint, "id = ?", id).Error; err != nil { + return nil, rest.ErrNotFound + } + + if err := cfg.DB().Delete(&endpoint).Error; err != nil { + return nil, err + } + return map[string]interface{}{ + "msg": "删除成功", + }, nil +} + +var _ = Router.Any("/*", anyHandle) + +func anyHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + return nil, nil +} diff --git a/api/project/stage/tools/endpoint/init.go b/api/project/stage/tools/endpoint/init.go new file mode 100644 index 0000000..ac5d101 --- /dev/null +++ b/api/project/stage/tools/endpoint/init.go @@ -0,0 +1,7 @@ +package endpoint + +import ( + "github.com/veypi/OneBD/rest" +) + +var Router = rest.NewRouter() diff --git a/api/project/stage/tools/init.go b/api/project/stage/tools/init.go new file mode 100644 index 0000000..d34fe0a --- /dev/null +++ b/api/project/stage/tools/init.go @@ -0,0 +1,7 @@ +package tools + +import ( + "github.com/veypi/OneBD/rest" +) + +var Router = rest.NewRouter() diff --git a/api/stage/handler.go b/api/stage/handler.go new file mode 100644 index 0000000..cdd41a6 --- /dev/null +++ b/api/stage/handler.go @@ -0,0 +1,171 @@ +package stage + +import ( + "app/cfg" + "app/models" + "app/utils" + "encoding/json" + "errors" + "fmt" + "io" + + "github.com/google/uuid" + "github.com/veypi/OneBD/rest" + "gorm.io/gorm" +) + +var _ = Router.Get("/:project_id/", getHandle) +var _ = Router.Get("/:project_id/:id", getHandle) +var _ = Router.Get("", getHandle) + +// 获取阶段 +func getHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + project_id, project_ok := x.Params.Get("project_id") + + id, ok := x.Params.Get("id") + if !project_ok { + return nil, errors.New("缺少project_id") + } + + if !ok { + // 获取所有阶段 + var stages []models.Stage + if err := cfg.DB().Where("project_id = ?", project_id).Find(&stages).Error; err != nil { + return nil, err + } + return stages, nil + } + + // 获取特定阶段 + var stage models.Stage + if err := cfg.DB().Where("id = ? and project_id =?", id, project_id).First(&stage).Error; err != nil { + return nil, err + } + + // 获取可用的树节点 + availableNodes, _ := stage.AvailableNodes(cfg.DB()) + var res = map[string]interface{}{ + "stage": stage, + "available_nodes": availableNodes, + } + + return res, nil +} + +var _ = Router.Post("", postHandle) + +// 创建阶段 +func postHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + stage := &models.Stage{} + if err := json.NewDecoder(x.Request.Body).Decode(&stage); err != nil { + return nil, err + } + stage.ID = uuid.New().String()[0:32] + + if err := cfg.DB().Create(&stage).Error; err != nil { + return nil, err + } + return stage, nil +} + +var _ = Router.Patch("/:project_id/:id", patchHandle) + +// TODO + +// 更新阶段 +func patchHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + // 从URL获取ID + body, err := io.ReadAll(x.Request.Body) + if err != nil { + return nil, fmt.Errorf("读取请求体失败: %v", err) + } + + id, ok := x.Params.Get("id") + if !ok { + return nil, errors.New("缺少id") + } + var updateMap map[string]interface{} + if err := json.Unmarshal(body, &updateMap); err != nil { + return nil, fmt.Errorf("解析JSON失败: %v", err) + } + + // 先检查阶段是否存在 + var stage models.Stage + if err := cfg.DB().Where("id = ?", id).First(&stage).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("阶段不存在") + } + return nil, err + } + + // 删除不允许更新的字段 + delete(updateMap, "id") + + // 只更新提供的字段 + if len(updateMap) > 0 { + if err := cfg.DB().Model(&stage).Updates(updateMap).Error; err != nil { + return nil, err + } + } + + // 重新查询更新后的完整数据 + if err := cfg.DB().Where("id = ?", id).First(&stage).Error; err != nil { + return nil, err + } + + return stage, nil +} + +var _ = Router.Delete("/:project_id/:id", deleteHandle) + +// todo + +// 删除阶段 +func deleteHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + id, ok := x.Params.Get("id") + if !ok { + return nil, errors.New("缺少id") + } + + var stage models.Stage + if err := cfg.DB().Where("id = ?", id).First(&stage).Error; err != nil { + return nil, err + } + + // 开始事务 + tx := cfg.DB().Begin() + + // 删除与该阶段关联的所有图形节点和连接 + if err := tx.Where("stage_id = ?", id).Delete(&models.GraphNode{}).Error; err != nil { + tx.Rollback() + return nil, err + } + + if err := tx.Where("stage_id = ?", id).Delete(&models.GraphLink{}).Error; err != nil { + tx.Rollback() + return nil, err + } + + // 删除阶段 + if err := tx.Delete(&stage).Error; err != nil { + tx.Rollback() + return nil, err + } + + tx.Commit() + + return map[string]interface{}{ + "msg": "删除成功", + }, nil +} + +var _ = Router.Any("/*", anyHandle) + +func anyHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + return nil, nil +} diff --git a/api/stage/init.go b/api/stage/init.go new file mode 100644 index 0000000..c57e812 --- /dev/null +++ b/api/stage/init.go @@ -0,0 +1,7 @@ +package stage + +import ( + "github.com/veypi/OneBD/rest" +) + +var Router = rest.NewRouter() \ No newline at end of file diff --git a/api/tree/handler.go b/api/tree/handler.go new file mode 100644 index 0000000..1f3a3dd --- /dev/null +++ b/api/tree/handler.go @@ -0,0 +1,356 @@ +package tree + +import ( + "app/cfg" + "app/models" + "app/utils" + "encoding/json" + "errors" + "fmt" + "io" + + "github.com/google/uuid" + "github.com/veypi/OneBD/rest" + "gorm.io/gorm" +) + +var _ = Router.Get("/:id/descendants", getDescendantsHandle) +var _ = Router.Get("/:id/:min_level/", getHandle) +var _ = Router.Get("/:id/", getHandle) +var _ = Router.Get("", getHandle) + +// 获取树节点 +func getHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + id, ok := x.Params.Get("id") + minLevel, hasMinLevel := x.Params.Get("min_level") + + if !ok || id == "-1" { + fmt.Print("id is not ok") + // 获取所有根节点 + var trees []models.Tree + query := cfg.DB() + + // 如果指定了最小级别,添加筛选条件 + if hasMinLevel { + minLevelInt := 0 + fmt.Sscanf(minLevel, "%d", &minLevelInt) + query = query.Where("level >= ?", minLevelInt) + } + + if err := query.Find(&trees).Error; err != nil { + return nil, err + } + return trees, nil + } + + // 获取特定节点及其子节点 + var tree models.Tree + if err := cfg.DB().Where("id = ?", id).First(&tree).Error; err != nil { + return nil, err + } + + children, _ := tree.Children(cfg.DB()) + var res = map[string]interface{}{ + "node": tree, + "children": children, + } + + return res, nil +} +func getDescendantsHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + id, ok := x.Params.Get("id") + if !ok { + return nil, errors.New("缺少id") + } + + // 获取特定节点 + var tree models.Tree + if err := cfg.DB().Where("id = ?", id).First(&tree).Error; err != nil { + return nil, err + } + + // 获取所有子孙节点 + descendants, err := tree.Descendants(cfg.DB()) + if err != nil { + return nil, err + } + + return map[string]interface{}{ + "node": tree, + "descendants": descendants, + }, nil +} + +var _ = Router.Post("", postHandle) + +// 创建树节点 +func postHandle(x *rest.X) (any, error) { + fmt.Print("postHandle") + utils.SetCORSHeaders(x) + tree := &models.Tree{} + if err := json.NewDecoder(x.Request.Body).Decode(&tree); err != nil { + return nil, err + } + tree.ID = uuid.New().String()[0:32] + // 设置级别 + if tree.ParentID == "-1" || tree.ParentID == "" { + tree.Level = 0 + } else { + var parent models.Tree + if err := cfg.DB().Where("id = ?", tree.ParentID).First(&parent).Error; err != nil { + return nil, err + } + tree.Level = parent.Level + 1 + } + + if err := cfg.DB().Create(&tree).Error; err != nil { + fmt.Println(err) + return nil, err + } + fmt.Println(tree) + return tree, nil +} + +var _ = Router.Patch("/:id/", patchHandle) + +// 更新树节点 +func patchHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + // 从URL获取ID + body, err := io.ReadAll(x.Request.Body) + if err != nil { + return nil, fmt.Errorf("读取请求体失败: %v", err) + } + fmt.Printf("收到的请求体内容: %s\n", string(body)) + + id, ok := x.Params.Get("id") + if !ok { + return nil, errors.New("缺少id") + } + var updateMap map[string]interface{} + if err := json.Unmarshal(body, &updateMap); err != nil { + return nil, fmt.Errorf("解析JSON失败: %v", err) + } + // 先检查节点是否存在 + var tree models.Tree + if err := cfg.DB().Where("id = ?", id).First(&tree).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("节点不存在") + } + return nil, err + } + + // 解析请求数据 + + // if err := x.Parse(&updateMap); err != nil { // 使用 x.Parse 替代 json.NewDecoder + // return nil, err + // } + + // 如果更新包含parent_id,需要特殊处理层级 + if parentID, ok := updateMap["parent_id"]; ok { + newParentID, ok := parentID.(string) + if !ok { + return nil, errors.New("parent_id 必须是字符串类型") + } + + // 检查是否形成循环引用 + if newParentID == tree.ID { + return nil, errors.New("不能将节点的父节点设置为自身") + } + + if newParentID != tree.ParentID { + var newLevel int + // 检查新的父节点是否存在(除非是设置为根节点) + if newParentID != "-1" && newParentID != "" { + var parent models.Tree + if err := cfg.DB().Where("id = ?", newParentID).First(&parent).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("父节点不存在") + } + return nil, err + } + + // 检查新父节点是否是当前节点的子节点 + children, err := tree.Children(cfg.DB()) + if err != nil { + return nil, err + } + for _, child := range children { + if child.ID == newParentID { + return nil, errors.New("不能将子节点设置为父节点") + } + } + + newLevel = parent.Level + 1 + } else { + newLevel = 0 + } + + // 开始事务 + tx := cfg.DB().Begin() + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + // 更新当前节点的 parent_id 和 level + if err := tx.Model(&models.Tree{}).Where("id = ?", tree.ID).Updates(map[string]interface{}{ + "parent_id": newParentID, + "level": newLevel, + }).Error; err != nil { + tx.Rollback() + return nil, err + } + if err := updateChildrenLevels(tx, tree.ID, newLevel); err != nil { + tx.Rollback() + return nil, err + } + + if err := tx.Commit().Error; err != nil { + return nil, err + } + // 递归更新所有子节点的 level + // if err := tx.Exec(`UPDATE trees, + // (WITH RECURSIVE tree_paths AS ( + // SELECT id, parent_id, 0 as depth + // FROM trees WHERE id = ? + // UNION ALL + // SELECT t.id, t.parent_id, tp.depth + 1 + // FROM trees t + // JOIN tree_paths tp ON t.parent_id = tp.id + // ) + // SELECT id, depth FROM tree_paths WHERE id != ?) as paths + // SET trees.level = ? + paths.depth + // WHERE trees.id = paths.id`, tree.ID, tree.ID, newLevel).Error; err != nil { + // tx.Rollback() + // return nil, err + // } + + // if err := tx.Commit().Error; err != nil { + // return nil, err + // } + } + } + + // 删除不允许更新的字段 + delete(updateMap, "id") + delete(updateMap, "level") // level 不允许直接更新 + + // 只更新提供的字段 + if len(updateMap) > 0 { + if err := cfg.DB().Model(&tree).Updates(updateMap).Error; err != nil { + return nil, err + } + } + + // 重新查询更新后的完整数据 + if err := cfg.DB().Where("id = ?", id).First(&tree).Error; err != nil { + return nil, err + } + + return tree, nil +} + +var _ = Router.Delete("/:id/", deleteHandle) + +// 删除树节点 +func deleteHandle(x *rest.X) (any, error) { + // 从路径中获取ID + utils.SetCORSHeaders(x) + id, ok := x.Params.Get("id") + if !ok { + return nil, errors.New("缺少id") + } + + // 从body中获取cascade参数 + var params struct { + Cascade bool `json:"cascade"` + } + if err := x.Parse(¶ms); err != nil { + params.Cascade = false // 默认不级联删除 + } + + var tree models.Tree + if err := cfg.DB().Where("id = ?", id).First(&tree).Error; err != nil { + + return nil, err + } + + tx := cfg.DB().Begin() + + if params.Cascade { + // 级联删除 + if err := tx.Where( + "id = ? OR parent_id = ? OR parent_id IN (SELECT id FROM trees WHERE level > ?)", + tree.ID, tree.ID, tree.Level, + ).Delete(&models.Tree{}).Error; err != nil { + tx.Rollback() + + return nil, err + } + } else { + // 非级联删除,子节点提升 + if err := tx.Model(&models.Tree{}).Where("parent_id = ?", tree.ID).Update( + "parent_id", tree.ParentID, + ).Error; err != nil { + tx.Rollback() + + return nil, err + } + + // 删除当前节点 + if err := tx.Delete(&tree).Error; err != nil { + tx.Rollback() + + return nil, err + } + } + + tx.Commit() + + var res = map[string]interface{}{ + "msg": "删除成功", + } + return res, nil +} + +var _ = Router.Any("/*", anyHandle) + +func anyHandle(x *rest.X) (any, error) { + utils.SetCORSHeaders(x) + fmt.Print("anyHandle") + return nil, nil +} +func updateChildrenLevels(tx *gorm.DB, parentID string, parentLevel int) error { + // 查找直接子节点 + var children []models.Tree + if err := tx.Where("parent_id = ?", parentID).Find(&children).Error; err != nil { + return err + } + + // 没有子节点,直接返回 + if len(children) == 0 { + return nil + } + + // 更新每个直接子节点的层级 + for _, child := range children { + // 计算新的层级 + newLevel := parentLevel + 1 + + // 更新当前子节点的层级 + if err := tx.Model(&models.Tree{}).Where("id = ?", child.ID).Update("level", newLevel).Error; err != nil { + return err + } + + // 递归更新该子节点的所有子节点 + if err := updateChildrenLevels(tx, child.ID, newLevel); err != nil { + return err + } + } + + return nil +} diff --git a/api/tree/init.go b/api/tree/init.go new file mode 100644 index 0000000..77e1f06 --- /dev/null +++ b/api/tree/init.go @@ -0,0 +1,14 @@ +// +// init.go +// Copyright (C) 2025 veypi +// 2025-02-27 +// Distributed under terms of the MIT license. +// + +package tree + +import ( + "github.com/veypi/OneBD/rest" +) + +var Router = rest.NewRouter() diff --git a/cfg/cfg.go b/cfg/cfg.go new file mode 100644 index 0000000..f1c4e2a --- /dev/null +++ b/cfg/cfg.go @@ -0,0 +1,26 @@ +// +// cfg.go +// Copyright (C) 2024 veypi +// 2025-02-27 19:32:09 +// Distributed under terms of the MIT license. +// + +package cfg + +type config struct { + Host string `json:"host"` + Port int `json:"port"` + LoggerPath string `json:"logger_path,omitempty"` + LoggerLevel string `json:"logger_level,omitempty"` + DSN string `json:"dsn"` + DB string `json:"db"` +} + +var Config = &config{ + Host: "0.0.0.0", + Port: 5002, + LoggerPath: "", + LoggerLevel: "debug", + DSN: "dev:20240309@tcp(127.0.0.1:3306)/testb?charset=utf8&parseTime=True&loc=Local", + DB: "mysql", +} diff --git a/cfg/db.go b/cfg/db.go new file mode 100644 index 0000000..bb3608d --- /dev/null +++ b/cfg/db.go @@ -0,0 +1,41 @@ +// +// db.go +// Copyright (C) 2024 veypi +// 2025-02-27 19:32:09 +// Distributed under terms of the MIT license. +// + +package cfg + +import ( + "github.com/glebarez/sqlite" + "gorm.io/driver/mysql" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +var db *gorm.DB +var ObjList = make([]any, 0, 10) + +func DB() *gorm.DB { + if db == nil { + var err error + var conn gorm.Dialector + if Config.DB == "mysql" { + conn = mysql.Open(Config.DSN) + } else if Config.DB == "postgres" { + conn = postgres.Open(Config.DSN) + } else { + conn = sqlite.Open(Config.DSN) + } + db, err = gorm.Open(conn, + &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + panic(err) + } + } + return db +} diff --git a/cfg/dev.yaml b/cfg/dev.yaml new file mode 100644 index 0000000..e42c810 --- /dev/null +++ b/cfg/dev.yaml @@ -0,0 +1,13 @@ +restconf: + host: 0.0.0.0 + port: 5000 + loggerpath: "" + loggerlevel: debug + prettylog: false + timeformat: "" + postmaxmemory: 0 + tlscfg: null + maxconnections: 0 +dsn: dev:20240309@tcp(127.0.0.1:3306)/testb?charset=utf8&parseTime=True&loc=Local +repo: /Users/zpc01/workspace/v/test_front/ +dev: true diff --git a/cfg/tool_node_definations.json b/cfg/tool_node_definations.json new file mode 100644 index 0000000..efbe1c3 --- /dev/null +++ b/cfg/tool_node_definations.json @@ -0,0 +1,707 @@ +{ + "demand": { + "name": "需求节点", + "category": "demand", + "color": "#1890ff", + "icon": "demand-icon", + "icon_path": "/assets/icons/demand.svg", + "shape": "rect", + "width": 180, + "height": 50, + "ports": [ + "top", + "right", + "bottom", + "left" + ], + "metaFields": [ + { + "name": "description", + "type": "string", + "label": "描述" + }, + { + "name": "priority", + "type": "select", + "label": "优先级", + "options": [ + "高", + "中", + "低" + ] + } + ] + }, + "start": { + "name": "开始", + "category": "logic", + "color": "#722ed1", + "icon": "play-circle", + "shape": "circle", + "width": 60, + "height": 60, + "ports": [ + "right", + "bottom", + "top", + "left" + ], + "metaFields": [ + { + "name": "description", + "type": "string", + "label": "描述" + } + ] + }, + "end": { + "name": "结束", + "category": "logic", + "color": "#722ed1", + "icon": "stop-circle", + "shape": "circle", + "width": 60, + "height": 60, + "ports": [ + "left", + "top", + "right", + "bottom" + ], + "metaFields": [ + { + "name": "description", + "type": "string", + "label": "描述" + } + ] + }, + "condition": { + "name": "条件", + "category": "logic", + "color": "#fa8c16", + "icon": "question-circle", + "shape": "diamond", + "width": 80, + "height": 80, + "ports": [ + "top", + "right", + "bottom", + "left" + ], + "metaFields": [ + { + "name": "condition", + "type": "code", + "label": "条件表达式" + } + ] + }, + "and": { + "name": "AND", + "category": "logic", + "color": "#722ed1", + "icon": "and-icon", + "shape": "diamond", + "width": 80, + "height": 80, + "ports": [ + "top", + "right", + "bottom", + "left" + ], + "metaFields": [ + { + "name": "operation", + "type": "hidden", + "defaultValue": "AND" + } + ] + }, + "or": { + "name": "OR", + "category": "logic", + "color": "#eb2f96", + "icon": "or-icon", + "shape": "diamond", + "width": 80, + "height": 80, + "ports": [ + "top", + "right", + "bottom", + "left" + ], + "metaFields": [ + { + "name": "operation", + "type": "hidden", + "defaultValue": "OR" + } + ] + }, + "httpRequest": { + "name": "HTTP验证", + "category": "request", + "color": "#52c41a", + "icon": "api", + "icon_path": "/assets/icons/httpFlow.svg", + "shape": "rect", + "width": 150, + "height": 50, + "ports": [ + "top", + "right", + "bottom", + "left" + ], + "metaFields": [ + { + "name": "method", + "type": "select", + "label": "请求方法", + "options": [ + "GET", + "POST", + "PUT", + "DELETE", + "PATCH" + ] + }, + { + "name": "path", + "type": "string", + "label": "请求路径" + }, + { + "name": "params", + "type": "json", + "label": "查询参数", + "defaultValue": "{}" + }, + { + "name": "headers", + "type": "json", + "label": "请求头", + "defaultValue": "{\"Content-Type\": \"application/json\"}" + }, + { + "name": "body", + "type": "json", + "label": "请求体", + "defaultValue": "{\"type\": \"json\", \"content\": \"{}\"}" + }, + { + "name": "response", + "type": "hidden", + "defaultValue": null + }, + { + "name": "endpointId", + "type": "hidden", + "defaultValue": "" + } + ] + }, + "loadTestHttpRequest": { + "name": "HTTP 负载测试", + "category": "perform", + "color": "#722ed1", + "icon_path": "/assets/icons/load-test.svg", + "shape": "rect", + "width": 180, + "height": 50, + "ports": [ + "top", + "right", + "bottom", + "left" + ], + "metaFields": [ + { + "name": "endpointId", + "type": "hidden", + "defaultValue": "" + }, + { + "name": "method", + "type": "select", + "label": "请求方法", + "options": [ + "GET", + "POST", + "PUT", + "DELETE", + "PATCH" + ], + "defaultValue": "GET" + }, + { + "name": "path", + "type": "string", + "label": "请求路径" + }, + { + "name": "params", + "type": "json", + "label": "查询参数", + "defaultValue": "{}" + }, + { + "name": "headers", + "type": "json", + "label": "请求头", + "defaultValue": "{\"Content-Type\": \"application/json\"}" + }, + { + "name": "body", + "type": "json", + "label": "请求体", + "defaultValue": "{\"type\": \"json\", \"content\": \"{}\"}" + }, + { + "name": "concurrentUsers", + "type": "number", + "label": "并发用户数", + "defaultValue": 10 + }, + { + "name": "testDurationSec", + "type": "number", + "label": "测试时长(秒)", + "defaultValue": 30 + }, + { + "name": "rampUpTimeSec", + "type": "number", + "label": "启动时长(秒)", + "defaultValue": 0, + "optional": true + }, + { + "name": "resultsSummary", + "type": "textarea", + "label": "测试结果摘要", + "readonly": true, + "rows": 4 + }, + { + "name": "detailedResults", + "type": "hidden", + "defaultValue": null + } + ] + }, + "httpLatencyTest": { + "name": "HTTP 延迟测试", + "category": "perform", + "color": "#b37feb", + "icon_path": "/assets/icons/latency.svg", + "shape": "rect", + "width": 180, + "height": 50, + "ports": [ + "top", + "right", + "bottom", + "left" + ], + "metaFields": [ + { + "name": "endpointId", + "type": "hidden", + "defaultValue": "" + }, + { + "name": "method", + "type": "select", + "label": "请求方法", + "options": [ + "GET", + "POST", + "PUT", + "DELETE", + "PATCH" + ], + "defaultValue": "GET" + }, + { + "name": "path", + "type": "string", + "label": "请求路径" + }, + { + "name": "params", + "type": "json", + "label": "查询参数", + "defaultValue": "{}" + }, + { + "name": "headers", + "type": "json", + "label": "请求头", + "defaultValue": "{\"Content-Type\": \"application/json\"}" + }, + { + "name": "body", + "type": "json", + "label": "请求体", + "defaultValue": "{\"type\": \"json\", \"content\": \"{}\"}" + }, + { + "name": "iterations", + "type": "number", + "label": "迭代次数", + "defaultValue": 10, + "description": "连续发送请求的次数" + }, + { + "name": "delayBetweenMs", + "type": "number", + "label": "迭代间隔(ms)", + "defaultValue": 0, + "optional": true, + "description": "每次请求之间的等待时间" + }, + { + "name": "latencyStats", + "type": "textarea", + "label": "延迟统计", + "readonly": true, + "rows": 4, + "description": "例如: Min, Max, Avg, P95, P99, Errors" + }, + { + "name": "detailedLatencies", + "type": "hidden", + "defaultValue": null + } + ] + }, + "securityScanTrigger": { + "name": "安全扫描", + "category": "safety", + "color": "#f5222d", + "icon_path": "/assets/icons/safety.svg", + "shape": "rect", + "width": 180, + "height": 50, + "ports": [ + "top", + "right", + "bottom", + "left" + ], + "metaFields": [ + { + "name": "scanTarget", + "type": "string", + "label": "扫描目标", + "description": "例如 URL, IP, 服务名" + }, + { + "name": "scanProfile", + "type": "select", + "label": "扫描配置/策略", + "options": [ + "快速扫描", + "全面扫描", + "OWASP Top10" + ], + "defaultValue": "快速扫描" + }, + { + "name": "apiKeyRef", + "type": "string", + "label": "扫描工具API密钥引用", + "optional": true, + "description": "引用环境变量或配置" + }, + { + "name": "waitForResult", + "type": "boolean", + "label": "等待扫描结果", + "defaultValue": false + }, + { + "name": "scanId", + "type": "string", + "label": "扫描任务ID", + "readonly": true + }, + { + "name": "scanStatus", + "type": "string", + "label": "扫描状态", + "readonly": true + }, + { + "name": "scanResultSummary", + "type": "textarea", + "label": "扫描结果摘要", + "readonly": true, + "rows": 3 + }, + { + "name": "detailedScanResult", + "type": "hidden" + } + ] + }, + "authCheck": { + "name": "认证授权检查", + "category": "safety", + "color": "#faad14", + "icon_path": "/assets/icons/auth-check.svg", + "shape": "rect", + "width": 170, + "height": 50, + "ports": [ + "top", + "right", + "bottom", + "left" + ], + "metaFields": [ + { + "name": "endpointInfo", + "type": "json", + "label": "目标接口信息", + "defaultValue": "{\"method\":\"GET\", \"path\":\"/api/resource\"}" + }, + { + "name": "credentials", + "type": "json", + "label": "使用的凭证", + "defaultValue": "{\"type\":\"token\", \"value\":\"{input.token}\"}", + "description": "支持从输入引用值" + }, + { + "name": "expectedStatus", + "type": "select", + "label": "预期HTTP状态", + "options": [ + "2xx (成功)", + "401 (未授权)", + "403 (禁止访问)", + "其他" + ], + "defaultValue": "2xx (成功)" + }, + { + "name": "customExpectedStatus", + "type": "number", + "label": "预期状态码(其他)", + "optional": true + }, + { + "name": "checkResult", + "type": "string", + "label": "检查结果", + "readonly": true + }, + { + "name": "responseDetails", + "type": "hidden" + } + ] + }, + "dataMaskingCheck": { + "name": "数据脱敏检查", + "category": "compliance", + "color": "#595959", + "icon_path": "/assets/icons/masking-check.svg", + "shape": "rect", + "width": 160, + "height": 50, + "ports": [ + "top", + "right", + "bottom", + "left" + ], + "metaFields": [ + { + "name": "dataFieldPath", + "type": "string", + "label": "检查字段路径", + "defaultValue": "body.creditCard", + "description": "使用点标记法指定字段" + }, + { + "name": "expectedFormat", + "type": "string", + "label": "预期脱敏格式", + "defaultValue": "**** **** **** 1234", + "description": "用*表示脱敏位" + }, + { + "name": "checkResult", + "type": "string", + "label": "检查结果", + "readonly": true + }, + { + "name": "actualValue", + "type": "hidden" + } + ] + }, + "failureInjection": { + "name": "故障注入", + "category": "reliability", + "color": "#cf1322", + "icon_path": "/assets/icons/chaos.svg", + "shape": "rect", + "width": 160, + "height": 50, + "ports": [ + "top", + "right", + "bottom", + "left" + ], + "metaFields": [ + { + "name": "targetResource", + "type": "string", + "label": "注入目标资源", + "description": "e.g., service:my-app, pod:app-*, db:orders" + }, + { + "name": "failureType", + "type": "select", + "label": "故障类型", + "options": [ + "latency", + "error", + "crash", + "network_loss", + "resource_limit" + ] + }, + { + "name": "magnitude", + "type": "string", + "label": "故障程度", + "description": "e.g., latency: 500ms, error: 503, network_loss: 80%" + }, + { + "name": "durationSec", + "type": "number", + "label": "持续时间(秒)", + "defaultValue": 60 + }, + { + "name": "injectionStatus", + "type": "string", + "label": "注入状态", + "readonly": true + }, + { + "name": "details", + "type": "hidden" + } + ] + }, + "agentBrowserTask": { + "name": "Agent UI任务", + "category": "ui", + "color": "#08979c", + "icon_path": "/assets/icons/agent.svg", + "shape": "rect", + "width": 180, + "height": 60, + "ports": [ + "top", + "right", + "bottom", + "left" + ], + "metaFields": [ + { + "name": "targetUrl", + "type": "string", + "label": "目标页面URL" + }, + { + "name": "taskPrompt", + "type": "textarea", + "label": "任务指令 (Prompt)", + "rows": 5, + "description": "给 Agent 的详细操作指令" + }, + { + "name": "agentModel", + "type": "string", + "label": "Agent模型/配置", + "optional": true, + "defaultValue": "default_browser_agent" + }, + { + "name": "inputDataUsage", + "type": "json", + "label": "输入数据使用方式", + "optional": true, + "defaultValue": "{\"map\": [{\"from\": \"input.username\", \"to\": \"form.user\"}, {\"from\": \"input.password\", \"to\": \"form.pass\"}]}", + "description": "定义如何将输入映射到表单或Prompt" + }, + { + "name": "validationCriteria", + "type": "textarea", + "label": "成功/失败标准", + "optional": true, + "description": "如何判断 Agent 执行结果是否符合预期" + }, + { + "name": "executionResult", + "type": "string", + "label": "执行结果", + "readonly": true + }, + { + "name": "agentLog", + "type": "textarea", + "label": "Agent 执行日志", + "readonly": true, + "rows": 4 + }, + { + "name": "screenshotUrl", + "type": "string", + "label": "截图链接", + "readonly": true, + "optional": true + }, + { + "name": "extractedData", + "type": "hidden" + } + ] + }, + "delayNode": { + "name": "延迟等待", + "category": "logic", + "color": "#bfbfbf", + "icon_path": "/assets/icons/delay.svg", + "shape": "circle", + "width": 60, + "height": 60, + "ports": [ + "top", + "right", + "bottom", + "left" + ], + "metaFields": [ + { + "name": "delayMs", + "type": "number", + "label": "延迟时间 (ms)", + "defaultValue": 1000 + } + ] + } +} \ No newline at end of file diff --git a/cli/args.go b/cli/args.go new file mode 100644 index 0000000..f655960 --- /dev/null +++ b/cli/args.go @@ -0,0 +1,37 @@ +// +// Copyright (C) 2024 veypi +// 2025-02-27 19:32:09 +// Distributed under terms of the MIT license. +// + +package main + +import ( + "app/cfg" + + "github.com/veypi/utils/flags" + "github.com/veypi/utils/logv" +) + +var CMD = flags.New("app", "the backend server of app") +var CfgDump = CMD.SubCommand("cfg", "generate cfg file") + +var configFile = CMD.String("f", "./dev.yaml", "the config file") + +func init() { + CMD.StringVar(&cfg.Config.Host, "host", "0.0.0.0", "host") + CMD.IntVar(&cfg.Config.Port, "p", 4000, "port") + CMD.StringVar(&cfg.Config.LoggerLevel, "l", "info", "log level") + CMD.StringVar(&cfg.Config.DSN, "dsn", "root:123456@tcp(127.0.0.1:3306)/test?charset=utf8&parseTime=True&loc=Local", "data source name") + CMD.StringVar(&cfg.Config.DB, "db", "mysql", "data source type: mysql/postgre/sqlite") + CMD.Before = func() error { + flags.LoadCfg(*configFile, cfg.Config) + CMD.Parse() + logv.SetLevel(logv.AssertFuncErr(logv.ParseLevel(cfg.Config.LoggerLevel))) + return nil + } + CfgDump.Command = func() error { + flags.DumpCfg(*configFile, cfg.Config) + return nil + } +} diff --git a/cli/cfg/cfg.go b/cli/cfg/cfg.go new file mode 100644 index 0000000..f1c4e2a --- /dev/null +++ b/cli/cfg/cfg.go @@ -0,0 +1,26 @@ +// +// cfg.go +// Copyright (C) 2024 veypi +// 2025-02-27 19:32:09 +// Distributed under terms of the MIT license. +// + +package cfg + +type config struct { + Host string `json:"host"` + Port int `json:"port"` + LoggerPath string `json:"logger_path,omitempty"` + LoggerLevel string `json:"logger_level,omitempty"` + DSN string `json:"dsn"` + DB string `json:"db"` +} + +var Config = &config{ + Host: "0.0.0.0", + Port: 5002, + LoggerPath: "", + LoggerLevel: "debug", + DSN: "dev:20240309@tcp(127.0.0.1:3306)/testb?charset=utf8&parseTime=True&loc=Local", + DB: "mysql", +} diff --git a/cli/cfg/db.go b/cli/cfg/db.go new file mode 100644 index 0000000..bb3608d --- /dev/null +++ b/cli/cfg/db.go @@ -0,0 +1,41 @@ +// +// db.go +// Copyright (C) 2024 veypi +// 2025-02-27 19:32:09 +// Distributed under terms of the MIT license. +// + +package cfg + +import ( + "github.com/glebarez/sqlite" + "gorm.io/driver/mysql" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +var db *gorm.DB +var ObjList = make([]any, 0, 10) + +func DB() *gorm.DB { + if db == nil { + var err error + var conn gorm.Dialector + if Config.DB == "mysql" { + conn = mysql.Open(Config.DSN) + } else if Config.DB == "postgres" { + conn = postgres.Open(Config.DSN) + } else { + conn = sqlite.Open(Config.DSN) + } + db, err = gorm.Open(conn, + &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + panic(err) + } + } + return db +} diff --git a/cli/cfg/dev.yaml b/cli/cfg/dev.yaml new file mode 100644 index 0000000..e42c810 --- /dev/null +++ b/cli/cfg/dev.yaml @@ -0,0 +1,13 @@ +restconf: + host: 0.0.0.0 + port: 5000 + loggerpath: "" + loggerlevel: debug + prettylog: false + timeformat: "" + postmaxmemory: 0 + tlscfg: null + maxconnections: 0 +dsn: dev:20240309@tcp(127.0.0.1:3306)/testb?charset=utf8&parseTime=True&loc=Local +repo: /Users/zpc01/workspace/v/test_front/ +dev: true diff --git a/cli/cfg/tool_node_definations.json b/cli/cfg/tool_node_definations.json new file mode 100644 index 0000000..efbe1c3 --- /dev/null +++ b/cli/cfg/tool_node_definations.json @@ -0,0 +1,707 @@ +{ + "demand": { + "name": "需求节点", + "category": "demand", + "color": "#1890ff", + "icon": "demand-icon", + "icon_path": "/assets/icons/demand.svg", + "shape": "rect", + "width": 180, + "height": 50, + "ports": [ + "top", + "right", + "bottom", + "left" + ], + "metaFields": [ + { + "name": "description", + "type": "string", + "label": "描述" + }, + { + "name": "priority", + "type": "select", + "label": "优先级", + "options": [ + "高", + "中", + "低" + ] + } + ] + }, + "start": { + "name": "开始", + "category": "logic", + "color": "#722ed1", + "icon": "play-circle", + "shape": "circle", + "width": 60, + "height": 60, + "ports": [ + "right", + "bottom", + "top", + "left" + ], + "metaFields": [ + { + "name": "description", + "type": "string", + "label": "描述" + } + ] + }, + "end": { + "name": "结束", + "category": "logic", + "color": "#722ed1", + "icon": "stop-circle", + "shape": "circle", + "width": 60, + "height": 60, + "ports": [ + "left", + "top", + "right", + "bottom" + ], + "metaFields": [ + { + "name": "description", + "type": "string", + "label": "描述" + } + ] + }, + "condition": { + "name": "条件", + "category": "logic", + "color": "#fa8c16", + "icon": "question-circle", + "shape": "diamond", + "width": 80, + "height": 80, + "ports": [ + "top", + "right", + "bottom", + "left" + ], + "metaFields": [ + { + "name": "condition", + "type": "code", + "label": "条件表达式" + } + ] + }, + "and": { + "name": "AND", + "category": "logic", + "color": "#722ed1", + "icon": "and-icon", + "shape": "diamond", + "width": 80, + "height": 80, + "ports": [ + "top", + "right", + "bottom", + "left" + ], + "metaFields": [ + { + "name": "operation", + "type": "hidden", + "defaultValue": "AND" + } + ] + }, + "or": { + "name": "OR", + "category": "logic", + "color": "#eb2f96", + "icon": "or-icon", + "shape": "diamond", + "width": 80, + "height": 80, + "ports": [ + "top", + "right", + "bottom", + "left" + ], + "metaFields": [ + { + "name": "operation", + "type": "hidden", + "defaultValue": "OR" + } + ] + }, + "httpRequest": { + "name": "HTTP验证", + "category": "request", + "color": "#52c41a", + "icon": "api", + "icon_path": "/assets/icons/httpFlow.svg", + "shape": "rect", + "width": 150, + "height": 50, + "ports": [ + "top", + "right", + "bottom", + "left" + ], + "metaFields": [ + { + "name": "method", + "type": "select", + "label": "请求方法", + "options": [ + "GET", + "POST", + "PUT", + "DELETE", + "PATCH" + ] + }, + { + "name": "path", + "type": "string", + "label": "请求路径" + }, + { + "name": "params", + "type": "json", + "label": "查询参数", + "defaultValue": "{}" + }, + { + "name": "headers", + "type": "json", + "label": "请求头", + "defaultValue": "{\"Content-Type\": \"application/json\"}" + }, + { + "name": "body", + "type": "json", + "label": "请求体", + "defaultValue": "{\"type\": \"json\", \"content\": \"{}\"}" + }, + { + "name": "response", + "type": "hidden", + "defaultValue": null + }, + { + "name": "endpointId", + "type": "hidden", + "defaultValue": "" + } + ] + }, + "loadTestHttpRequest": { + "name": "HTTP 负载测试", + "category": "perform", + "color": "#722ed1", + "icon_path": "/assets/icons/load-test.svg", + "shape": "rect", + "width": 180, + "height": 50, + "ports": [ + "top", + "right", + "bottom", + "left" + ], + "metaFields": [ + { + "name": "endpointId", + "type": "hidden", + "defaultValue": "" + }, + { + "name": "method", + "type": "select", + "label": "请求方法", + "options": [ + "GET", + "POST", + "PUT", + "DELETE", + "PATCH" + ], + "defaultValue": "GET" + }, + { + "name": "path", + "type": "string", + "label": "请求路径" + }, + { + "name": "params", + "type": "json", + "label": "查询参数", + "defaultValue": "{}" + }, + { + "name": "headers", + "type": "json", + "label": "请求头", + "defaultValue": "{\"Content-Type\": \"application/json\"}" + }, + { + "name": "body", + "type": "json", + "label": "请求体", + "defaultValue": "{\"type\": \"json\", \"content\": \"{}\"}" + }, + { + "name": "concurrentUsers", + "type": "number", + "label": "并发用户数", + "defaultValue": 10 + }, + { + "name": "testDurationSec", + "type": "number", + "label": "测试时长(秒)", + "defaultValue": 30 + }, + { + "name": "rampUpTimeSec", + "type": "number", + "label": "启动时长(秒)", + "defaultValue": 0, + "optional": true + }, + { + "name": "resultsSummary", + "type": "textarea", + "label": "测试结果摘要", + "readonly": true, + "rows": 4 + }, + { + "name": "detailedResults", + "type": "hidden", + "defaultValue": null + } + ] + }, + "httpLatencyTest": { + "name": "HTTP 延迟测试", + "category": "perform", + "color": "#b37feb", + "icon_path": "/assets/icons/latency.svg", + "shape": "rect", + "width": 180, + "height": 50, + "ports": [ + "top", + "right", + "bottom", + "left" + ], + "metaFields": [ + { + "name": "endpointId", + "type": "hidden", + "defaultValue": "" + }, + { + "name": "method", + "type": "select", + "label": "请求方法", + "options": [ + "GET", + "POST", + "PUT", + "DELETE", + "PATCH" + ], + "defaultValue": "GET" + }, + { + "name": "path", + "type": "string", + "label": "请求路径" + }, + { + "name": "params", + "type": "json", + "label": "查询参数", + "defaultValue": "{}" + }, + { + "name": "headers", + "type": "json", + "label": "请求头", + "defaultValue": "{\"Content-Type\": \"application/json\"}" + }, + { + "name": "body", + "type": "json", + "label": "请求体", + "defaultValue": "{\"type\": \"json\", \"content\": \"{}\"}" + }, + { + "name": "iterations", + "type": "number", + "label": "迭代次数", + "defaultValue": 10, + "description": "连续发送请求的次数" + }, + { + "name": "delayBetweenMs", + "type": "number", + "label": "迭代间隔(ms)", + "defaultValue": 0, + "optional": true, + "description": "每次请求之间的等待时间" + }, + { + "name": "latencyStats", + "type": "textarea", + "label": "延迟统计", + "readonly": true, + "rows": 4, + "description": "例如: Min, Max, Avg, P95, P99, Errors" + }, + { + "name": "detailedLatencies", + "type": "hidden", + "defaultValue": null + } + ] + }, + "securityScanTrigger": { + "name": "安全扫描", + "category": "safety", + "color": "#f5222d", + "icon_path": "/assets/icons/safety.svg", + "shape": "rect", + "width": 180, + "height": 50, + "ports": [ + "top", + "right", + "bottom", + "left" + ], + "metaFields": [ + { + "name": "scanTarget", + "type": "string", + "label": "扫描目标", + "description": "例如 URL, IP, 服务名" + }, + { + "name": "scanProfile", + "type": "select", + "label": "扫描配置/策略", + "options": [ + "快速扫描", + "全面扫描", + "OWASP Top10" + ], + "defaultValue": "快速扫描" + }, + { + "name": "apiKeyRef", + "type": "string", + "label": "扫描工具API密钥引用", + "optional": true, + "description": "引用环境变量或配置" + }, + { + "name": "waitForResult", + "type": "boolean", + "label": "等待扫描结果", + "defaultValue": false + }, + { + "name": "scanId", + "type": "string", + "label": "扫描任务ID", + "readonly": true + }, + { + "name": "scanStatus", + "type": "string", + "label": "扫描状态", + "readonly": true + }, + { + "name": "scanResultSummary", + "type": "textarea", + "label": "扫描结果摘要", + "readonly": true, + "rows": 3 + }, + { + "name": "detailedScanResult", + "type": "hidden" + } + ] + }, + "authCheck": { + "name": "认证授权检查", + "category": "safety", + "color": "#faad14", + "icon_path": "/assets/icons/auth-check.svg", + "shape": "rect", + "width": 170, + "height": 50, + "ports": [ + "top", + "right", + "bottom", + "left" + ], + "metaFields": [ + { + "name": "endpointInfo", + "type": "json", + "label": "目标接口信息", + "defaultValue": "{\"method\":\"GET\", \"path\":\"/api/resource\"}" + }, + { + "name": "credentials", + "type": "json", + "label": "使用的凭证", + "defaultValue": "{\"type\":\"token\", \"value\":\"{input.token}\"}", + "description": "支持从输入引用值" + }, + { + "name": "expectedStatus", + "type": "select", + "label": "预期HTTP状态", + "options": [ + "2xx (成功)", + "401 (未授权)", + "403 (禁止访问)", + "其他" + ], + "defaultValue": "2xx (成功)" + }, + { + "name": "customExpectedStatus", + "type": "number", + "label": "预期状态码(其他)", + "optional": true + }, + { + "name": "checkResult", + "type": "string", + "label": "检查结果", + "readonly": true + }, + { + "name": "responseDetails", + "type": "hidden" + } + ] + }, + "dataMaskingCheck": { + "name": "数据脱敏检查", + "category": "compliance", + "color": "#595959", + "icon_path": "/assets/icons/masking-check.svg", + "shape": "rect", + "width": 160, + "height": 50, + "ports": [ + "top", + "right", + "bottom", + "left" + ], + "metaFields": [ + { + "name": "dataFieldPath", + "type": "string", + "label": "检查字段路径", + "defaultValue": "body.creditCard", + "description": "使用点标记法指定字段" + }, + { + "name": "expectedFormat", + "type": "string", + "label": "预期脱敏格式", + "defaultValue": "**** **** **** 1234", + "description": "用*表示脱敏位" + }, + { + "name": "checkResult", + "type": "string", + "label": "检查结果", + "readonly": true + }, + { + "name": "actualValue", + "type": "hidden" + } + ] + }, + "failureInjection": { + "name": "故障注入", + "category": "reliability", + "color": "#cf1322", + "icon_path": "/assets/icons/chaos.svg", + "shape": "rect", + "width": 160, + "height": 50, + "ports": [ + "top", + "right", + "bottom", + "left" + ], + "metaFields": [ + { + "name": "targetResource", + "type": "string", + "label": "注入目标资源", + "description": "e.g., service:my-app, pod:app-*, db:orders" + }, + { + "name": "failureType", + "type": "select", + "label": "故障类型", + "options": [ + "latency", + "error", + "crash", + "network_loss", + "resource_limit" + ] + }, + { + "name": "magnitude", + "type": "string", + "label": "故障程度", + "description": "e.g., latency: 500ms, error: 503, network_loss: 80%" + }, + { + "name": "durationSec", + "type": "number", + "label": "持续时间(秒)", + "defaultValue": 60 + }, + { + "name": "injectionStatus", + "type": "string", + "label": "注入状态", + "readonly": true + }, + { + "name": "details", + "type": "hidden" + } + ] + }, + "agentBrowserTask": { + "name": "Agent UI任务", + "category": "ui", + "color": "#08979c", + "icon_path": "/assets/icons/agent.svg", + "shape": "rect", + "width": 180, + "height": 60, + "ports": [ + "top", + "right", + "bottom", + "left" + ], + "metaFields": [ + { + "name": "targetUrl", + "type": "string", + "label": "目标页面URL" + }, + { + "name": "taskPrompt", + "type": "textarea", + "label": "任务指令 (Prompt)", + "rows": 5, + "description": "给 Agent 的详细操作指令" + }, + { + "name": "agentModel", + "type": "string", + "label": "Agent模型/配置", + "optional": true, + "defaultValue": "default_browser_agent" + }, + { + "name": "inputDataUsage", + "type": "json", + "label": "输入数据使用方式", + "optional": true, + "defaultValue": "{\"map\": [{\"from\": \"input.username\", \"to\": \"form.user\"}, {\"from\": \"input.password\", \"to\": \"form.pass\"}]}", + "description": "定义如何将输入映射到表单或Prompt" + }, + { + "name": "validationCriteria", + "type": "textarea", + "label": "成功/失败标准", + "optional": true, + "description": "如何判断 Agent 执行结果是否符合预期" + }, + { + "name": "executionResult", + "type": "string", + "label": "执行结果", + "readonly": true + }, + { + "name": "agentLog", + "type": "textarea", + "label": "Agent 执行日志", + "readonly": true, + "rows": 4 + }, + { + "name": "screenshotUrl", + "type": "string", + "label": "截图链接", + "readonly": true, + "optional": true + }, + { + "name": "extractedData", + "type": "hidden" + } + ] + }, + "delayNode": { + "name": "延迟等待", + "category": "logic", + "color": "#bfbfbf", + "icon_path": "/assets/icons/delay.svg", + "shape": "circle", + "width": 60, + "height": 60, + "ports": [ + "top", + "right", + "bottom", + "left" + ], + "metaFields": [ + { + "name": "delayMs", + "type": "number", + "label": "延迟时间 (ms)", + "defaultValue": 1000 + } + ] + } +} \ No newline at end of file diff --git a/cli/db.go b/cli/db.go new file mode 100644 index 0000000..09c05e8 --- /dev/null +++ b/cli/db.go @@ -0,0 +1,29 @@ +// +// Copyright (C) 2024 veypi +// 2025-02-27 19:32:09 +// Distributed under terms of the MIT license. +// + +package main + +import "app/cfg" + +var cmdDB = CMD.SubCommand("db", "database operations") +var cmdMigrate = cmdDB.SubCommand("migrate", "migrate database") + +func init() { + cmdMigrate.Command = func() error { + // create table without constraints + cfg.DB().DisableForeignKeyConstraintWhenMigrating = true + err := cfg.DB().AutoMigrate(cfg.ObjList...) + if err != nil { + return err + } + // create constraints + cfg.DB().DisableForeignKeyConstraintWhenMigrating = false + return cfg.DB().AutoMigrate(cfg.ObjList...) + } + cmdDB.SubCommand("drop", "drop database").Command = func() error { + return cfg.DB().Migrator().DropTable(cfg.ObjList...) + } +} diff --git a/cli/main.go b/cli/main.go new file mode 100644 index 0000000..ee66549 --- /dev/null +++ b/cli/main.go @@ -0,0 +1,100 @@ +// +// main.go +// Copyright (C) 2024 veypi +// 2025-02-27 19:32:09 +// Distributed under terms of the MIT license. +// + +package main + +import ( + "app/api" + "app/api/project/stage" + "app/cfg" + "fmt" + "log" + "time" + + "github.com/nats-io/nats-server/v2/server" + "github.com/nats-io/nats.go" + "github.com/veypi/OneBD/rest" + "github.com/veypi/utils/flags" + "github.com/veypi/utils/logv" +) + +func main() { + opts := &server.Options{ + Host: "127.0.0.1", + Port: 4222, + NoLog: false, + NoSigs: false, + HTTPPort: 8222, // 监控端口 + Websocket: server.WebsocketOpts{ + Port: 9222, // 使用相同的端口 + Host: "127.0.0.1", // WebSocket 主机 + NoTLS: true, // 非 TLS WebSocket + }, + } + + // 创建并启动服务器 + natsServer, err := server.NewServer(opts) + if err != nil { + log.Fatalf("无法创建 NATS 服务器: %v", err) + } + + // 启动服务器 (以 goroutine 方式运行) + go natsServer.Start() + + // 等待服务器启动 + if !natsServer.ReadyForConnections(5 * time.Second) { + log.Fatal("NATS 服务器无法在指定时间内启动") + } + + fmt.Println("NATS 服务器已启动") + + // 示例:连接到刚刚启动的 NATS 服务器 + nc, err := nats.Connect("nats://127.0.0.1:4222") + if err != nil { + log.Fatalf("无法连接到 NATS: %v", err) + } + defer nc.Close() + + nc1, err := nats.Connect("ws://127.0.0.1:9222") + if err != nil { + log.Fatalf("无法连接到 ws: %v", err) + } + defer nc1.Close() + + // 现在可以使用 nc 发布和订阅消息 + fmt.Println("已成功连接到 NATS 服务器") + + if err := stage.InitGraphProcessor(); err != nil { + log.Fatalf("Stage 图生成处理器初始化失败: %v", err) + } + log.Println("Stage 图生成处理器初始化成功.") + + var CMD = flags.New("app", "the backend server of app") + + CMD.Command = runWeb + + CMD.Parse() + err = CMD.Run() + if err != nil { + logv.Warn().Msg(err.Error()) + } +} + +func runWeb() error { + server, err := rest.New(rest.WithHost(cfg.Config.Host), rest.WithPort(cfg.Config.Port)) + if err != nil { + return err + } + // server.SetRouter(app.Router) + // server.Router().Print() + // return server.Run() + + server.Router().Extend("api", api.Router) + + server.Router().Print() + return server.Run() +} diff --git a/cli/migrations/migrations.go b/cli/migrations/migrations.go new file mode 100644 index 0000000..e0130d9 --- /dev/null +++ b/cli/migrations/migrations.go @@ -0,0 +1,36 @@ +package main + +import ( + "app/cfg" + "app/models" + "app/models/project" + "app/models/project/stage" + "app/models/project/stage/tools" +) + +// Run executes all pending migrations +func main() { + // 初始化数据库连接 + var db = cfg.DB() + + // 在这里添加所有的迁移 + migrations := []interface{}{ + models.Stage{}, + models.GraphLink{}, + models.GraphNode{}, + models.Tree{}, + models.Endpoint{}, + models.Doc{}, + models.Project{}, + models.Response{}, + models.Demand{}, + project.Demand{}, + project.Stage{}, + stage.GraphLink{}, + stage.GraphNode{}, + tools.Endpoint{}, + } + + // 执行迁移 + var _ = db.AutoMigrate(migrations...) +} diff --git a/event.nats.md b/event.nats.md new file mode 100644 index 0000000..e69de29 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..027047d --- /dev/null +++ b/go.mod @@ -0,0 +1,98 @@ +module app + +go 1.23.2 + +require ( + github.com/glebarez/sqlite v1.11.0 + github.com/google/generative-ai-go v0.19.0 + github.com/google/uuid v1.6.0 + github.com/invopop/jsonschema v0.13.0 + github.com/ledongthuc/pdf v0.0.0-20240201131950-da5b75280b06 + github.com/nats-io/nats-server/v2 v2.11.0 + github.com/nats-io/nats.go v1.39.1 + github.com/openai/openai-go v0.1.0-alpha.62 + github.com/unidoc/unioffice v1.39.0 + github.com/veypi/OneBD v0.6.1 + github.com/veypi/utils v0.4.2 + github.com/xeipuuv/gojsonschema v1.2.0 + google.golang.org/api v0.223.0 + gorm.io/driver/mysql v1.5.7 + gorm.io/driver/postgres v1.5.11 + gorm.io/gorm v1.25.12 +) + +require ( + cloud.google.com/go v0.115.0 // indirect + cloud.google.com/go/ai v0.8.0 // indirect + cloud.google.com/go/auth v0.15.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.7 // indirect + cloud.google.com/go/compute/metadata v0.6.0 // indirect + cloud.google.com/go/longrunning v0.5.7 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-sql-driver/mysql v1.7.0 // indirect + github.com/google/go-tpm v0.9.3 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect + github.com/googleapis/gax-go/v2 v2.14.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.5.5 // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/minio/highwayhash v1.0.3 // indirect + github.com/nats-io/jwt/v2 v2.7.3 // indirect + github.com/nats-io/nkeys v0.4.10 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/richardlehane/msoleps v1.0.3 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/rs/zerolog v1.17.2 // indirect + github.com/tidwall/gjson v1.14.4 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect + go.opentelemetry.io/otel v1.34.0 // indirect + go.opentelemetry.io/otel/metric v1.34.0 // indirect + go.opentelemetry.io/otel/trace v1.34.0 // indirect + golang.org/x/crypto v0.36.0 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/oauth2 v0.26.0 // indirect + golang.org/x/sync v0.12.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect + golang.org/x/time v0.11.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250219182151-9fdb1cabc7b2 // indirect + google.golang.org/grpc v1.70.0 // indirect + google.golang.org/protobuf v1.36.5 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect +) + +replace github.com/veypi/OneBD => ../../OneBD/ + +replace github.com/veypi/OneAuth => ./OneAuth/new/ + +replace github.com/veypi/vyes-ui => ./vyes-ui/ + +replace github.com/veypi/utils => ../../utils/ diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b32a738 --- /dev/null +++ b/go.sum @@ -0,0 +1,211 @@ +cloud.google.com/go v0.115.0 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14= +cloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU= +cloud.google.com/go/ai v0.8.0 h1:rXUEz8Wp2OlrM8r1bfmpF2+VKqc1VJpafE3HgzRnD/w= +cloud.google.com/go/ai v0.8.0/go.mod h1:t3Dfk4cM61sytiggo2UyGsDVW3RF1qGZaUKDrZFyqkE= +cloud.google.com/go/auth v0.15.0 h1:Ly0u4aA5vG/fsSsxu98qCQBemXtAtJf+95z9HK+cxps= +cloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQNVXLZ8= +cloud.google.com/go/auth/oauth2adapt v0.2.7 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M= +cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc= +cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I= +cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= +cloud.google.com/go/longrunning v0.5.7 h1:WLbHekDbjK1fVFD3ibpFFVoyizlLRl73I7YKuAKilhU= +cloud.google.com/go/longrunning v0.5.7/go.mod h1:8GClkudohy1Fxm3owmBGid8W0pSgodEMwEAztp38Xng= +github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= +github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/antithesishq/antithesis-sdk-go v0.4.3-default-no-op h1:+OSa/t11TFhqfrX0EOSqQBDJ0YlpmK0rDSiB19dg9M0= +github.com/antithesishq/antithesis-sdk-go v0.4.3-default-no-op/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/generative-ai-go v0.19.0 h1:R71szggh8wHMCUlEMsW2A/3T+5LdEIkiaHSYgSpUgdg= +github.com/google/generative-ai-go v0.19.0/go.mod h1:JYolL13VG7j79kM5BtHz4qwONHkeJQzOCkKXnpqtS/E= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-tpm v0.9.3 h1:+yx0/anQuGzi+ssRqeD6WpXjW2L/V0dItUayO0i9sRc= +github.com/google/go-tpm v0.9.3/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw= +github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= +github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q= +github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= +github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/ledongthuc/pdf v0.0.0-20240201131950-da5b75280b06 h1:kacRlPN7EN++tVpGUorNGPn/4DnB7/DfTY82AOn6ccU= +github.com/ledongthuc/pdf v0.0.0-20240201131950-da5b75280b06/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/minio/highwayhash v1.0.3 h1:kbnuUMoHYyVl7szWjSxJnxw11k2U709jqFPPmIUyD6Q= +github.com/minio/highwayhash v1.0.3/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= +github.com/nats-io/jwt/v2 v2.7.3 h1:6bNPK+FXgBeAqdj4cYQ0F8ViHRbi7woQLq4W29nUAzE= +github.com/nats-io/jwt/v2 v2.7.3/go.mod h1:GvkcbHhKquj3pkioy5put1wvPxs78UlZ7D/pY+BgZk4= +github.com/nats-io/nats-server/v2 v2.11.0 h1:fdwAT1d6DZW/4LUz5rkvQUe5leGEwjjOQYntzVRKvjE= +github.com/nats-io/nats-server/v2 v2.11.0/go.mod h1:leXySghbdtXSUmWem8K9McnJ6xbJOb0t9+NQ5HTRZjI= +github.com/nats-io/nats.go v1.39.1 h1:oTkfKBmz7W047vRxV762M67ZdXeOtUgvbBaNoQ+3PPk= +github.com/nats-io/nats.go v1.39.1/go.mod h1:MgRb8oOdigA6cYpEPhXJuRVH6UE/V4jblJ2jQ27IXYM= +github.com/nats-io/nkeys v0.4.10 h1:glmRrpCmYLHByYcePvnTBEAwawwapjCPMjy2huw20wc= +github.com/nats-io/nkeys v0.4.10/go.mod h1:OjRrnIKnWBFl+s4YK5ChQfvHP2fxqZexrKJoVVyWB3U= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/openai/openai-go v0.1.0-alpha.62 h1:wf1Z+ZZAlqaUBlxhE5rhXxc9hQylcDRgMU2fg+jME+E= +github.com/openai/openai-go v0.1.0-alpha.62/go.mod h1:3SdE6BffOX9HPEQv8IL/fi3LYZ5TUpRYaqGQZbyk11A= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/richardlehane/msoleps v1.0.3 h1:aznSZzrwYRl3rLKRT3gUk9am7T/mLNSnJINvN0AQoVM= +github.com/richardlehane/msoleps v1.0.3/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= +github.com/rs/zerolog v1.17.2 h1:RMRHFw2+wF7LO0QqtELQwo8hqSmqISyCJeFeAAuWcRo= +github.com/rs/zerolog v1.17.2/go.mod h1:9nvC1axdVrAHcu/s9taAVfBuIdTZLVQmKQyvrUjF5+I= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= +github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/unidoc/unioffice v1.39.0 h1:Wo5zvrzCqhyK/1Zi5dg8a5F5+NRftIMZPnFPYwruLto= +github.com/unidoc/unioffice v1.39.0/go.mod h1:Axz6ltIZZTUUyHoEnPe4Mb3VmsN4TRHT5iZCGZ1rgnU= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 h1:rgMkmiGfix9vFJDcDi1PK8WEQP4FLQwLDfhp5ZLpFeE= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0/go.mod h1:ijPqXp5P6IRRByFVVg9DY8P5HkxkHE5ARIa+86aXPf4= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I= +go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= +go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= +go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= +go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= +go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= +go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= +go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU= +go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ= +go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= +go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/oauth2 v0.26.0 h1:afQXWNNaeC4nvZ0Ed9XvCCzXM6UHJG7iCg0W4fPqSBE= +golang.org/x/oauth2 v0.26.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= +golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.0.0-20190828213141-aed303cbaa74/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.223.0 h1:JUTaWEriXmEy5AhvdMgksGGPEFsYfUKaPEYXd4c3Wvc= +google.golang.org/api v0.223.0/go.mod h1:C+RS7Z+dDwds2b+zoAk5hN/eSfsiCn0UDrYof/M4d2M= +google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q= +google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250219182151-9fdb1cabc7b2 h1:DMTIbak9GhdaSxEjvVzAeNZvyc03I61duqNbnm3SU0M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250219182151-9fdb1cabc7b2/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I= +google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ= +google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8= +gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= +gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= +gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314= +gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= +gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= +gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= diff --git a/init.go b/init.go new file mode 100644 index 0000000..72d68e9 --- /dev/null +++ b/init.go @@ -0,0 +1,29 @@ +// +// Copyright (C) 2025 veypi +// 2025-02-27 19:32:09 +// Distributed under terms of the MIT license. +// + +package app + +import ( + "app/api" + "embed" + + "github.com/veypi/OneBD/rest" + "github.com/veypi/OneBD/rest/middlewares" +) + +var Router = rest.NewRouter() + +//go:embed ui/* +var uifs embed.FS + +//go:embed ui/root.html +var rootFile []byte + +var ( + _ = Router.Extend("api", api.Router) + _ = Router.Get("/ui/*path", middlewares.EmbedDir(uifs, "ui", "")) + _ = Router.Get("/*path", middlewares.EmbedFile(rootFile, "text/html")) +) diff --git a/models/demand.go b/models/demand.go new file mode 100644 index 0000000..ddad80c --- /dev/null +++ b/models/demand.go @@ -0,0 +1,16 @@ +package models + +type Demand struct { + Tree + DocID string `json:"doc_id" gorm:"column:doc_id;type:varchar(255);not null"` + Description string `json:"description" gorm:"column:description;type:text"` + ReqID string `json:"req_id" gorm:"column:req_id;type:varchar(50)"` + ParentReqID string `json:"parent_req_id" gorm:"column:parent_req_id;type:varchar(50)"` + Priority string `json:"priority" gorm:"column:priority;type:varchar(20)"` + Type string `json:"type" gorm:"column:type;type:varchar(50)"` + Status string `json:"status" gorm:"column:status;type:varchar(20)"` +} + +func (Demand) TableName() string { + return "demands" +} diff --git a/models/doc.go b/models/doc.go new file mode 100644 index 0000000..0ea72cb --- /dev/null +++ b/models/doc.go @@ -0,0 +1,27 @@ +package models + +import "app/cfg" + +type Doc struct { + BaseModel + ProjectID string `json:"project_id" gorm:"column:project_id;type:varchar(255);not null"` + Name string `json:"name" gorm:"column:name;type:varchar(255);not null"` + Type string `json:"type" gorm:"column:type;type:varchar(50);not null"` + Description string `json:"description" gorm:"column:description;type:text"` + FilePath string `json:"file_path" gorm:"column:file_path;type:varchar(255)"` + FileType string `json:"file_type" gorm:"column:file_type;type:varchar(50)"` + FileSize int64 `json:"file_size" gorm:"column:file_size;type:bigint"` + FileName string `json:"file_name" gorm:"column:file_name;type:varchar(255)"` + AnalysisCompleted bool `json:"analysis_completed" gorm:"column:analysis_completed;type:boolean;default:false"` + AnalysisPercent int `json:"analysis_percent" gorm:"column:analysis_percent;type:int;default:0"` + AnalysisError string `json:"analysis_error" gorm:"column:analysis_error;type:text"` + Merged bool `json:"merged" gorm:"column:merged;type:boolean;default:false"` +} + +func GetProjectIDByDocID(docID string) (string, error) { + var doc Doc + if err := cfg.DB().Where("id = ?", docID).First(&doc).Error; err != nil { + return "", err + } + return doc.ProjectID, nil +} diff --git a/models/endpoint.go b/models/endpoint.go new file mode 100644 index 0000000..679b2c0 --- /dev/null +++ b/models/endpoint.go @@ -0,0 +1,13 @@ +package models + +type Endpoint struct { + BaseModel + DocID string `json:"doc_id" gorm:"column:doc_id;type:varchar(255);not null"` + Name string `json:"name" gorm:"column:name;type:varchar(255);not null"` + Path string `json:"path" gorm:"column:path;type:varchar(255);not null"` + Method string `json:"method" gorm:"column:method;type:varchar(20);not null"` + Description string `json:"description" gorm:"column:description;type:text"` + BodyType string `json:"body_type" gorm:"column:body_type;type:varchar(50)"` + Node string `json:"node" gorm:"column:node;type:text"` + Merged bool `json:"merged" gorm:"column:merged;type:boolean;default:false"` +} diff --git a/models/graph_link.go b/models/graph_link.go new file mode 100644 index 0000000..22037e5 --- /dev/null +++ b/models/graph_link.go @@ -0,0 +1,27 @@ +package models + +import ( + "gorm.io/gorm" +) + +type GraphLink struct { + BaseModel + StageID string `json:"stage_id" gorm:"column:stage_id;type:varchar(255);not null"` + SourceID string `json:"source_id" gorm:"column:source_id;type:varchar(255);not null"` + TargetID string `json:"target_id" gorm:"column:target_id;type:varchar(255);not null"` + LinkID string `json:"link_id" gorm:"column:link_id;type:varchar(255);not null"` +} + +// 获取前置节点 +func (gl *GraphLink) SourceNode(db *gorm.DB) (*GraphNode, error) { + var node GraphNode + err := db.Where("id = ?", gl.SourceID).First(&node).Error + return &node, err +} + +// 获取后置节点 +func (gl *GraphLink) TargetNode(db *gorm.DB) (*GraphNode, error) { + var node GraphNode + err := db.Where("id = ?", gl.TargetID).First(&node).Error + return &node, err +} diff --git a/models/graph_node.go b/models/graph_node.go new file mode 100644 index 0000000..a1341d8 --- /dev/null +++ b/models/graph_node.go @@ -0,0 +1,33 @@ +package models + +import ( + "gorm.io/gorm" +) + +type GraphNode struct { + BaseModel + TreeID string `json:"tree_id" gorm:"column:tree_id;type:varchar(255);not null"` + StageID string `json:"stage_id" gorm:"column:stage_id;type:varchar(255);not null"` + InstanceID string `json:"instance_id" gorm:"column:instance_id;type:varchar(255);not null"` + X float64 `json:"x" gorm:"column:x;default:0"` + Y float64 `json:"y" gorm:"column:y;default:0"` + FX float64 `json:"fx" gorm:"column:fx;default:0"` + FY float64 `json:"fy" gorm:"column:fy;default:0"` + Name string `json:"name" gorm:"column:name;type:varchar(255)"` + Level int `json:"level" gorm:"column:level;default:0"` + ParentID string `json:"parent_id" gorm:"column:parent_id;type:varchar(255)"` +} + +// 获取关联的树节点 +func (gn *GraphNode) Tree(db *gorm.DB) (*Tree, error) { + var tree Tree + err := db.Where("id = ?", gn.TreeID).First(&tree).Error + return &tree, err +} + +// 获取关联的所有连接 +func (gn *GraphNode) Links(db *gorm.DB) ([]GraphLink, error) { + var links []GraphLink + err := db.Where("source_id = ? OR target_id = ?", gn.ID, gn.ID).Find(&links).Error + return links, err +} diff --git a/models/init.go b/models/init.go new file mode 100644 index 0000000..79a4f99 --- /dev/null +++ b/models/init.go @@ -0,0 +1,29 @@ +// +// Copyright (C) 2024 veypi +// 2025-02-27 19:32:09 +// Distributed under terms of the MIT license. +// + +package models + +import ( + "github.com/google/uuid" + "gorm.io/gorm" + "strings" + "time" +) + +type BaseModel struct { + // ID uint `json:"id" gorm:"primaryKey" methods:"get,patch,delete" parse:"path"` + ID string `json:"id" gorm:"primaryKey;type:varchar(32)" methods:"get,put,patch,delete" parse:"path"` + CreatedAt time.Time `json:"created_at" methods:"*list" parse:"query"` + UpdatedAt time.Time `json:"updated_at" methods:"*list" parse:"query"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +func (m *BaseModel) BeforeCreate(tx *gorm.DB) error { + if m.ID == "" { + m.ID = strings.ReplaceAll(uuid.New().String(), "-", "") + } + return nil +} diff --git a/models/node.go b/models/node.go new file mode 100644 index 0000000..8ffbb40 --- /dev/null +++ b/models/node.go @@ -0,0 +1,2 @@ +package models +// json parameters \ No newline at end of file diff --git a/models/node_type.go b/models/node_type.go new file mode 100644 index 0000000..74299dc --- /dev/null +++ b/models/node_type.go @@ -0,0 +1,2 @@ +package models +// args default value \ No newline at end of file diff --git a/models/parameter.go b/models/parameter.go new file mode 100644 index 0000000..8f2ebc1 --- /dev/null +++ b/models/parameter.go @@ -0,0 +1,13 @@ +package models + +type Parameter struct { + BaseModel + EndpointID string `json:"endpoint_id" gorm:"column:endpoint_id;type:varchar(255);not null"` + Name string `json:"name" gorm:"column:name;type:varchar(255);not null"` + Type string `json:"type" gorm:"column:type;type:varchar(50);not null"` // query, header, body + ParamType string `json:"param_type" gorm:"column:param_type;type:varchar(50)"` // string, number, boolean + Required bool `json:"required" gorm:"column:required;type:boolean;default:false"` + Description string `json:"description" gorm:"column:description;type:text"` + Example string `json:"example" gorm:"column:example;type:text"` + Value string `json:"value" gorm:"column:value;type:text"` // 调试值 +} \ No newline at end of file diff --git a/models/project.go b/models/project.go new file mode 100644 index 0000000..523e027 --- /dev/null +++ b/models/project.go @@ -0,0 +1,8 @@ +package models + +type Project struct { + BaseModel + Name string `json:"name" gorm:"column:name;type:varchar(255);not null;unique"` + Description string `json:"description" gorm:"column:description;type:text"` +} + diff --git a/models/project/demand.go b/models/project/demand.go new file mode 100644 index 0000000..78564ba --- /dev/null +++ b/models/project/demand.go @@ -0,0 +1,51 @@ +package project + +import ( + "app/models" + + "gorm.io/gorm" +) + +type Demand struct { + models.Tree + DocID string `json:"doc_id" gorm:"column:doc_id;type:varchar(255);default:''"` + ProjectID string `json:"project_id" gorm:"column:project_id;type:varchar(255);not null"` + Description string `json:"description" gorm:"column:description;type:text"` + ReqID string `json:"req_id" gorm:"column:req_id;type:varchar(50)"` + ParentReqID string `json:"parent_req_id" gorm:"column:parent_req_id;type:varchar(50)"` + Priority string `json:"priority" gorm:"column:priority;type:varchar(20)"` + Type string `json:"type" gorm:"column:type;type:varchar(50)"` + Status string `json:"status" gorm:"column:status;type:varchar(20)"` +} + +func (Demand) TableName() string { + return "project_demands" +} + +func (t *Demand) Descendants(db *gorm.DB) ([]Demand, error) { + var descendants []Demand + + // 使用GORM递归获取所有后代节点 + var getAllDescendants func(parentID string) error + getAllDescendants = func(parentID string) error { + var children []Demand + if err := db.Where("parent_id = ?", parentID).Find(&children).Error; err != nil { + return err + } + + for _, child := range children { + descendants = append(descendants, child) + if err := getAllDescendants(child.ID); err != nil { + return err + } + } + return nil + } + + // 从当前节点开始递归 + if err := getAllDescendants(t.ID); err != nil { + return nil, err + } + + return descendants, nil +} diff --git a/models/project/stage.go b/models/project/stage.go new file mode 100644 index 0000000..f1ae3df --- /dev/null +++ b/models/project/stage.go @@ -0,0 +1,51 @@ +package project + +import ( + M "app/models" + + "gorm.io/gorm" +) + +type Stage struct { + M.BaseModel + ProjectID string `json:"project_id" gorm:"type:varchar(32);not null"` + Name string `json:"name" gorm:"type:varchar(255);not null"` + DemandID string `json:"demand_id" gorm:"column:tree_id;type:varchar(32)"` + GraphStatus string `json:"graph_status" gorm:"type:varchar(50);default:'';index"` // 状态: pending, processing, completed, failed + GraphError string `json:"graph_error" gorm:"type:text"` +} + +func (Stage) TableName() string { + return "project_stages" +} +func (s *Stage) AvailableNodes(db *gorm.DB) ([]Demand, error) { + // 如果没有指定树节点,返回空列表 + if s.DemandID == "" { + return []Demand{}, nil + } + + // 如果是虚拟根节点,返回所有树节点 + if s.DemandID == "root" { + var allNodes []Demand + if err := db.Find(&allNodes).Error; err != nil { + return nil, err + } + return allNodes, nil + } + + // 获取指定的树节点 + var tree Demand + if err := db.Where("id = ?", s.DemandID).First(&tree).Error; err != nil { + return nil, err + } + + // 获取该树节点及其所有子孙节点 + descendants, err := tree.Descendants(db) + if err != nil { + return nil, err + } + + // 将当前节点也添加到可用节点列表中 + result := append([]Demand{tree}, descendants...) + return result, nil +} diff --git a/models/project/stage/graph_link.go b/models/project/stage/graph_link.go new file mode 100644 index 0000000..2c0fe09 --- /dev/null +++ b/models/project/stage/graph_link.go @@ -0,0 +1,34 @@ +package stage + +import ( + M "app/models" + + "gorm.io/gorm" +) + +type GraphLink struct { + M.BaseModel + StageID string `json:"stage_id" gorm:"column:stage_id;type:varchar(255);not null"` + SourceID string `json:"source_id" gorm:"column:source_id;type:varchar(255);not null"` + TargetID string `json:"target_id" gorm:"column:target_id;type:varchar(255);not null"` + LinkID string `json:"link_id" gorm:"column:link_id;type:varchar(255);not null"` + Meta string `json:"meta" gorm:"column:meta;type:mediumtext"` +} + +func (GraphLink) TableName() string { + return "stage_graph_links" +} + +// 获取前置节点 +func (gl *GraphLink) SourceNode(db *gorm.DB) (*GraphNode, error) { + var node GraphNode + err := db.Where("id = ?", gl.SourceID).First(&node).Error + return &node, err +} + +// 获取后置节点 +func (gl *GraphLink) TargetNode(db *gorm.DB) (*GraphNode, error) { + var node GraphNode + err := db.Where("id = ?", gl.TargetID).First(&node).Error + return &node, err +} diff --git a/models/project/stage/graph_node.go b/models/project/stage/graph_node.go new file mode 100644 index 0000000..7211aa8 --- /dev/null +++ b/models/project/stage/graph_node.go @@ -0,0 +1,54 @@ +package stage + +import ( + M "app/models" + P "app/models/project" + + "gorm.io/gorm" +) + +type GraphNode struct { + M.BaseModel + DemandID string `json:"tree_id" gorm:"column:tree_id;type:varchar(255);not null"` + StageID string `json:"stage_id" gorm:"column:stage_id;type:varchar(255);not null"` + InstanceID string `json:"instance_id" gorm:"column:instance_id;type:varchar(255);not null"` + X float64 `json:"x" gorm:"column:x;default:0"` + Y float64 `json:"y" gorm:"column:y;default:0"` + FX float64 `json:"fx" gorm:"column:fx;default:0"` + FY float64 `json:"fy" gorm:"column:fy;default:0"` + Name string `json:"name" gorm:"column:name;type:varchar(255)"` + Level int `json:"level" gorm:"column:level;default:0"` + ParentID string `json:"parent_id" gorm:"column:parent_id;type:varchar(255)"` + Type string `json:"type" gorm:"column:type;type:varchar(255);default:'node'"` + Meta string `json:"meta" gorm:"column:meta;type:mediumtext"` +} + +func (GraphNode) TableName() string { + return "stage_graph_nodes" +} + +// 获取关联的树节点 +func (gn *GraphNode) Tree(db *gorm.DB) (*P.Demand, error) { + var tree P.Demand + err := db.Where("id = ?", gn.DemandID).First(&tree).Error + return &tree, err +} + +// 获取关联的所有连接 +func (gn *GraphNode) Links(db *gorm.DB) ([]GraphLink, error) { + var links []GraphLink + err := db.Where("source_id = ? OR target_id = ?", gn.ID, gn.ID).Find(&links).Error + return links, err +} +func (gn *GraphNode) IncomingLinks(db *gorm.DB) ([]GraphLink, error) { + var links []GraphLink + err := db.Where("target_id = ?", gn.ID).Find(&links).Error + return links, err +} + +// OutgoingLinks 获取出度连接 +func (gn *GraphNode) OutgoingLinks(db *gorm.DB) ([]GraphLink, error) { + var links []GraphLink + err := db.Where("source_id = ?", gn.ID).Find(&links).Error + return links, err +} diff --git a/models/project/stage/tools/endpoint.go b/models/project/stage/tools/endpoint.go new file mode 100644 index 0000000..b973c01 --- /dev/null +++ b/models/project/stage/tools/endpoint.go @@ -0,0 +1,27 @@ +package tools + +import ( + M "app/models" +) + +type Endpoint struct { + M.BaseModel + StageID string `json:"stage_id" gorm:"column:stage_id;type:varchar(255);"` + ProjectID string `json:"project_id" gorm:"column:project_id;type:varchar(255);"` + DocID string `json:"doc_id" gorm:"column:doc_id;type:varchar(255);"` + + Name string `json:"name" gorm:"column:name;type:varchar(255);"` + Path string `json:"path" gorm:"column:path;type:varchar(255);"` + Method string `json:"method" gorm:"column:method;type:varchar(255);"` + Description string `json:"description" gorm:"column:description;type:varchar(255);"` + + Params string `json:"params" gorm:"column:params;type:mediumtext;"` + Headers string `json:"headers" gorm:"column:headers;type:mediumtext;"` + Body string `json:"body" gorm:"column:body;type:mediumtext;"` + Response string `json:"response" gorm:"column:response;type:mediumtext;"` + Merged bool `json:"merged" gorm:"column:merged;type:bool;"` +} + +func (Endpoint) TableName() string { + return "stage_tool_endpoints" +} diff --git a/models/response.go b/models/response.go new file mode 100644 index 0000000..0da47dc --- /dev/null +++ b/models/response.go @@ -0,0 +1,12 @@ +package models + +type Response struct { + BaseModel + EndpointID string `json:"endpoint_id" gorm:"column:endpoint_id;type:varchar(255);not null"` + Name string `json:"name" gorm:"column:name;type:varchar(255);not null"` + StatusCode int `json:"status_code" gorm:"column:status_code;type:int;not null"` + ContentType string `json:"content_type" gorm:"column:content_type;type:varchar(100)"` + Description string `json:"description" gorm:"column:description;type:text"` + Example string `json:"example" gorm:"column:example;type:text"` + Content string `json:"content" gorm:"column:content;type:text"` // 实际响应内容 +} diff --git a/models/stage.go b/models/stage.go new file mode 100644 index 0000000..239037b --- /dev/null +++ b/models/stage.go @@ -0,0 +1,45 @@ +package models + +import ( + "gorm.io/gorm" +) + +type Stage struct { + BaseModel + ProjectID string `json:"project_id" gorm:"type:varchar(32);not null"` + Name string `json:"name" gorm:"type:varchar(255);not null"` + TreeID string `json:"tree_id" gorm:"column:tree_id;type:varchar(32)"` + DemandID string `json:"demand_id" gorm:"column:tree_id;type:varchar(32)"` +} + +func (s *Stage) AvailableNodes(db *gorm.DB) ([]Tree, error) { + // 如果没有指定树节点,返回空列表 + if s.TreeID == "" { + return []Tree{}, nil + } + + // 如果是虚拟根节点,返回所有树节点 + if s.TreeID == "root" { + var allNodes []Tree + if err := db.Find(&allNodes).Error; err != nil { + return nil, err + } + return allNodes, nil + } + + // 获取指定的树节点 + var tree Tree + if err := db.Where("id = ?", s.TreeID).First(&tree).Error; err != nil { + return nil, err + } + + // 获取该树节点及其所有子孙节点 + descendants, err := tree.Descendants(db) + if err != nil { + return nil, err + } + + // 将当前节点也添加到可用节点列表中 + result := append([]Tree{tree}, descendants...) + return result, nil +} diff --git a/models/tree.go b/models/tree.go new file mode 100644 index 0000000..88b39fd --- /dev/null +++ b/models/tree.go @@ -0,0 +1,64 @@ +package models + +import ( + "gorm.io/gorm" +) + +type Tree struct { + BaseModel + // ID int64 `json:"id" gorm:"column:id;primary_key;type:bigint(20) auto_increment;"` + Name string `json:"name" gorm:"type:varchar(255);not null"` + ParentID string `json:"parent_id" gorm:"column:parent_id; defualt:'-1' "` + Level int `json:"level" gorm:"column:level; default:0 "` +} + +// 获取子节点 +func (t *Tree) Children(db *gorm.DB) ([]Tree, error) { + var children []Tree + err := db.Where("parent_id = ?", t.ID).Find(&children).Error + return children, err +} + +// 获取所有后代节点 +// +// func (t *Tree) Descendants(db *gorm.DB) ([]Tree, error) { +// var descendants []Tree +// err := db.Where("level > ? AND (parent_id = ? OR parent_id IN (SELECT id FROM trees WHERE level > ?))", +// t.Level, t.ID, t.Level).Find(&descendants).Error +// return descendants, err +// } +func (t *Tree) Descendants(db *gorm.DB) ([]Tree, error) { + var descendants []Tree + + // 使用GORM递归获取所有后代节点 + var getAllDescendants func(parentID string) error + getAllDescendants = func(parentID string) error { + var children []Tree + if err := db.Where("parent_id = ?", parentID).Find(&children).Error; err != nil { + return err + } + + for _, child := range children { + descendants = append(descendants, child) + if err := getAllDescendants(child.ID); err != nil { + return err + } + } + return nil + } + + // 从当前节点开始递归 + if err := getAllDescendants(t.ID); err != nil { + return nil, err + } + + return descendants, nil +} + +func (t *Tree) ChildrenWithMinLevel(db *gorm.DB, minLevel int) ([]Tree, error) { + var children []Tree + if err := db.Where("parent_id = ? AND level >= ?", t.ID, minLevel).Find(&children).Error; err != nil { + return nil, err + } + return children, nil +} diff --git a/npminstall-debug.log b/npminstall-debug.log new file mode 100644 index 0000000..c17fbd8 --- /dev/null +++ b/npminstall-debug.log @@ -0,0 +1,202 @@ +{ + root: '/Users/zpc01/workspace/v/test_front', + registry: 'https://registry.npmmirror.com', + pkgs: [], + production: false, + cacheStrict: false, + cacheDir: '/Users/zpc01/.npminstall_tarball', + env: { + npm_config_registry: 'https://registry.npmmirror.com', + npm_config_argv: '{"remain":[],"cooked":["--fix-bug-versions","--china","--userconfig=/Users/zpc01/.cnpmrc","--disturl=https://cdn.npmmirror.com/binaries/node","--registry=https://registry.npmmirror.com","-g","flowise"],"original":["--fix-bug-versions","--china","--userconfig=/Users/zpc01/.cnpmrc","--disturl=https://cdn.npmmirror.com/binaries/node","--registry=https://registry.npmmirror.com","-g","flowise"]}', + npm_config_user_agent: 'npminstall/7.12.0 npm/? node/v23.7.0 darwin arm64', + npm_config_cache: '/Users/zpc01/.npminstall_tarball', + NODE: '/opt/homebrew/Cellar/node/23.7.0/bin/node', + npm_node_execpath: '/opt/homebrew/Cellar/node/23.7.0/bin/node', + npm_execpath: '/opt/homebrew/lib/node_modules/cnpm/node_modules/npminstall/bin/install.js', + npm_config_userconfig: '/Users/zpc01/.cnpmrc', + npm_config_disturl: 'https://cdn.npmmirror.com/binaries/node', + npm_config_r: 'https://registry.npmmirror.com', + COREPACK_NPM_REGISTRY: 'https://registry.npmmirror.com', + EDGEDRIVER_CDNURL: 'https://npmmirror.com/mirrors/edgedriver', + NODEJS_ORG_MIRROR: 'https://cdn.npmmirror.com/binaries/node', + NVM_NODEJS_ORG_MIRROR: 'https://cdn.npmmirror.com/binaries/node', + PHANTOMJS_CDNURL: 'https://cdn.npmmirror.com/binaries/phantomjs', + CHROMEDRIVER_CDNURL: 'https://cdn.npmmirror.com/binaries/chromedriver', + OPERADRIVER_CDNURL: 'https://cdn.npmmirror.com/binaries/operadriver', + CYPRESS_DOWNLOAD_PATH_TEMPLATE: 'https://cdn.npmmirror.com/binaries/cypress/${version}/${platform}-${arch}/cypress.zip', + ELECTRON_MIRROR: 'https://cdn.npmmirror.com/binaries/electron/', + ELECTRON_BUILDER_BINARIES_MIRROR: 'https://cdn.npmmirror.com/binaries/electron-builder-binaries/', + SASS_BINARY_SITE: 'https://cdn.npmmirror.com/binaries/node-sass', + SWC_BINARY_SITE: 'https://cdn.npmmirror.com/binaries/node-swc', + NWJS_URLBASE: 'https://cdn.npmmirror.com/binaries/nwjs/v', + PUPPETEER_DOWNLOAD_HOST: 'https://cdn.npmmirror.com/binaries/chrome-for-testing', + PUPPETEER_DOWNLOAD_BASE_URL: 'https://cdn.npmmirror.com/binaries/chrome-for-testing', + PLAYWRIGHT_DOWNLOAD_HOST: 'https://cdn.npmmirror.com/binaries/playwright', + SENTRYCLI_CDNURL: 'https://cdn.npmmirror.com/binaries/sentry-cli', + SAUCECTL_INSTALL_BINARY_MIRROR: 'https://cdn.npmmirror.com/binaries/saucectl', + RE2_DOWNLOAD_MIRROR: 'https://cdn.npmmirror.com/binaries/node-re2', + RE2_DOWNLOAD_SKIP_PATH: 'true', + PRISMA_ENGINES_MIRROR: 'https://cdn.npmmirror.com/binaries/prisma', + npm_config_better_sqlite3_binary_host: 'https://cdn.npmmirror.com/binaries/better-sqlite3', + npm_config_keytar_binary_host: 'https://cdn.npmmirror.com/binaries/keytar', + npm_config_sharp_binary_host: 'https://cdn.npmmirror.com/binaries/sharp', + npm_config_sharp_libvips_binary_host: 'https://cdn.npmmirror.com/binaries/sharp-libvips', + npm_config_robotjs_binary_host: 'https://cdn.npmmirror.com/binaries/robotjs', + npm_rootpath: '/Users/zpc01/workspace/v/test_front', + INIT_CWD: '/Users/zpc01/workspace/v/test_front' + }, + binaryMirrors: { + ENVS: { + COREPACK_NPM_REGISTRY: 'https://registry.npmmirror.com', + EDGEDRIVER_CDNURL: 'https://npmmirror.com/mirrors/edgedriver', + NODEJS_ORG_MIRROR: 'https://cdn.npmmirror.com/binaries/node', + NVM_NODEJS_ORG_MIRROR: 'https://cdn.npmmirror.com/binaries/node', + PHANTOMJS_CDNURL: 'https://cdn.npmmirror.com/binaries/phantomjs', + CHROMEDRIVER_CDNURL: 'https://cdn.npmmirror.com/binaries/chromedriver', + OPERADRIVER_CDNURL: 'https://cdn.npmmirror.com/binaries/operadriver', + CYPRESS_DOWNLOAD_PATH_TEMPLATE: 'https://cdn.npmmirror.com/binaries/cypress/${version}/${platform}-${arch}/cypress.zip', + ELECTRON_MIRROR: 'https://cdn.npmmirror.com/binaries/electron/', + ELECTRON_BUILDER_BINARIES_MIRROR: 'https://cdn.npmmirror.com/binaries/electron-builder-binaries/', + SASS_BINARY_SITE: 'https://cdn.npmmirror.com/binaries/node-sass', + SWC_BINARY_SITE: 'https://cdn.npmmirror.com/binaries/node-swc', + NWJS_URLBASE: 'https://cdn.npmmirror.com/binaries/nwjs/v', + PUPPETEER_DOWNLOAD_HOST: 'https://cdn.npmmirror.com/binaries/chrome-for-testing', + PUPPETEER_DOWNLOAD_BASE_URL: 'https://cdn.npmmirror.com/binaries/chrome-for-testing', + PLAYWRIGHT_DOWNLOAD_HOST: 'https://cdn.npmmirror.com/binaries/playwright', + SENTRYCLI_CDNURL: 'https://cdn.npmmirror.com/binaries/sentry-cli', + SAUCECTL_INSTALL_BINARY_MIRROR: 'https://cdn.npmmirror.com/binaries/saucectl', + RE2_DOWNLOAD_MIRROR: 'https://cdn.npmmirror.com/binaries/node-re2', + RE2_DOWNLOAD_SKIP_PATH: 'true', + PRISMA_ENGINES_MIRROR: 'https://cdn.npmmirror.com/binaries/prisma', + npm_config_better_sqlite3_binary_host: 'https://cdn.npmmirror.com/binaries/better-sqlite3', + npm_config_keytar_binary_host: 'https://cdn.npmmirror.com/binaries/keytar', + npm_config_sharp_binary_host: 'https://cdn.npmmirror.com/binaries/sharp', + npm_config_sharp_libvips_binary_host: 'https://cdn.npmmirror.com/binaries/sharp-libvips', + npm_config_robotjs_binary_host: 'https://cdn.npmmirror.com/binaries/robotjs' + }, + '@ali/s2': { host: 'https://cdn.npmmirror.com/binaries/looksgood-s2' }, + sharp: { replaceHostFiles: [Array], replaceHostMap: [Object] }, + '@tensorflow/tfjs-node': { + replaceHostFiles: [Array], + replaceHostRegExpMap: [Object], + replaceHostMap: [Object] + }, + cypress: { + host: 'https://cdn.npmmirror.com/binaries/cypress', + newPlatforms: [Object] + }, + 'utf-8-validate': { + host: 'https://cdn.npmmirror.com/binaries/utf-8-validate/v{version}' + }, + xprofiler: { + remote_path: './xprofiler/v{version}/', + host: 'https://cdn.npmmirror.com/binaries' + }, + leveldown: { host: 'https://cdn.npmmirror.com/binaries/leveldown/v{version}' }, + couchbase: { host: 'https://cdn.npmmirror.com/binaries/couchbase/v{version}' }, + gl: { host: 'https://cdn.npmmirror.com/binaries/gl/v{version}' }, + sqlite3: { + host: 'https://cdn.npmmirror.com/binaries/sqlite3', + remote_path: 'v{version}' + }, + '@journeyapps/sqlcipher': { host: 'https://cdn.npmmirror.com/binaries' }, + grpc: { + host: 'https://cdn.npmmirror.com/binaries', + remote_path: '{name}/v{version}' + }, + 'grpc-tools': { host: 'https://cdn.npmmirror.com/binaries' }, + wrtc: { + host: 'https://cdn.npmmirror.com/binaries', + remote_path: '{name}/v{version}' + }, + fsevents: { host: 'https://cdn.npmmirror.com/binaries/fsevents' }, + nodejieba: { host: 'https://cdn.npmmirror.com/binaries/nodejieba' }, + canvas: { + host: 'https://cdn.npmmirror.com/binaries/canvas', + remote_path: 'v{version}' + }, + 'skia-canvas': { host: 'https://cdn.npmmirror.com/binaries/skia-canvas' }, + 'flow-bin': { + replaceHost: 'https://github.com/facebook/flow/releases/download/v', + host: 'https://cdn.npmmirror.com/binaries/flow/v' + }, + 'jpegtran-bin': { + replaceHost: [Array], + host: 'https://cdn.npmmirror.com/binaries/jpegtran-bin' + }, + 'cwebp-bin': { + replaceHost: [Array], + host: 'https://cdn.npmmirror.com/binaries/cwebp-bin' + }, + 'zopflipng-bin': { + replaceHost: [Array], + host: 'https://cdn.npmmirror.com/binaries/zopflipng-bin' + }, + 'optipng-bin': { + replaceHost: [Array], + host: 'https://cdn.npmmirror.com/binaries/optipng-bin' + }, + mozjpeg: { + replaceHost: [Array], + host: 'https://cdn.npmmirror.com/binaries/mozjpeg-bin' + }, + gifsicle: { + replaceHost: [Array], + host: 'https://cdn.npmmirror.com/binaries/gifsicle-bin' + }, + 'pngquant-bin': { + replaceHost: [Array], + host: 'https://cdn.npmmirror.com/binaries/pngquant-bin', + replaceHostMap: [Object] + }, + 'pngcrush-bin': { + replaceHost: [Array], + host: 'https://cdn.npmmirror.com/binaries/pngcrush-bin' + }, + 'jpeg-recompress-bin': { + replaceHost: [Array], + host: 'https://cdn.npmmirror.com/binaries/jpeg-recompress-bin' + }, + 'advpng-bin': { + replaceHost: [Array], + host: 'https://cdn.npmmirror.com/binaries/advpng-bin' + }, + 'pngout-bin': { + replaceHost: [Array], + host: 'https://cdn.npmmirror.com/binaries/pngout-bin' + }, + 'jpegoptim-bin': { + replaceHost: [Array], + host: 'https://cdn.npmmirror.com/binaries/jpegoptim-bin' + }, + argon2: { host: 'https://cdn.npmmirror.com/binaries/argon2' }, + 'ali-zeromq': { host: 'https://cdn.npmmirror.com/binaries/ali-zeromq' }, + 'ali-usb_ctl': { host: 'https://cdn.npmmirror.com/binaries/ali-usb_ctl' }, + 'gdal-async': { host: 'https://cdn.npmmirror.com/binaries/node-gdal-async' }, + 'libpg-query': { host: 'https://cdn.npmmirror.com/binaries' } + }, + forbiddenLicenses: null, + flatten: false, + proxy: undefined, + prune: false, + disableFallbackStore: false, + workspacesMap: Map(0) {}, + enableWorkspace: false, + workspaceRoot: '/Users/zpc01/workspace/v/test_front', + isWorkspaceRoot: true, + isWorkspacePackage: false, + offline: false, + strictSSL: true, + ignoreScripts: false, + foregroundScripts: false, + ignoreOptionalDependencies: false, + detail: true, + forceLinkLatest: false, + trace: false, + engineStrict: false, + registryOnly: false, + client: false, + autoFixVersion: [Function: autoFixVersion], + targetDir: null, + binDir: null +} \ No newline at end of file diff --git a/ui b/ui new file mode 160000 index 0000000..b633a93 --- /dev/null +++ b/ui @@ -0,0 +1 @@ +Subproject commit b633a93d9a4de1d4dcffda2030bcc946e4668438 diff --git a/uploads/docs/.DS_Store b/uploads/docs/.DS_Store new file mode 100644 index 0000000..a2eb9e7 Binary files /dev/null and b/uploads/docs/.DS_Store differ diff --git a/uploads/docs/1.drawio b/uploads/docs/1.drawio new file mode 100644 index 0000000..00c36b6 --- /dev/null +++ b/uploads/docs/1.drawio @@ -0,0 +1,379 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/uploads/docs/c4a52528-7d45-4e90-aa4b-606c4ab4/1741702381098261000_软件需求规格说明书_范例.pdf b/uploads/docs/c4a52528-7d45-4e90-aa4b-606c4ab4/1741702381098261000_软件需求规格说明书_范例.pdf new file mode 100644 index 0000000..73fcd04 Binary files /dev/null and b/uploads/docs/c4a52528-7d45-4e90-aa4b-606c4ab4/1741702381098261000_软件需求规格说明书_范例.pdf differ diff --git a/uploads/docs/c4a52528-7d45-4e90-aa4b-606c4ab4/1741702731739407000_软件需求规格说明书_范例.pdf b/uploads/docs/c4a52528-7d45-4e90-aa4b-606c4ab4/1741702731739407000_软件需求规格说明书_范例.pdf new file mode 100644 index 0000000..73fcd04 Binary files /dev/null and b/uploads/docs/c4a52528-7d45-4e90-aa4b-606c4ab4/1741702731739407000_软件需求规格说明书_范例.pdf differ diff --git a/uploads/docs/c4a52528-7d45-4e90-aa4b-606c4ab4/1741703169383813000_软件需求规格说明书_范例.pdf b/uploads/docs/c4a52528-7d45-4e90-aa4b-606c4ab4/1741703169383813000_软件需求规格说明书_范例.pdf new file mode 100644 index 0000000..73fcd04 Binary files /dev/null and b/uploads/docs/c4a52528-7d45-4e90-aa4b-606c4ab4/1741703169383813000_软件需求规格说明书_范例.pdf differ diff --git a/uploads/docs/c4a52528-7d45-4e90-aa4b-606c4ab4/1741751922473930000_软件需求规格说明书_范例.pdf b/uploads/docs/c4a52528-7d45-4e90-aa4b-606c4ab4/1741751922473930000_软件需求规格说明书_范例.pdf new file mode 100644 index 0000000..73fcd04 Binary files /dev/null and b/uploads/docs/c4a52528-7d45-4e90-aa4b-606c4ab4/1741751922473930000_软件需求规格说明书_范例.pdf differ diff --git a/uploads/docs/dcc0faa4-9efa-4436-a1b4-a66007d2/1741949591250431000_软件需求规格说明书_范例.pdf b/uploads/docs/dcc0faa4-9efa-4436-a1b4-a66007d2/1741949591250431000_软件需求规格说明书_范例.pdf new file mode 100644 index 0000000..73fcd04 Binary files /dev/null and b/uploads/docs/dcc0faa4-9efa-4436-a1b4-a66007d2/1741949591250431000_软件需求规格说明书_范例.pdf differ diff --git a/uploads/docs/dcc0faa4-9efa-4436-a1b4-a66007d2/1743064231478392000_基于RT-DETR的病理图像癌细胞检测方法.docx b/uploads/docs/dcc0faa4-9efa-4436-a1b4-a66007d2/1743064231478392000_基于RT-DETR的病理图像癌细胞检测方法.docx new file mode 100644 index 0000000..9ef129b Binary files /dev/null and b/uploads/docs/dcc0faa4-9efa-4436-a1b4-a66007d2/1743064231478392000_基于RT-DETR的病理图像癌细胞检测方法.docx differ diff --git a/uploads/docs/dcc0faa4-9efa-4436-a1b4-a66007d2/1743064395865874000_「后端」k8s 部署实操 - 简道园 - 知识管理.pdf b/uploads/docs/dcc0faa4-9efa-4436-a1b4-a66007d2/1743064395865874000_「后端」k8s 部署实操 - 简道园 - 知识管理.pdf new file mode 100644 index 0000000..06619f2 Binary files /dev/null and b/uploads/docs/dcc0faa4-9efa-4436-a1b4-a66007d2/1743064395865874000_「后端」k8s 部署实操 - 简道园 - 知识管理.pdf differ diff --git a/uploads/docs/dcc0faa4-9efa-4436-a1b4-a66007d2/1743082836761993000_梦想云地震中心统一共享服务接口文档@2024722.pdf b/uploads/docs/dcc0faa4-9efa-4436-a1b4-a66007d2/1743082836761993000_梦想云地震中心统一共享服务接口文档@2024722.pdf new file mode 100644 index 0000000..6209abb Binary files /dev/null and b/uploads/docs/dcc0faa4-9efa-4436-a1b4-a66007d2/1743082836761993000_梦想云地震中心统一共享服务接口文档@2024722.pdf differ diff --git a/uploads/docs/dcc0faa4-9efa-4436-a1b4-a66007d2/1743928735174105000_1741702381098261000_软件需求规格说明书_范例.pdf b/uploads/docs/dcc0faa4-9efa-4436-a1b4-a66007d2/1743928735174105000_1741702381098261000_软件需求规格说明书_范例.pdf new file mode 100644 index 0000000..73fcd04 Binary files /dev/null and b/uploads/docs/dcc0faa4-9efa-4436-a1b4-a66007d2/1743928735174105000_1741702381098261000_软件需求规格说明书_范例.pdf differ diff --git a/uploads/docs/dcc0faa4-9efa-4436-a1b4-a66007d2/1743928927861020000_1741702381098261000_软件需求规格说明书_范例.pdf b/uploads/docs/dcc0faa4-9efa-4436-a1b4-a66007d2/1743928927861020000_1741702381098261000_软件需求规格说明书_范例.pdf new file mode 100644 index 0000000..73fcd04 Binary files /dev/null and b/uploads/docs/dcc0faa4-9efa-4436-a1b4-a66007d2/1743928927861020000_1741702381098261000_软件需求规格说明书_范例.pdf differ diff --git a/uploads/docs/dcc0faa4-9efa-4436-a1b4-a66007d2/1743929563363196000_1741702381098261000_软件需求规格说明书_范例.pdf b/uploads/docs/dcc0faa4-9efa-4436-a1b4-a66007d2/1743929563363196000_1741702381098261000_软件需求规格说明书_范例.pdf new file mode 100644 index 0000000..73fcd04 Binary files /dev/null and b/uploads/docs/dcc0faa4-9efa-4436-a1b4-a66007d2/1743929563363196000_1741702381098261000_软件需求规格说明书_范例.pdf differ diff --git a/uploads/docs/dcc0faa4-9efa-4436-a1b4-a66007d2/1744090842942836000_软件需求规格说明书_范例.pdf b/uploads/docs/dcc0faa4-9efa-4436-a1b4-a66007d2/1744090842942836000_软件需求规格说明书_范例.pdf new file mode 100644 index 0000000..73fcd04 Binary files /dev/null and b/uploads/docs/dcc0faa4-9efa-4436-a1b4-a66007d2/1744090842942836000_软件需求规格说明书_范例.pdf differ diff --git a/uploads/docs/dcc0faa4-9efa-4436-a1b4-a66007d2/1744092005687286000_软件需求规格说明书_范例.pdf b/uploads/docs/dcc0faa4-9efa-4436-a1b4-a66007d2/1744092005687286000_软件需求规格说明书_范例.pdf new file mode 100644 index 0000000..73fcd04 Binary files /dev/null and b/uploads/docs/dcc0faa4-9efa-4436-a1b4-a66007d2/1744092005687286000_软件需求规格说明书_范例.pdf differ diff --git a/uploads/sample.txt b/uploads/sample.txt new file mode 100644 index 0000000..56033f0 --- /dev/null +++ b/uploads/sample.txt @@ -0,0 +1,429 @@ +软件需求规格说明书 +项目类别:新品开发 +项目名称:贵州红华集团公共商业平台 1.0 +编 制: 年 月 日 +审 核: 年 月 日 +批 准: 年 月 日 +日期 2003-06-04 修订历史记录 +版本 说明 0.9 未评审的初稿 作者 +丁建德 +目录 +1 引言 ...................................................................................................................... 1 +1.1 目的 ........................................................................................................................................................ 1 +1.2 文档约定 ................................................................................................................................................ 1 +1.3 预期的读者和阅读建议 ........................................................................................................................ 2 +1.4 产品的范围 ............................................................................................................................................ 2 +1.5 参考文献 ................................................................................................................................................ 3 +2 综合描述 .............................................................................................................. 4 +2.1 产品的前景 ............................................................................................................................................ 4 +2.2 产品的功能 ............................................................................................................................................ 4 +2.3 用户类和特征 ........................................................................................................................................ 6 +2.4 运行环境 ................................................................................................................................................ 6 +2.5 设计和实现上的限制 ............................................................................................................................ 7 +2.6 假设和依赖 ............................................................................................................................................ 7 +3 外部接口需求 ...................................................................................................... 8 +3.1 用户界面 ................................................................................................................................................ 8 +3.2 硬件接口 ................................................................................................................................................ 8 +3.3 软件接口 ................................................................................................................................................ 8 +3.4 通讯接口 ................................................................................................................................................ 8 +4 系统特性 .............................................................................................................. 9 +4.1 主角 ........................................................................................................................................................ 9 +第 i 页 +4.2 系统管理 ................................................................................................................................................ 9 +4.2.1 个人管理 ..................................................................................................................................... 9 +4.2.2 用户管理 ..................................................................................................................................... 9 +4.2.3 数据管理 ..................................................................................................................................... 9 +4.2.4 工具管理 ..................................................................................................................................... 9 +4.3 连锁管理 ................................................................................................................................................ 9 +4.3.1 编码管理 ..................................................................................................................................... 9 +4.3.2 商品管理 ..................................................................................................................................... 9 +4.3.3 供应商管理 ............................................................................................................................... 10 +4.3.4 合同管理 ................................................................................................................................... 10 +4.3.5 进货管理 ................................................................................................................................... 10 +4.3.6 库存管理 ................................................................................................................................... 10 +4.3.7 物价管理 ................................................................................................................................... 10 +4.3.8 调配管理 ................................................................................................................................... 10 +4.3.9 计划管理 ................................................................................................................................... 10 +4.3.10 促销管理 ................................................................................................................................. 10 +4.3.11 销售管理 ................................................................................................................................. 10 +4.3.12 批发管理 ................................................................................................................................. 11 +4.3.13 网络销售管理 ......................................................................................................................... 11 +4.3.14 应收管理 ................................................................................................................................. 11 +4.3.15 应付管理 ................................................................................................................................. 11 +4.4 物流管理 .............................................................................................................................................. 11 +4.4.1 基本资料管理 ........................................................................................................................... 11 +4.4.2 物流货主管理 ........................................................................................................................... 11 +4.4.3 物流仓库管理 ........................................................................................................................... 11 +4.4.4 物流配送管理 ........................................................................................................................... 11 +4.4.5 物流费用管理 ........................................................................................................................... 11 +4.5 客户关系管理 ...................................................................................................................................... 12 +4.5.1 基本资料管理 ........................................................................................................................... 12 +4.5.2 客户服务管理 ........................................................................................................................... 12 +4.5.3 安装维修管理 ........................................................................................................................... 12 +4.5.4 配件仓库管理 ........................................................................................................................... 12 +4.5.5 服务费用管理 ........................................................................................................................... 12 +4.6 供应链管理 .......................................................................................................................................... 12 +4.7 辅助管理 .............................................................................................................................................. 12 +4.7.1 办公管理 ................................................................................................................................... 12 +4.7.2 工作管理 ................................................................................................................................... 13 +4.8 查询分析 .............................................................................................................................................. 13 +第 ii 页 +5 非功能性需求 .................................................................................................... 14 +5.1 性能需求 .............................................................................................................................................. 14 +5.2 安全设施需求 ...................................................................................................................................... 14 +5.3 安全性需求 .......................................................................................................................................... 14 +5.4 软件质量属性 ...................................................................................................................................... 14 +5.5 业务规则 .............................................................................................................................................. 15 +5.6 用户文档 .............................................................................................................................................. 15 +5.7 其它需求 .............................................................................................................................................. 15 +附录 A:词汇表 ..................................................................................................... 16 +附录 B:业务流程 ................................................................................................. 17 +附录 C:待定问题列表 ......................................................................................... 18 +第 iii 页 +1 引言 +1.1 目的 +该文档首先给出了整个系统的整体网络结构和功能结构的概貌,试图从总体架构上给出整个系统的 +轮廓,然后又对功能需求、性能需求和其它非功能性需求进行了详细的描述。其中对功能需求的描述采 +用了 UML 的用例模型方式,不仅描述了每一用例的基本事件流和备选事件流,而且还给出了非常直观的 +用例图。这些文字和图形都为了详细准确地描述用户的需求,同时也为用户更容易地理解这些需求的描 +述创造了条件。 +该文档详尽说明了这一软件产品的需求和规格,这些规格说明是进行设计的基础,也是编写测试用 +例和进行系统测试的主要依据。同时,该文档也是用户确定软件功能需求的主要依据。 +1.2 文档约定 +该文档采用公司 ISO9000 质量体系 V2.0 所提供的《YFRM04 软件需求规格说明书》文档模板,但对 +文档的字体版式以及部分章节的内容进行了适当的调整,这些调整有: +(1)在扉页增加“历史修订记录”页。该文档处于受控状态时,对该文档的所有修改和变更都要 +记录到该页中。 +(2)目录的文字格式进行了调整。原目录的字体和格式显得过于凌乱。 +(3)标题和正文的文字格式进行了调整。取消了标题的自动编号功能,同时,为了扩大每页的文 +字容量,把正文字体调整为了宋体五号。 +(4)左右页边距进行了调整。这也是为了扩大每页的文字容量。 +(5)页脚的文字进行了调整。原页脚中有“共 xx 页”的内容,但在整个文档进行分节的情况下, +总页数显示的却是所有节的总页数,而不是当前页的总页数。 +(6)把原“4 系统特性”一章的性能需求、安全设施需求、安全性需求、软件质量属性、业务规 +则、用户文档内容与原“5 其它需求”一章的内容进行合同形成新的章节“5 非功能性需求”。 +(7)根据实际情况把“4 系统特性”一章的内容按功能类型进行了细化。该章被细分为 8 大节, +每一节中都有详细的功能描述。 +(8)去掉可选项“附录 B:分析模型”的内容。 +根据以上的调整,本文档按以下要求和约定进行书写: +(1)页面的左边距为 2.5cm,右边距为 2.0cm,装订线靠左。 +(2)标题最多分三级。 +(3) 正文字体为宋体五号,系统特性一章中各功能的小项字体为加粗的楷体小四。 无特殊情况下, +字体颜色均采用黑色。 +(4)出现序号的段落不采用自动编号功能而采用人工编号, 各级别的序号依次为(1)、 1)、 a)等, +特殊情况另作规定。 +(5)本文所引用的图片均采用超级链接的方式进行引用,图片的文件格式分 VSD、 GIF 和 JPG 三种。 +第 1 页 +VSD 格式的文件用于查看和编辑, GIF 和 JPG 格式文件仅用于查看。 VSD 格式是采用 Microsoft Visio 2002 +所绘制的图形,GIF 格式是由 VSD 格式转化而来或从其它格式转化而来的 GIF 文件格式,JPG 格式是从 +Rational Rose 的用例视图中通过 Web Publisher 工具而来的。图象文件均采用中文进行命名,本文档 +不引用这三种格式外的其它图像文件。 +1.3 预期的读者和阅读建议 +本文档的主要内容共分 4 部分:综合描述、外部接口描述、系统特性和非功能性需求,另外还有 2 +个附录。 综合描述部分主要对系统的整体结构进行了大致的介绍;外部接口需求部分对用户界面、软件 +接口、 硬件接口和通讯接口等进行了详细的描述; 系统特性部分对系统的功能需求进行了详细描述; 非 +功能性需求部分对非功能需求进行了详细的描述。 +本文档面向多种读者对象: +(1)项目经理:项目经理可以根据该文档了解预期产品的功能,并据此进行系统设计。 +(2)设计员:对需求进行分析,并设计出系统,包括数据库的设计。 +(3)程序员:配合《设计报告》,了解系统功能,编写《用户手册》。 +(4)测试员:根据本文档对软件产品进行功能性测试和非功能性测试。 +(5)销售人员:了解预期产品的功能和性能。 +(6)用户:了解预期产品的功能和性能,并与分析人员一起对整个需求进行讨论和协商。 +(7)其他人员:如部门领导、公司领导等可以据此了解产品的功能和性能。 +在阅读本文档时,首先要了解产品的功能概貌,然后可以根据自身的需要对每一功能进行适当的了 +解。 +1.4 产品的范围 +该产品是在积累了丰富业务经验的基础上进行开发的,在需求上, 充分考虑了具体用户的实际情况。 +本产品主要适用于家电连锁企业的以下范围: +(1)总公司。 +(2)家电超市。 +(3)物流中心。 +(4)客户服务中心。 +针对贵阳红华,可以完成以下业务: +(1)完成对贵阳红华贸易有限公司作为家电超市总部部分的业务管理,包括采购、销售、库存、 +结算、促销等。 +(2)完成贵阳红华贸易有限公司网络超市及其它非独立核算超市的要货、销售、收款和送货等。 +(3)完成对贵阳红华贸易有限公司下属的独立核算家电超市的订货、销售、收款和送货的管理。 +(4)完成对贵阳华捷物流有限公司的仓库、配送、结算业务的管理。 +(5)完成对贵阳红华家电维修有限公司的维修安装、售后服务、配件、结算的管理。 +第 2 页 +1.5 参考文献 +(1)《零售业:发展热点思辩》,顾建国,1997,中国商业出版社。 +(2)《商场现代化》期刊。 +(3)Internet 资源。 +(4)《软件需求分析规范》,海信网络科技公司,2002,内部资料。 +(5)《物流配送中心设计》,刘昌祺,2001,机械工业出版社。 +(6)红华企业集团软件需求规格说明书。 +第 3 页 +2 综合描述 +2.1 产品的前景 +企业规模的扩大必须有相应的计算机管理系统作为支撑, 尤其是连锁经营企业, 如果没有一套适合 +于规模不断扩大的计算机管理系统,在规模扩大到一定程度后,管理和经营问题将会凸现。 +成熟和完善的计算机管理系统可以适应连锁企业规模的不断扩大, 红华公共商业平台采用先进的三 +层分布式架构,在系统的功能上具有不断扩展的空间,在系统的性能上可以通过应用服务器来不断地扩 +容。 +先进的计算机管理系统为红华与国内大型家电连锁企业进行抗衡提供了保障,这也是进行国内扩张 +从而成为全国性的大型家电连锁企业的有力工具。 +2.2 产品的功能 +该产品分系统管理、辅助管理、连锁管理、物流管理、供应链管理、客户关系管理六大主要功能, +这六部分功能共同组成了红华商业平台系统。商业平台系统通过财务接口与 Oracle 财务系统实现联接, +通过网站接口系统与红华网站实现联接。 +(一)系统管理功能 +(1)个人管理。 +(2)用户管理。 +(3)数据管理。 +(4)工具管理。 +(二)辅助管理功能 +(1)办公管理。 +(2)工作管理。 +(三)连锁管理功能 +(1)编码管理。 +(2)供应商管理。 +(3)合同管理。 +(4)商品管理。 +(5)进货管理。 +(6)库存管理。 +(7)物价管理。 +(8)促销管理。 +(9)调配管理。 +(10)计划管理。 +(11)销售管理。 +(12)批发管理。 +第 4 页 +(13)网络销售管理。 +(14)应收管理。 +(15)应付管理。 +(四)物流管理功能 +(1)物流货主管理。 +(2)物流仓库管理。 +(3)物流配送管理。 +(4)物流费用管理。 +(五)供应链管理 +(六)客户关系管理 +(1)客户服务管理。 +(2)安装维修管理。 +(3)配件仓库管理。 +(4)服务费用管理。 +该系统的结构图如下所示: +(1)网络结构示意图如下: +(2)功能结构示意图如下: +第 5 页 +为方便描述, 根据实际需要把系统的功能划分为模块,这些功能将在“系统特性”部分进行详细的 +描述和说明。 +2.3 用户类和特征 +该产品主要面向连锁配送企业的以下用户群体: +(1)业务处理用户 +这类用户是该产品的主要使用者, 他们一般熟悉企业的具体业务,但在计算机的使用上可能具有一 +定程度的欠缺。 +(2)查询分析用户 +这类用户是该产品的主要使用者, 他们一般从管理的角度利用该系统进行数据的查询和分析。这类 +用户具有一定的管理经验,但在计算机的使用上可以具有一定程度的欠缺。 +(3)系统管理用户 +这类用户是该产品的次要使用者, 他们利用该系统对系统进行维护。这类用户是计算机应用的高手, +但在业务上可能是一个门外汉。 +具体的用户,请参考系统特性中“主角”的描述。 +2.4 运行环境 +该系统为 C/S+B/S 三层结构,它的运行环境分客户端、应用服务器端和数据库服务器端三部分, 另 +外还包括 Oracle 财务接口。 +以下是系统的软件环境。为使系统达到预期的性能,必须根据业务规模对硬件配置进行论证。 +(1)客户端 +操作系统:Windows98/Windows2000 Professional。 +数据库访问:ADO。 +(2)应用服务器端 +操作系统:Windows2000 Server。 +第 6 页 +应用服务器:Tomcat 4。 +数据库访问:ADO、JDBC。 +(3)数据库服务器端 +操作系统:Unix(具体版本与硬件有关)。 +数据库系统:Oracle9i。 +(4)Oracle 财务接口 +按财务接口要求的软件环境配置。 +2.5 设计和实现上的限制 +(1)服务器操作系统和数据库的限制 +为了满足企业大型的应用,必须采用 Unix+Oracle。 +(2)开发工具的限制 +本系统采用三层的 C/S+B/S 体系结构,客户端仍然采用 GUI 界面,而 Delphi 是开发 GUI 产品的首 +选工具。对于 B/S,采用 JBuilder 进行开发。 +(3)Oracle 财务系统的限制 +该产品要与 Oracle 财务系统相连接,这就要求在设计上要兼顾 Oracle 财务系统。 +(4)外部网站的限制 +该产品要与红华现有的外部网站相连接。 +(5)合法 +该软件产品的业务处理和数据处理必须符合国家的相关法律和法规。 +2.6 假设和依赖 +本项目是否能够成功实施,主要取决于以下的条件: +(1)红华能够积极地与海信配合,为了项目的开发和实施,在必要时对现有的业务和财务流程进 +行合理的调整。 +(2)红华为海信的调研、开发和实施过程提供必要的工作环境和系统运行环境,这些环境有助于 +海信开展工作。 +(3)红华为海信提供完整的功能和性能需求资料,以便于海信对其进行分析,从而形成完善的软 +件需求。 +(4)红华为软件系统的运行提供必要的且能够满足系统运行条件的硬件环境和通讯环境,不合适 +的硬件环境和通讯环境将会影响系统的性能。 +(5) 海信掌握先进的能够适用于该项目的技术,这是系统的性能是否优化和项目能否成功的保证。 +(6)红华与海信具有相对稳定的项目团队,不稳定的团队将影响项目的进度和质量。 +第 7 页 +3 外部接口需求 +3.1 用户界面 +本系统的三层 C/S 部分采用标准的 GUI 图形界面,主窗体为多文档风格,其中出现的所有错误信息 +和提示信息均采用 Windows 的标准提示框。 +B/S 部分采用浏览器界面,仅适用于供应链管理部分。 +用户界面的具体细节将在概要设计文档中描述。 +3.2 硬件接口 +(1)条码打印机接口 +系统集成了 Zebra 系列产品的驱动, 如果是其它品牌只要厂商提供产品驱动即可。系统通常通过串 +口连接条码打印机。 +(2)数据采集器接口 +系统指定接口文件格式,数据采集器将采集后的数据按照系统指定的格式导出到指定目录,然后系 +统读取接口文件,读入数据。系统通常使用串口连接数据采集器。 +(3)收款机专用打印机接口 +根据具体的型号确定接口。 +3.3 软件接口 +(1)与 Oracle 数据库的接口:通过 ADO 和 JDBC 进行联接。 +(2)与 Oracle 财务系统的接口:通过财务接口系统实现。 +(3)与红华网站的接口:通过接口表实现。 +3.4 通讯接口 +(1)TCP/IP 网络协议。 +(2)CORBA 组件通讯采用 UDP 协议。 +(3)HTTP 协议。 +奇偶检验。 +(4)条码打印机、MODEM 通讯接口:数据格式速率为 9600 波特率,8 位数据位,1 位停止位,无 +第 8 页 +4 系统特性 +4.1 主角 +见文件“4.1 主角.doc”。 +4.2 系统管理 +4.2.1 个人管理 +见文件“4.2.01 个人管理.doc”。 +4.2.2 用户管理 +见文件“4.2.02 用户管理.doc”。 +4.2.3 数据管理 +见文件“4.2.03 数据管理.doc”。 +4.2.4 工具管理 +见文件“4.2.04 工具管理.doc”。 +4.3 连锁管理 +4.3.1 编码管理 +见文件“4.3.01 编码管理.doc”。 +4.3.2 商品管理 +第 9 页 +见文件“4.3.02 商品管理.doc”。 +4.3.3 供应商管理 +见文件“4.3.03 供应商管理.doc”。 +4.3.4 合同管理 +见文件“4.3.04 合同管理.doc”。 +4.3.5 进货管理 +见文件“4.3.05 进货管理.doc”。 +4.3.6 库存管理 +见文件“4.3.06 库存管理.doc”。 +4.3.7 物价管理 +见文件“4.3.07 物价管理.doc”。 +4.3.8 调配管理 +见文件“4.3.08 调配管理.doc”。 +4.3.9 计划管理 +见文件“4.3.09 计划管理.doc”。 +4.3.10 促销管理 +见文件“4.3.10 促销管理.doc”。 +4.3.11 销售管理 +见文件“4.3.11 销售管理.doc”。 +第 10 页 +4.3.12 批发管理 +见文件“4.3.12 批发管理.doc”。 +4.3.13 网络销售管理 +见文件“4.3.13 网络销售管理.doc”。 +4.3.14 应收管理 +见文件“4.3.14 应收管理.doc”。 +4.3.15 应付管理 +见文件“4.3.15 应付管理.doc”。 +4.4 物流管理 +4.4.1 基本资料管理 +见文件“4.4.01 基本资料管理.doc”。 +4.4.2 物流货主管理 +见文件“4.4.02 物流货主管理.doc”。 +4.4.3 物流仓库管理 +见文件“4.4.03 物流仓库管理.doc”。 +4.4.4 物流配送管理 +见文件“4.4.04 物流配送管理.doc”。 +4.4.5 物流费用管理 +见文件“4.4.05 物流费用管理.doc”。 +第 11 页 +4.5 客户关系管理 +4.5.1 基本资料管理 +见文件“4.5.01 基本资料管理.doc”。 +4.5.2 客户服务管理 +见文件“4.5.02 客户服务管理.doc”。 +4.5.3 安装维修管理 +见文件“4.5.03 安装维修管理.doc”。 +4.5.4 配件仓库管理 +见文件“4.5.04 配件仓库管理.doc”。 +4.5.5 服务费用管理 +见文件“4.5.05 服务费用管理.doc”。 +4.6 供应链管理 +见文件“4.6 供应链管理.doc”。 +4.7 辅助管理 +4.7.1 办公管理 +见文件“4.7.01 办公管理.doc”。 +第 12 页 +4.7.2 工作管理 +见文件“4.7.02 工作管理.doc”。 +4.8 查询分析 +见文件“4.8 查询分析.doc”。 +第 13 页 +5 非功能性需求 +5.1 性能需求 +(1)系统运行稳定。 +(2)系统数据安全。 +(3)客户端响应快捷,速度能达到业务的基本要求。 +(4)扩容性强,在达到 1000 台终端时能够保证速度。 +(5)系统具有一定的容错和抗干扰能力,在非硬件故障或非通讯故障时,系统能够保证终端能正 +常运行。 +(6)扩展性强,能够满足将来业务和财务扩展需要。 +5.2 安全设施需求 +本系统不存在安全设施方面的需求。 +5.3 安全性需求 +(1)权限控制 +根据不同用户角色,设置相应权限,用户的重要操作都做相应的日志记录以备查看, 没有权限的用 +户禁止使用系统。 +(2)重要数据加密 +本系统对一些重要的数据按一定的算法进行加密,如用户口令、重要参数等。 +(3)数据备份 +允许用户进行数据的备份和恢复,以弥补数据的破坏和丢失。 +5.4 软件质量属性 +(1)记录日志 +本系统应该能够记录系统运行时所发生的所有错误, 包括本机错误和网络错误。这些错误记录便于 +查找错误的原因。 +(2)验证权限 +本系统的所有功能都应该进行功能权限、部门权限的判断和控制。 +(3)控制必录入项 +第 14 页 +本系统能够对必须录入的项目进行控制,使用户能够确保信息录入的完整。 +(4)方便操作 +尽量从用户角度出发,以方便使用本产品。 如:录入商品信息时, 敲入回车键光标的自动跳转、 输 +入法的自动转换,信息检索时输入汉语简拼快速检索到结果等。 +(5)用户可自定义 +为了满足业务的不断变化,一些重要的参数应该可以灵活设置。 +5.5 业务规则 +见文件“5.5 业务规则.doc” +。 +5.6 用户文档 +同本软件一起发行的用户文档包括: +(1)安装手册:Word 格式文件。 +(2)用户手册:Word 格式文件。 +(3)在线帮助:HTML Help 格式文件,联机式。 +5.7 其它需求 +(1)能够应用多种先进的工作器具。 +(2)系统安装方便,易于维护。 +第 15 页 +附录 A:词汇表 +见文件“附录 A:词汇表.doc” +。 +第 16 页 +附录 B:业务流程 +见文件“附录 B:业务流程.doc” +。 +第 17 页 +附录 C:待定问题列表 +见文件“附录 C:待定问题列表.doc” +。 +第 18 页 \ No newline at end of file diff --git a/utils/init.go b/utils/init.go new file mode 100644 index 0000000..88ca345 --- /dev/null +++ b/utils/init.go @@ -0,0 +1,9 @@ +package utils +import ( + "github.com/veypi/OneBD/rest" +) +func SetCORSHeaders(x *rest.X) { + x.ResponseWriter().Header().Set("Access-Control-Allow-Origin", "*") + x.ResponseWriter().Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS, FETCH, PUT") + x.ResponseWriter().Header().Set("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, token, Accept") +} \ No newline at end of file diff --git a/vyes-ui b/vyes-ui new file mode 160000 index 0000000..f1c7ded --- /dev/null +++ b/vyes-ui @@ -0,0 +1 @@ +Subproject commit f1c7dedc1e6c2bceec011c4da59caa35e104f10f