feedbackServices.go 13 KB

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