feedbackServices.go 13 KB

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