support_ticket.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. package service
  2. import (
  3. "context"
  4. "database/sql"
  5. "errors"
  6. "strings"
  7. "time"
  8. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  9. )
  10. // 工单状态:open(待处理)/ answered(已回复)/ closed(已关闭)。
  11. const (
  12. TicketStatusOpen = "open"
  13. TicketStatusAnswered = "answered"
  14. TicketStatusClosed = "closed"
  15. )
  16. // TicketReply 工单回复。
  17. type TicketReply struct {
  18. ID int64 `json:"id"`
  19. TicketID int64 `json:"ticket_id"`
  20. Author string `json:"author"` // user / admin
  21. AuthorID int64 `json:"author_id"`
  22. Body string `json:"body"`
  23. CreatedAt time.Time `json:"created_at"`
  24. }
  25. // SupportTicket 工单(含首层回复列表由详情接口返回)。
  26. type SupportTicket struct {
  27. ID int64 `json:"id"`
  28. UserID int64 `json:"user_id"`
  29. UserEmail string `json:"user_email,omitempty"`
  30. Subject string `json:"subject"`
  31. Status string `json:"status"`
  32. CreatedAt time.Time `json:"created_at"`
  33. UpdatedAt time.Time `json:"updated_at"`
  34. ClosedAt *time.Time `json:"closed_at,omitempty"`
  35. Replies []TicketReply `json:"replies,omitempty"`
  36. }
  37. var (
  38. ErrTicketNotFound = errors.New("support ticket not found")
  39. ErrTicketClosed = errors.New("support ticket is closed")
  40. )
  41. // TicketService 轻量工单服务(SQL 直连,无 ent)。
  42. type TicketService struct {
  43. db *sql.DB
  44. }
  45. // NewTicketService 创建工单服务。
  46. func NewTicketService(db *sql.DB) *TicketService {
  47. return &TicketService{db: db}
  48. }
  49. func validateTicketSubject(subject string) (string, error) {
  50. subject = strings.TrimSpace(subject)
  51. if subject == "" {
  52. return "", infraerrors.BadRequest("TICKET_SUBJECT_REQUIRED", "subject must not be empty")
  53. }
  54. if len([]rune(subject)) > 200 {
  55. return "", infraerrors.BadRequest("TICKET_SUBJECT_TOO_LONG", "subject must not exceed 200 runes")
  56. }
  57. return subject, nil
  58. }
  59. func validateTicketBody(body string) (string, error) {
  60. body = strings.TrimSpace(body)
  61. if body == "" {
  62. return "", infraerrors.BadRequest("TICKET_BODY_REQUIRED", "body must not be empty")
  63. }
  64. if len([]rune(body)) > 20000 {
  65. return "", infraerrors.BadRequest("TICKET_BODY_TOO_LONG", "body must not exceed 20000 runes")
  66. }
  67. return body, nil
  68. }
  69. // Create 用户建单(含首条描述),状态 open。
  70. func (s *TicketService) Create(ctx context.Context, userID int64, subject, body string) (*SupportTicket, error) {
  71. if s == nil || s.db == nil {
  72. return nil, errors.New("ticket service unavailable")
  73. }
  74. subject, err := validateTicketSubject(subject)
  75. if err != nil {
  76. return nil, err
  77. }
  78. body, err = validateTicketBody(body)
  79. if err != nil {
  80. return nil, err
  81. }
  82. tx, err := s.db.BeginTx(ctx, nil)
  83. if err != nil {
  84. return nil, err
  85. }
  86. defer func() { _ = tx.Rollback() }()
  87. var t SupportTicket
  88. var closed sql.NullTime
  89. err = tx.QueryRowContext(ctx, `
  90. INSERT INTO support_tickets (user_id, subject, status, created_at, updated_at)
  91. VALUES ($1, $2, 'open', NOW(), NOW())
  92. RETURNING id, user_id, subject, status, created_at, updated_at, closed_at`,
  93. userID, subject).Scan(&t.ID, &t.UserID, &t.Subject, &t.Status, &t.CreatedAt, &t.UpdatedAt, &closed)
  94. if err != nil {
  95. return nil, err
  96. }
  97. if closed.Valid {
  98. t.ClosedAt = &closed.Time
  99. }
  100. var r TicketReply
  101. err = tx.QueryRowContext(ctx, `
  102. INSERT INTO support_ticket_replies (ticket_id, author_type, author_id, body, created_at)
  103. VALUES ($1, 'user', $2, $3, NOW())
  104. RETURNING id, ticket_id, author_type, author_id, body, created_at`,
  105. t.ID, userID, body).Scan(&r.ID, &r.TicketID, &r.Author, &r.AuthorID, &r.Body, &r.CreatedAt)
  106. if err != nil {
  107. return nil, err
  108. }
  109. if err := tx.Commit(); err != nil {
  110. return nil, err
  111. }
  112. t.Replies = []TicketReply{r}
  113. return &t, nil
  114. }
  115. func scanTicketRow(scan func(dest ...any) error) (*SupportTicket, error) {
  116. var t SupportTicket
  117. var closed sql.NullTime
  118. if err := scan(&t.ID, &t.UserID, &t.Subject, &t.Status, &t.CreatedAt, &t.UpdatedAt, &closed); err != nil {
  119. return nil, err
  120. }
  121. if closed.Valid {
  122. t.ClosedAt = &closed.Time
  123. }
  124. return &t, nil
  125. }
  126. // escapeTicketLike 转义 LIKE 通配符(% _ \),防搜索词改变语义。
  127. func escapeTicketLike(s string) string {
  128. r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
  129. return r.Replace(s)
  130. }
  131. const ticketColumns = `id, user_id, subject, status, created_at, updated_at, closed_at`
  132. // ListMine 用户工单列表(倒序,limit 上限 200)。
  133. func (s *TicketService) ListMine(ctx context.Context, userID int64) ([]SupportTicket, error) {
  134. if s == nil || s.db == nil {
  135. return nil, errors.New("ticket service unavailable")
  136. }
  137. rows, err := s.db.QueryContext(ctx, `
  138. SELECT `+ticketColumns+` FROM support_tickets
  139. WHERE user_id = $1 ORDER BY id DESC LIMIT 200`, userID)
  140. if err != nil {
  141. return nil, err
  142. }
  143. defer func() { _ = rows.Close() }()
  144. out := make([]SupportTicket, 0)
  145. for rows.Next() {
  146. t, err := scanTicketRow(rows.Scan)
  147. if err != nil {
  148. return nil, err
  149. }
  150. out = append(out, *t)
  151. }
  152. return out, rows.Err()
  153. }
  154. // ListAll 管理端列表:status 可选过滤,search 匹配主题/用户邮箱,offset 分页。
  155. func (s *TicketService) ListAll(ctx context.Context, status, search string, limit, offset int) ([]SupportTicket, error) {
  156. if s == nil || s.db == nil {
  157. return nil, errors.New("ticket service unavailable")
  158. }
  159. if limit <= 0 || limit > 500 {
  160. limit = 100
  161. }
  162. if offset < 0 {
  163. offset = 0
  164. }
  165. q := `
  166. SELECT t.id, t.user_id, t.subject, t.status, t.created_at, t.updated_at, t.closed_at,
  167. COALESCE(u.email, '')
  168. FROM support_tickets t
  169. LEFT JOIN users u ON u.id = t.user_id
  170. WHERE ($1 = '' OR t.status = $1)
  171. AND ($2 = '' OR t.subject ILIKE '%' || $2 || '%' ESCAPE '\' OR COALESCE(u.email, '') ILIKE '%' || $2 || '%' ESCAPE '\')
  172. ORDER BY t.id DESC LIMIT $3 OFFSET $4`
  173. rows, err := s.db.QueryContext(ctx, q, strings.TrimSpace(status), escapeTicketLike(strings.TrimSpace(search)), limit, offset)
  174. if err != nil {
  175. return nil, err
  176. }
  177. defer func() { _ = rows.Close() }()
  178. out := make([]SupportTicket, 0)
  179. for rows.Next() {
  180. var t SupportTicket
  181. if err := rows.Scan(&t.ID, &t.UserID, &t.Subject, &t.Status, &t.CreatedAt, &t.UpdatedAt, &t.ClosedAt, &t.UserEmail); err != nil {
  182. return nil, err
  183. }
  184. out = append(out, t)
  185. }
  186. return out, rows.Err()
  187. }
  188. // GetMine 用户查看自己的工单(含回复)。
  189. func (s *TicketService) GetMine(ctx context.Context, userID, id int64) (*SupportTicket, error) {
  190. t, err := s.getByID(ctx, id)
  191. if err != nil {
  192. return nil, err
  193. }
  194. if t.UserID != userID {
  195. return nil, ErrTicketNotFound
  196. }
  197. return s.withReplies(ctx, t)
  198. }
  199. // GetAny 管理端查看任意工单(含回复与用户邮箱)。
  200. func (s *TicketService) GetAny(ctx context.Context, id int64) (*SupportTicket, error) {
  201. t, err := s.getByID(ctx, id)
  202. if err != nil {
  203. return nil, err
  204. }
  205. var email string
  206. _ = s.db.QueryRowContext(ctx, `SELECT COALESCE(email,'') FROM users WHERE id = $1`, t.UserID).Scan(&email)
  207. t.UserEmail = email
  208. return s.withReplies(ctx, t)
  209. }
  210. func (s *TicketService) getByID(ctx context.Context, id int64) (*SupportTicket, error) {
  211. var t SupportTicket
  212. var closed sql.NullTime
  213. err := s.db.QueryRowContext(ctx, `
  214. SELECT `+ticketColumns+` FROM support_tickets WHERE id = $1`, id).
  215. Scan(&t.ID, &t.UserID, &t.Subject, &t.Status, &t.CreatedAt, &t.UpdatedAt, &closed)
  216. if err != nil {
  217. if errors.Is(err, sql.ErrNoRows) {
  218. return nil, ErrTicketNotFound
  219. }
  220. return nil, err
  221. }
  222. if closed.Valid {
  223. t.ClosedAt = &closed.Time
  224. }
  225. return &t, nil
  226. }
  227. func (s *TicketService) withReplies(ctx context.Context, t *SupportTicket) (*SupportTicket, error) {
  228. rows, err := s.db.QueryContext(ctx, `
  229. SELECT id, ticket_id, author_type, author_id, body, created_at
  230. FROM support_ticket_replies WHERE ticket_id = $1 ORDER BY id`, t.ID)
  231. if err != nil {
  232. return nil, err
  233. }
  234. defer func() { _ = rows.Close() }()
  235. t.Replies = make([]TicketReply, 0)
  236. for rows.Next() {
  237. var r TicketReply
  238. if err := rows.Scan(&r.ID, &r.TicketID, &r.Author, &r.AuthorID, &r.Body, &r.CreatedAt); err != nil {
  239. return nil, err
  240. }
  241. t.Replies = append(t.Replies, r)
  242. }
  243. return t, rows.Err()
  244. }
  245. // ReplyMine 用户追问(关闭后不可再回;追问后状态回到 open 待处理)。
  246. func (s *TicketService) ReplyMine(ctx context.Context, userID, id int64, body string) (*TicketReply, error) {
  247. t, err := s.GetMine(ctx, userID, id)
  248. if err != nil {
  249. return nil, err
  250. }
  251. return s.addReply(ctx, t, "user", userID, body, TicketStatusOpen)
  252. }
  253. // ReplyAny 管理员回复(状态置 answered)。
  254. func (s *TicketService) ReplyAny(ctx context.Context, adminID, id int64, body string) (*TicketReply, error) {
  255. t, err := s.getByID(ctx, id)
  256. if err != nil {
  257. return nil, err
  258. }
  259. return s.addReply(ctx, t, "admin", adminID, body, TicketStatusAnswered)
  260. }
  261. func (s *TicketService) addReply(ctx context.Context, t *SupportTicket, author string, authorID int64, body, nextStatus string) (*TicketReply, error) {
  262. if t.Status == TicketStatusClosed {
  263. return nil, ErrTicketClosed
  264. }
  265. body, err := validateTicketBody(body)
  266. if err != nil {
  267. return nil, err
  268. }
  269. tx, err := s.db.BeginTx(ctx, nil)
  270. if err != nil {
  271. return nil, err
  272. }
  273. defer func() { _ = tx.Rollback() }()
  274. var r TicketReply
  275. err = tx.QueryRowContext(ctx, `
  276. INSERT INTO support_ticket_replies (ticket_id, author_type, author_id, body, created_at)
  277. VALUES ($1, $2, $3, $4, NOW())
  278. RETURNING id, ticket_id, author_type, author_id, body, created_at`,
  279. t.ID, author, authorID, body).Scan(&r.ID, &r.TicketID, &r.Author, &r.AuthorID, &r.Body, &r.CreatedAt)
  280. if err != nil {
  281. return nil, err
  282. }
  283. if _, err := tx.ExecContext(ctx, `
  284. UPDATE support_tickets SET status = $2, updated_at = NOW(), closed_at = NULL WHERE id = $1`,
  285. t.ID, nextStatus); err != nil {
  286. return nil, err
  287. }
  288. if err := tx.Commit(); err != nil {
  289. return nil, err
  290. }
  291. return &r, nil
  292. }
  293. // CloseMine 用户关闭自己的工单。
  294. func (s *TicketService) CloseMine(ctx context.Context, userID, id int64) error {
  295. t, err := s.GetMine(ctx, userID, id)
  296. if err != nil {
  297. return err
  298. }
  299. return s.close(ctx, t.ID)
  300. }
  301. // CloseAny 管理员关闭任意工单。
  302. func (s *TicketService) CloseAny(ctx context.Context, id int64) error {
  303. t, err := s.getByID(ctx, id)
  304. if err != nil {
  305. return err
  306. }
  307. return s.close(ctx, t.ID)
  308. }
  309. func (s *TicketService) close(ctx context.Context, id int64) error {
  310. // 已关闭不再刷新 closed_at(幂等)。
  311. _, err := s.db.ExecContext(ctx, `
  312. UPDATE support_tickets SET status = 'closed', updated_at = NOW(), closed_at = COALESCE(closed_at, NOW())
  313. WHERE id = $1 AND status <> 'closed'`, id)
  314. return err
  315. }
  316. // OpenCount 未关闭工单数(管理端角标)。
  317. func (s *TicketService) OpenCount(ctx context.Context) (int64, error) {
  318. if s == nil || s.db == nil {
  319. return 0, nil
  320. }
  321. var n int64
  322. if err := s.db.QueryRowContext(ctx, `
  323. SELECT COUNT(*) FROM support_tickets WHERE status <> 'closed'`).Scan(&n); err != nil {
  324. return 0, err
  325. }
  326. return n, nil
  327. }