package services import ( "encoding/json" "fmt" "fwh-toolbox-server/dto" "fwh-toolbox-server/repositories" "io" "net/http" "regexp" "strings" "time" "github.com/google/uuid" "go.uber.org/zap" ) // FeedbackHistoryItem 返回给前端的历史记录条目 type FeedbackHistoryItem struct { Id string `json:"id"` Title string `json:"title"` Type string `json:"type"` // bug / feature Status int `json:"status"` // -2 Pending, -1 Rejected, 0 Snoozed, 1 Accepted, 2 Duplicate Priority string `json:"priority"` CreatedAt string `json:"createdAt"` CreatedBy string `json:"createdBy"` // 提交人用户名 } // SubmitFeedback 提交反馈到 Plane Intake (V2 Web API) // 流程:1. 构造HTML(含image-component) → 2. 创建IntakeIssue → 3. 绑定Asset到Issue func SubmitFeedback(body dto.FeedbackSubmitDTO, userName string) (*repositories.PlaneWebIntakeIssueResponse, error) { // 构造标题(包含提交人标识,供历史查询时解析) var prefix string if body.Type == "bug" { prefix = "[Bug]" } else { prefix = "[Feature]" } title := fmt.Sprintf("%s %s 「%s」", prefix, body.Title, userName) // 构造描述 HTML(附件使用 image-component 格式) descHTML := buildDescriptionHTML(body, userName) // 选择 Label(始终添加 Feedback 标签 + 类型标签) var labelIds []string if repositories.Plane.LabelFeedbackId != "" { labelIds = append(labelIds, repositories.Plane.LabelFeedbackId) } if body.Type == "bug" && repositories.Plane.LabelBugId != "" { labelIds = append(labelIds, repositories.Plane.LabelBugId) } else if body.Type == "feature" && repositories.Plane.LabelFeatureId != "" { labelIds = append(labelIds, repositories.Plane.LabelFeatureId) } // 映射优先级 priority := mapPriority(body.Priority) // 构造请求体 (Plane V2 Web API) req := repositories.PlaneWebIntakeIssueRequest{ Name: title, DescriptionHTML: descHTML, Priority: priority, LabelIDs: labelIds, } // 调用 Plane V2 Web Intake API 创建Issue result, err := repositories.Plane.CreateIntakeIssue(req) if err != nil { return nil, err } issueId := result.Issue.ID // 创建成功后,如果有附件,将 Asset 绑定到 Issue Entity if len(body.AssetIds) > 0 { if err := repositories.Plane.BindAssetsToEntity(issueId, body.AssetIds); err != nil { // 绑定失败不阻塞整体提交,但记录警告 zap.L().Warn("[Feedback] 绑定附件到Issue失败", zap.String("issueId", issueId), zap.Strings("assetIds", body.AssetIds), zap.Error(err), ) } else { zap.L().Info("[Feedback] 附件绑定成功", zap.String("issueId", issueId), zap.Int("assetCount", len(body.AssetIds)), ) } } zap.L().Info("[Feedback] 提交成功", zap.String("issueId", issueId), zap.String("user", userName), zap.String("type", body.Type), zap.String("title", body.Title), ) return result, nil } // buildDescriptionHTML 构造提交描述的 HTML // 附件使用 Plane 原生 格式,放在文本最后 func buildDescriptionHTML(body dto.FeedbackSubmitDTO, userName string) string { var sb strings.Builder sb.WriteString(fmt.Sprintf(`

提交人:%s

`, userName)) sb.WriteString(fmt.Sprintf(`

分类:%s

`, body.Category)) if body.RelatedPage != nil && *body.RelatedPage != "" { sb.WriteString(fmt.Sprintf(`

相关页面:%s

`, *body.RelatedPage)) } sb.WriteString("
") if body.Type == "bug" { // Bug Report if body.Steps != nil && *body.Steps != "" { sb.WriteString(fmt.Sprintf(`

复现步骤

%s

`, strings.ReplaceAll(*body.Steps, "\n", "
"))) } if body.Expected != nil && *body.Expected != "" { sb.WriteString(fmt.Sprintf(`

期望结果

%s

