| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414 |
- 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) {
- if repositories.Plane == nil {
- return nil, fmt.Errorf("Plane 未初始化(repositories.Plane 为 nil)")
- }
- // 构造标题(包含提交人标识,供历史查询时解析)
- 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 原生 <image-component> 格式,放在文本最后
- func buildDescriptionHTML(body dto.FeedbackSubmitDTO, userName string) string {
- var sb strings.Builder
- sb.WriteString(fmt.Sprintf(`<p class="editor-paragraph-block"><strong>提交人:</strong>%s</p>`, userName))
- sb.WriteString(fmt.Sprintf(`<p class="editor-paragraph-block"><strong>分类:</strong>%s</p>`, body.Category))
- if body.RelatedPage != nil && *body.RelatedPage != "" {
- sb.WriteString(fmt.Sprintf(`<p class="editor-paragraph-block"><strong>相关页面:</strong>%s</p>`, *body.RelatedPage))
- }
- sb.WriteString("<hr/>")
- if body.Type == "bug" {
- // Bug Report
- if body.Steps != nil && *body.Steps != "" {
- sb.WriteString(fmt.Sprintf(`<h3>复现步骤</h3><p class="editor-paragraph-block">%s</p>`, strings.ReplaceAll(*body.Steps, "\n", "<br/>")))
- }
- if body.Expected != nil && *body.Expected != "" {
- sb.WriteString(fmt.Sprintf(`<h3>期望结果</h3><p class="editor-paragraph-block">%s</p>`, strings.ReplaceAll(*body.Expected, "\n", "<br/>")))
- }
- if body.Actual != nil && *body.Actual != "" {
- sb.WriteString(fmt.Sprintf(`<h3>实际结果</h3><p class="editor-paragraph-block">%s</p>`, strings.ReplaceAll(*body.Actual, "\n", "<br/>")))
- }
- if body.Description != "" {
- sb.WriteString(fmt.Sprintf(`<h3>补充说明</h3><p class="editor-paragraph-block">%s</p>`, strings.ReplaceAll(body.Description, "\n", "<br/>")))
- }
- } else {
- // Feature Request
- if body.Description != "" {
- sb.WriteString(fmt.Sprintf(`<h3>需求描述</h3><p class="editor-paragraph-block">%s</p>`, strings.ReplaceAll(body.Description, "\n", "<br/>")))
- }
- if body.Scenario != nil && *body.Scenario != "" {
- sb.WriteString(fmt.Sprintf(`<h3>使用场景</h3><p class="editor-paragraph-block">%s</p>`, strings.ReplaceAll(*body.Scenario, "\n", "<br/>")))
- }
- }
- // 附件放在最后,带"附件"大标题
- if len(body.AssetIds) > 0 {
- sb.WriteString(`<h3>附件</h3>`)
- for _, assetId := range body.AssetIds {
- dataId := uuid.New().String()
- sb.WriteString(fmt.Sprintf(
- `<image-component data-id="%s" src="%s" id="%s" width="35%%" alignment="left" status="uploaded"></image-component>`,
- dataId, assetId, dataId,
- ))
- }
- }
- return sb.String()
- }
- // GetFeedbackHistory 查询 Plane Intake 列表 (V2 Web API)
- // 只返回带有 Feedback 标签的记录,包含所有状态(Pending/Accepted/Rejected/Snoozed/Duplicate)
- func GetFeedbackHistory(userName string) ([]FeedbackHistoryItem, error) {
- if repositories.Plane == nil {
- return nil, fmt.Errorf("Plane 未初始化(repositories.Plane 为 nil)")
- }
- // 查询所有状态的 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 匹配 <image-component ... src="assetId" ...>...</image-component>
- var imageComponentSrcRegex = regexp.MustCompile(`<image-component[^>]+src="([0-9a-f-]{36})"[^>]*>.*?</image-component>`)
- // 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
- }
|