feedbackServices.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408
  1. package services
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "fwh-toolbox-server/dto"
  6. "fwh-toolbox-server/repositories"
  7. "io"
  8. "net/http"
  9. "regexp"
  10. "strings"
  11. "time"
  12. "github.com/google/uuid"
  13. "go.uber.org/zap"
  14. )
  15. // FeedbackHistoryItem 返回给前端的历史记录条目
  16. type FeedbackHistoryItem struct {
  17. Id string `json:"id"`
  18. Title string `json:"title"`
  19. Type string `json:"type"` // bug / feature
  20. Status int `json:"status"` // -2 Pending, -1 Rejected, 0 Snoozed, 1 Accepted, 2 Duplicate
  21. Priority string `json:"priority"`
  22. CreatedAt string `json:"createdAt"`
  23. CreatedBy string `json:"createdBy"` // 提交人用户名
  24. }
  25. // SubmitFeedback 提交反馈到 Plane Intake (V2 Web API)
  26. // 流程:1. 构造HTML(含image-component) → 2. 创建IntakeIssue → 3. 绑定Asset到Issue
  27. func SubmitFeedback(body dto.FeedbackSubmitDTO, userName string) (*repositories.PlaneWebIntakeIssueResponse, error) {
  28. // 构造标题(包含提交人标识,供历史查询时解析)
  29. var prefix string
  30. if body.Type == "bug" {
  31. prefix = "[Bug]"
  32. } else {
  33. prefix = "[Feature]"
  34. }
  35. title := fmt.Sprintf("%s %s 「%s」", prefix, body.Title, userName)
  36. // 构造描述 HTML(附件使用 image-component 格式)
  37. descHTML := buildDescriptionHTML(body, userName)
  38. // 选择 Label(始终添加 Feedback 标签 + 类型标签)
  39. var labelIds []string
  40. if repositories.Plane.LabelFeedbackId != "" {
  41. labelIds = append(labelIds, repositories.Plane.LabelFeedbackId)
  42. }
  43. if body.Type == "bug" && repositories.Plane.LabelBugId != "" {
  44. labelIds = append(labelIds, repositories.Plane.LabelBugId)
  45. } else if body.Type == "feature" && repositories.Plane.LabelFeatureId != "" {
  46. labelIds = append(labelIds, repositories.Plane.LabelFeatureId)
  47. }
  48. // 映射优先级
  49. priority := mapPriority(body.Priority)
  50. // 构造请求体 (Plane V2 Web API)
  51. req := repositories.PlaneWebIntakeIssueRequest{
  52. Name: title,
  53. DescriptionHTML: descHTML,
  54. Priority: priority,
  55. LabelIDs: labelIds,
  56. }
  57. // 调用 Plane V2 Web Intake API 创建Issue
  58. result, err := repositories.Plane.CreateIntakeIssue(req)
  59. if err != nil {
  60. return nil, err
  61. }
  62. issueId := result.Issue.ID
  63. // 创建成功后,如果有附件,将 Asset 绑定到 Issue Entity
  64. if len(body.AssetIds) > 0 {
  65. if err := repositories.Plane.BindAssetsToEntity(issueId, body.AssetIds); err != nil {
  66. // 绑定失败不阻塞整体提交,但记录警告
  67. zap.L().Warn("[Feedback] 绑定附件到Issue失败",
  68. zap.String("issueId", issueId),
  69. zap.Strings("assetIds", body.AssetIds),
  70. zap.Error(err),
  71. )
  72. } else {
  73. zap.L().Info("[Feedback] 附件绑定成功",
  74. zap.String("issueId", issueId),
  75. zap.Int("assetCount", len(body.AssetIds)),
  76. )
  77. }
  78. }
  79. zap.L().Info("[Feedback] 提交成功",
  80. zap.String("issueId", issueId),
  81. zap.String("user", userName),
  82. zap.String("type", body.Type),
  83. zap.String("title", body.Title),
  84. )
  85. return result, nil
  86. }
  87. // buildDescriptionHTML 构造提交描述的 HTML
  88. // 附件使用 Plane 原生 <image-component> 格式,放在文本最后
  89. func buildDescriptionHTML(body dto.FeedbackSubmitDTO, userName string) string {
  90. var sb strings.Builder
  91. sb.WriteString(fmt.Sprintf(`<p class="editor-paragraph-block"><strong>提交人:</strong>%s</p>`, userName))
  92. sb.WriteString(fmt.Sprintf(`<p class="editor-paragraph-block"><strong>分类:</strong>%s</p>`, body.Category))
  93. if body.RelatedPage != nil && *body.RelatedPage != "" {
  94. sb.WriteString(fmt.Sprintf(`<p class="editor-paragraph-block"><strong>相关页面:</strong>%s</p>`, *body.RelatedPage))
  95. }
  96. sb.WriteString("<hr/>")
  97. if body.Type == "bug" {
  98. // Bug Report
  99. if body.Steps != nil && *body.Steps != "" {
  100. sb.WriteString(fmt.Sprintf(`<h3>复现步骤</h3><p class="editor-paragraph-block">%s</p>`, strings.ReplaceAll(*body.Steps, "\n", "<br/>")))
  101. }
  102. if body.Expected != nil && *body.Expected != "" {
  103. sb.WriteString(fmt.Sprintf(`<h3>期望结果</h3><p class="editor-paragraph-block">%s</p>`, strings.ReplaceAll(*body.Expected, "\n", "<br/>")))
  104. }
  105. if body.Actual != nil && *body.Actual != "" {
  106. sb.WriteString(fmt.Sprintf(`<h3>实际结果</h3><p class="editor-paragraph-block">%s</p>`, strings.ReplaceAll(*body.Actual, "\n", "<br/>")))
  107. }
  108. if body.Description != "" {
  109. sb.WriteString(fmt.Sprintf(`<h3>补充说明</h3><p class="editor-paragraph-block">%s</p>`, strings.ReplaceAll(body.Description, "\n", "<br/>")))
  110. }
  111. } else {
  112. // Feature Request
  113. if body.Description != "" {
  114. sb.WriteString(fmt.Sprintf(`<h3>需求描述</h3><p class="editor-paragraph-block">%s</p>`, strings.ReplaceAll(body.Description, "\n", "<br/>")))
  115. }
  116. if body.Scenario != nil && *body.Scenario != "" {
  117. sb.WriteString(fmt.Sprintf(`<h3>使用场景</h3><p class="editor-paragraph-block">%s</p>`, strings.ReplaceAll(*body.Scenario, "\n", "<br/>")))
  118. }
  119. }
  120. // 附件放在最后,带"附件"大标题
  121. if len(body.AssetIds) > 0 {
  122. sb.WriteString(`<h3>附件</h3>`)
  123. for _, assetId := range body.AssetIds {
  124. dataId := uuid.New().String()
  125. sb.WriteString(fmt.Sprintf(
  126. `<image-component data-id="%s" src="%s" id="%s" width="35%%" alignment="left" status="uploaded"></image-component>`,
  127. dataId, assetId, dataId,
  128. ))
  129. }
  130. }
  131. return sb.String()
  132. }
  133. // GetFeedbackHistory 查询 Plane Intake 列表 (V2 Web API)
  134. // 只返回带有 Feedback 标签的记录,包含所有状态(Pending/Accepted/Rejected/Snoozed/Duplicate)
  135. func GetFeedbackHistory(userName string) ([]FeedbackHistoryItem, error) {
  136. // 查询所有状态的 Intake 记录:-2 Pending, -1 Rejected, 0 Snoozed, 1 Accepted, 2 Duplicate
  137. data, err := repositories.Plane.ListIntakeIssues("-2,-1,0,1,2")
  138. if err != nil {
  139. return nil, err
  140. }
  141. feedbackLabelId := repositories.Plane.LabelFeedbackId
  142. // V2 返回的是 raw JSON,直接解析
  143. type intakeItem struct {
  144. Status int `json:"status"`
  145. Issue struct {
  146. ID string `json:"id"`
  147. Name string `json:"name"`
  148. Priority string `json:"priority"`
  149. LabelIDs []string `json:"label_ids"`
  150. CreatedAt string `json:"created_at"`
  151. CreatedBy string `json:"created_by"`
  152. } `json:"issue"`
  153. }
  154. var items []intakeItem
  155. if err := json.Unmarshal(data, &items); err != nil {
  156. // 可能是分页格式
  157. var paged struct {
  158. Results []intakeItem `json:"results"`
  159. }
  160. if err2 := json.Unmarshal(data, &paged); err2 != nil {
  161. return nil, fmt.Errorf("解析Intake列表失败: %w", err)
  162. }
  163. items = paged.Results
  164. }
  165. result := []FeedbackHistoryItem{}
  166. for _, item := range items {
  167. // 只显示带有 Feedback 标签的记录
  168. if feedbackLabelId != "" && !containsLabel(item.Issue.LabelIDs, feedbackLabelId) {
  169. continue
  170. }
  171. result = append(result, FeedbackHistoryItem{
  172. Id: item.Issue.ID,
  173. Title: cleanTitle(item.Issue.Name),
  174. Type: extractType(item.Issue.Name),
  175. Status: item.Status,
  176. Priority: item.Issue.Priority,
  177. CreatedAt: item.Issue.CreatedAt,
  178. CreatedBy: extractCreator(item.Issue.Name),
  179. })
  180. }
  181. return result, nil
  182. }
  183. // extractCreator 从标题中提取提交人(格式:[Type] title 「username」)
  184. func extractCreator(title string) string {
  185. start := strings.LastIndex(title, "「")
  186. end := strings.LastIndex(title, "」")
  187. if start >= 0 && end > start {
  188. return title[start+len("「") : end]
  189. }
  190. return ""
  191. }
  192. // extractType 从标题中提取类型
  193. func extractType(title string) string {
  194. if strings.HasPrefix(title, "[Bug]") {
  195. return "bug"
  196. }
  197. if strings.HasPrefix(title, "[Feature]") {
  198. return "feature"
  199. }
  200. return ""
  201. }
  202. // cleanTitle 从标题中移除类型前缀和提交人后缀,返回纯标题
  203. func cleanTitle(title string) string {
  204. // 移除 [Bug] 或 [Feature] 前缀
  205. title = strings.TrimPrefix(title, "[Bug] ")
  206. title = strings.TrimPrefix(title, "[Feature] ")
  207. // 移除末尾的 「username」
  208. if idx := strings.LastIndex(title, " 「"); idx >= 0 {
  209. title = title[:idx]
  210. }
  211. return title
  212. }
  213. // containsLabel 判断标签列表中是否包含指定标签
  214. func containsLabel(labels []string, target string) bool {
  215. for _, l := range labels {
  216. if l == target {
  217. return true
  218. }
  219. }
  220. return false
  221. }
  222. // FeedbackDetailResult 反馈详情
  223. type FeedbackDetailResult struct {
  224. Id string `json:"id"`
  225. Title string `json:"title"`
  226. DescriptionHTML string `json:"descriptionHtml"`
  227. Priority string `json:"priority"`
  228. Status int `json:"status"`
  229. CreatedAt string `json:"createdAt"`
  230. Attachments []FeedbackAttachment `json:"attachments"`
  231. }
  232. // FeedbackAttachment 附件信息
  233. type FeedbackAttachment struct {
  234. AssetId string `json:"assetId"`
  235. }
  236. // GetFeedbackDetail 获取单条反馈详情(含 description_html 和附件 ID 列表)
  237. func GetFeedbackDetail(issueId string) (*FeedbackDetailResult, error) {
  238. data, err := repositories.Plane.GetIntakeIssue(issueId)
  239. if err != nil {
  240. return nil, err
  241. }
  242. var detail struct {
  243. Status int `json:"status"`
  244. Issue struct {
  245. ID string `json:"id"`
  246. Name string `json:"name"`
  247. DescriptionHTML string `json:"description_html"`
  248. Priority string `json:"priority"`
  249. CreatedAt string `json:"created_at"`
  250. } `json:"issue"`
  251. }
  252. if err := json.Unmarshal(data, &detail); err != nil {
  253. return nil, fmt.Errorf("解析反馈详情失败: %w", err)
  254. }
  255. // 从 description_html 中提取 image-component 的 asset ID
  256. attachments := extractAttachmentIds(detail.Issue.DescriptionHTML)
  257. // 清理 description_html 中的 image-component 标签(附件单独展示)
  258. cleanedHTML := removeImageComponents(detail.Issue.DescriptionHTML)
  259. return &FeedbackDetailResult{
  260. Id: detail.Issue.ID,
  261. Title: cleanTitle(detail.Issue.Name),
  262. DescriptionHTML: cleanedHTML,
  263. Priority: detail.Issue.Priority,
  264. Status: detail.Status,
  265. CreatedAt: detail.Issue.CreatedAt,
  266. Attachments: attachments,
  267. }, nil
  268. }
  269. // imageComponentSrcRegex 匹配 <image-component ... src="assetId" ...>...</image-component>
  270. var imageComponentSrcRegex = regexp.MustCompile(`<image-component[^>]+src="([0-9a-f-]{36})"[^>]*>.*?</image-component>`)
  271. // extractAttachmentIds 从 description_html 中提取附件 asset ID 列表
  272. func extractAttachmentIds(html string) []FeedbackAttachment {
  273. matches := imageComponentSrcRegex.FindAllStringSubmatch(html, -1)
  274. if len(matches) == 0 {
  275. return nil
  276. }
  277. var attachments []FeedbackAttachment
  278. for _, match := range matches {
  279. attachments = append(attachments, FeedbackAttachment{AssetId: match[1]})
  280. }
  281. return attachments
  282. }
  283. // removeImageComponents 移除 description_html 中的 image-component 标签
  284. func removeImageComponents(html string) string {
  285. return imageComponentSrcRegex.ReplaceAllString(html, "")
  286. }
  287. // mapPriority 映射优先级到 Plane 格式
  288. func mapPriority(priority string) string {
  289. switch priority {
  290. case "urgent":
  291. return "urgent"
  292. case "high":
  293. return "high"
  294. case "medium":
  295. return "medium"
  296. case "low":
  297. return "low"
  298. default:
  299. return "none"
  300. }
  301. }
  302. // UploadFeedbackAsset 通过 Plane V2 Assets API 上传文件
  303. func UploadFeedbackAsset(fileName string, contentType string, fileSize int64, fileData []byte) (*repositories.PlaneWebAssetUploadResult, error) {
  304. return repositories.Plane.UploadProjectAsset(fileName, contentType, fileData, repositories.PlaneWebEntityIssueDescription)
  305. }
  306. // ProxyFeedbackAsset 代理获取附件文件(后端从 Plane 下载后返回给前端)
  307. func ProxyFeedbackAsset(assetId string) (string, []byte, error) {
  308. // 获取预签名 URL
  309. presignedURL, err := repositories.Plane.GetProjectAssetURL(assetId)
  310. if err != nil {
  311. return "", nil, fmt.Errorf("获取附件URL失败: %w", err)
  312. }
  313. // 下载文件
  314. resp, err := httpGet(presignedURL)
  315. if err != nil {
  316. return "", nil, fmt.Errorf("下载附件失败: %w", err)
  317. }
  318. return resp.ContentType, resp.Body, nil
  319. }
  320. // DeleteFeedbackAsset 删除已上传的附件 (V2)
  321. func DeleteFeedbackAsset(assetId string) error {
  322. return repositories.Plane.DeleteProjectAsset(assetId)
  323. }
  324. // httpGetResponse HTTP GET 响应
  325. type httpGetResponse struct {
  326. ContentType string
  327. Body []byte
  328. }
  329. // httpGet 简单 HTTP GET 请求(用于下载预签名 URL 的文件)
  330. func httpGet(url string) (*httpGetResponse, error) {
  331. client := &http.Client{Timeout: 60 * time.Second}
  332. resp, err := client.Get(url)
  333. if err != nil {
  334. return nil, err
  335. }
  336. defer resp.Body.Close()
  337. if resp.StatusCode < 200 || resp.StatusCode >= 300 {
  338. return nil, fmt.Errorf("HTTP GET 失败: status=%d", resp.StatusCode)
  339. }
  340. body, err := io.ReadAll(resp.Body)
  341. if err != nil {
  342. return nil, fmt.Errorf("读取响应失败: %w", err)
  343. }
  344. contentType := resp.Header.Get("Content-Type")
  345. if contentType == "" {
  346. contentType = "application/octet-stream"
  347. }
  348. return &httpGetResponse{
  349. ContentType: contentType,
  350. Body: body,
  351. }, nil
  352. }