`, strings.ReplaceAll(*body.Expected, "\n", "
"))) } if body.Actual != nil && *body.Actual != "" { sb.WriteString(fmt.Sprintf(`

实际结果

%s

`, strings.ReplaceAll(*body.Actual, "\n", "
"))) } if body.Description != "" { sb.WriteString(fmt.Sprintf(`

补充说明

%s

`, strings.ReplaceAll(body.Description, "\n", "
"))) } } else { // Feature Request if body.Description != "" { sb.WriteString(fmt.Sprintf(`

需求描述

%s

`, strings.ReplaceAll(body.Description, "\n", "
"))) } if body.Scenario != nil && *body.Scenario != "" { sb.WriteString(fmt.Sprintf(`

使用场景

%s

`, strings.ReplaceAll(*body.Scenario, "\n", "
"))) } } // 附件放在最后,带"附件"大标题 if len(body.AssetIds) > 0 { sb.WriteString(`

附件

`) for _, assetId := range body.AssetIds { dataId := uuid.New().String() sb.WriteString(fmt.Sprintf( ``, dataId, assetId, dataId, )) } } return sb.String() } // GetFeedbackHistory 查询 Plane Intake 列表 (V2 Web API) // 只返回带有 Feedback 标签的记录,包含所有状态(Pending/Accepted/Rejected/Snoozed/Duplicate) func GetFeedbackHistory(userName string) ([]FeedbackHistoryItem, error) { // 查询所有状态的 Intake 记录:-2 Pending, -1 Rejected, 0 Snoozed, 1 Accepted, 2 Duplicate data, err := repositories.Plane.ListIntakeIssues("-2,-1,0,1,2") if err != nil { return nil, err } feedbackLabelId := repositories.Plane.LabelFeedbackId // V2 返回的是 raw JSON,直接解析 type intakeItem struct { Status int `json:"status"` Issue struct { ID string `json:"id"` Name string `json:"name"` Priority string `json:"priority"` LabelIDs []string `json:"label_ids"` CreatedAt string `json:"created_at"` CreatedBy string `json:"created_by"` } `json:"issue"` } var items []intakeItem if err := json.Unmarshal(data, &items); err != nil { // 可能是分页格式 var paged struct { Results []intakeItem `json:"results"` } if err2 := json.Unmarshal(data, &paged); err2 != nil { return nil, fmt.Errorf("解析Intake列表失败: %w", err) } items = paged.Results } result := []FeedbackHistoryItem{} for _, item := range items { // 只显示带有 Feedback 标签的记录 if feedbackLabelId != "" && !containsLabel(item.Issue.LabelIDs, feedbackLabelId) { continue } result = append(result, FeedbackHistoryItem{ Id: item.Issue.ID, Title: cleanTitle(item.Issue.Name), Type: extractType(item.Issue.Name), Status: item.Status, Priority: item.Issue.Priority, CreatedAt: item.Issue.CreatedAt, CreatedBy: extractCreator(item.Issue.Name), }) } return result, nil } // extractCreator 从标题中提取提交人(格式:[Type] title 「username」) func extractCreator(title string) string { start := strings.LastIndex(title, "「") end := strings.LastIndex(title, "」") if start >= 0 && end > start { return title[start+len("「") : end] } return "" } // extractType 从标题中提取类型 func extractType(title string) string { if strings.HasPrefix(title, "[Bug]") { return "bug" } if strings.HasPrefix(title, "[Feature]") { return "feature" } return "" } // cleanTitle 从标题中移除类型前缀和提交人后缀,返回纯标题 func cleanTitle(title string) string { // 移除 [Bug] 或 [Feature] 前缀 title = strings.TrimPrefix(title, "[Bug] ") title = strings.TrimPrefix(title, "[Feature] ") // 移除末尾的 「username」 if idx := strings.LastIndex(title, " 「"); idx >= 0 { title = title[:idx] } return title } // containsLabel 判断标签列表中是否包含指定标签 func containsLabel(labels []string, target string) bool { for _, l := range labels { if l == target { return true } } return false } // FeedbackDetailResult 反馈详情 type FeedbackDetailResult struct { Id string `json:"id"` Title string `json:"title"` DescriptionHTML string `json:"descriptionHtml"` Priority string `json:"priority"` Status int `json:"status"` CreatedAt string `json:"createdAt"` Attachments []FeedbackAttachment `json:"attachments"` } // FeedbackAttachment 附件信息 type FeedbackAttachment struct { AssetId string `json:"assetId"` } // GetFeedbackDetail 获取单条反馈详情(含 description_html 和附件 ID 列表) func GetFeedbackDetail(issueId string) (*FeedbackDetailResult, error) { data, err := repositories.Plane.GetIntakeIssue(issueId) if err != nil { return nil, err } var detail struct { Status int `json:"status"` Issue struct { ID string `json:"id"` Name string `json:"name"` DescriptionHTML string `json:"description_html"` Priority string `json:"priority"` CreatedAt string `json:"created_at"` } `json:"issue"` } if err := json.Unmarshal(data, &detail); err != nil { return nil, fmt.Errorf("解析反馈详情失败: %w", err) } // 从 description_html 中提取 image-component 的 asset ID attachments := extractAttachmentIds(detail.Issue.DescriptionHTML) // 清理 description_html 中的 image-component 标签(附件单独展示) cleanedHTML := removeImageComponents(detail.Issue.DescriptionHTML) return &FeedbackDetailResult{ Id: detail.Issue.ID, Title: cleanTitle(detail.Issue.Name), DescriptionHTML: cleanedHTML, Priority: detail.Issue.Priority, Status: detail.Status, CreatedAt: detail.Issue.CreatedAt, Attachments: attachments, }, nil } // imageComponentSrcRegex 匹配 ... var imageComponentSrcRegex = regexp.MustCompile(`]+src="([0-9a-f-]{36})"[^>]*>.*?
`) // extractAttachmentIds 从 description_html 中提取附件 asset ID 列表 func extractAttachmentIds(html string) []FeedbackAttachment { matches := imageComponentSrcRegex.FindAllStringSubmatch(html, -1) if len(matches) == 0 { return nil } var attachments []FeedbackAttachment for _, match := range matches { attachments = append(attachments, FeedbackAttachment{AssetId: match[1]}) } return attachments } // removeImageComponents 移除 description_html 中的 image-component 标签 func removeImageComponents(html string) string { return imageComponentSrcRegex.ReplaceAllString(html, "") } // mapPriority 映射优先级到 Plane 格式 func mapPriority(priority string) string { switch priority { case "urgent": return "urgent" case "high": return "high" case "medium": return "medium" case "low": return "low" default: return "none" } } // UploadFeedbackAsset 通过 Plane V2 Assets API 上传文件 func UploadFeedbackAsset(fileName string, contentType string, fileSize int64, fileData []byte) (*repositories.PlaneWebAssetUploadResult, error) { return repositories.Plane.UploadProjectAsset(fileName, contentType, fileData, repositories.PlaneWebEntityIssueDescription) } // ProxyFeedbackAsset 代理获取附件文件(后端从 Plane 下载后返回给前端) func ProxyFeedbackAsset(assetId string) (string, []byte, error) { // 获取预签名 URL presignedURL, err := repositories.Plane.GetProjectAssetURL(assetId) if err != nil { return "", nil, fmt.Errorf("获取附件URL失败: %w", err) } // 下载文件 resp, err := httpGet(presignedURL) if err != nil { return "", nil, fmt.Errorf("下载附件失败: %w", err) } return resp.ContentType, resp.Body, nil } // DeleteFeedbackAsset 删除已上传的附件 (V2) func DeleteFeedbackAsset(assetId string) error { return repositories.Plane.DeleteProjectAsset(assetId) } // httpGetResponse HTTP GET 响应 type httpGetResponse struct { ContentType string Body []byte } // httpGet 简单 HTTP GET 请求(用于下载预签名 URL 的文件) func httpGet(url string) (*httpGetResponse, error) { client := &http.Client{Timeout: 60 * time.Second} resp, err := client.Get(url) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("HTTP GET 失败: status=%d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("读取响应失败: %w", err) } contentType := resp.Header.Get("Content-Type") if contentType == "" { contentType = "application/octet-stream" } return &httpGetResponse{ ContentType: contentType, Body: body, }, nil }