| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354 |
- package service
- import (
- "context"
- "database/sql"
- "errors"
- "strings"
- "time"
- infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
- )
- // 工单状态:open(待处理)/ answered(已回复)/ closed(已关闭)。
- const (
- TicketStatusOpen = "open"
- TicketStatusAnswered = "answered"
- TicketStatusClosed = "closed"
- )
- // TicketReply 工单回复。
- type TicketReply struct {
- ID int64 `json:"id"`
- TicketID int64 `json:"ticket_id"`
- Author string `json:"author"` // user / admin
- AuthorID int64 `json:"author_id"`
- Body string `json:"body"`
- CreatedAt time.Time `json:"created_at"`
- }
- // SupportTicket 工单(含首层回复列表由详情接口返回)。
- type SupportTicket struct {
- ID int64 `json:"id"`
- UserID int64 `json:"user_id"`
- UserEmail string `json:"user_email,omitempty"`
- Subject string `json:"subject"`
- Status string `json:"status"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
- ClosedAt *time.Time `json:"closed_at,omitempty"`
- Replies []TicketReply `json:"replies,omitempty"`
- }
- var (
- ErrTicketNotFound = errors.New("support ticket not found")
- ErrTicketClosed = errors.New("support ticket is closed")
- )
- // TicketService 轻量工单服务(SQL 直连,无 ent)。
- type TicketService struct {
- db *sql.DB
- }
- // NewTicketService 创建工单服务。
- func NewTicketService(db *sql.DB) *TicketService {
- return &TicketService{db: db}
- }
- func validateTicketSubject(subject string) (string, error) {
- subject = strings.TrimSpace(subject)
- if subject == "" {
- return "", infraerrors.BadRequest("TICKET_SUBJECT_REQUIRED", "subject must not be empty")
- }
- if len([]rune(subject)) > 200 {
- return "", infraerrors.BadRequest("TICKET_SUBJECT_TOO_LONG", "subject must not exceed 200 runes")
- }
- return subject, nil
- }
- func validateTicketBody(body string) (string, error) {
- body = strings.TrimSpace(body)
- if body == "" {
- return "", infraerrors.BadRequest("TICKET_BODY_REQUIRED", "body must not be empty")
- }
- if len([]rune(body)) > 20000 {
- return "", infraerrors.BadRequest("TICKET_BODY_TOO_LONG", "body must not exceed 20000 runes")
- }
- return body, nil
- }
- // Create 用户建单(含首条描述),状态 open。
- func (s *TicketService) Create(ctx context.Context, userID int64, subject, body string) (*SupportTicket, error) {
- if s == nil || s.db == nil {
- return nil, errors.New("ticket service unavailable")
- }
- subject, err := validateTicketSubject(subject)
- if err != nil {
- return nil, err
- }
- body, err = validateTicketBody(body)
- if err != nil {
- return nil, err
- }
- tx, err := s.db.BeginTx(ctx, nil)
- if err != nil {
- return nil, err
- }
- defer func() { _ = tx.Rollback() }()
- var t SupportTicket
- var closed sql.NullTime
- err = tx.QueryRowContext(ctx, `
- INSERT INTO support_tickets (user_id, subject, status, created_at, updated_at)
- VALUES ($1, $2, 'open', NOW(), NOW())
- RETURNING id, user_id, subject, status, created_at, updated_at, closed_at`,
- userID, subject).Scan(&t.ID, &t.UserID, &t.Subject, &t.Status, &t.CreatedAt, &t.UpdatedAt, &closed)
- if err != nil {
- return nil, err
- }
- if closed.Valid {
- t.ClosedAt = &closed.Time
- }
- var r TicketReply
- err = tx.QueryRowContext(ctx, `
- INSERT INTO support_ticket_replies (ticket_id, author_type, author_id, body, created_at)
- VALUES ($1, 'user', $2, $3, NOW())
- RETURNING id, ticket_id, author_type, author_id, body, created_at`,
- t.ID, userID, body).Scan(&r.ID, &r.TicketID, &r.Author, &r.AuthorID, &r.Body, &r.CreatedAt)
- if err != nil {
- return nil, err
- }
- if err := tx.Commit(); err != nil {
- return nil, err
- }
- t.Replies = []TicketReply{r}
- return &t, nil
- }
- func scanTicketRow(scan func(dest ...any) error) (*SupportTicket, error) {
- var t SupportTicket
- var closed sql.NullTime
- if err := scan(&t.ID, &t.UserID, &t.Subject, &t.Status, &t.CreatedAt, &t.UpdatedAt, &closed); err != nil {
- return nil, err
- }
- if closed.Valid {
- t.ClosedAt = &closed.Time
- }
- return &t, nil
- }
- // escapeTicketLike 转义 LIKE 通配符(% _ \),防搜索词改变语义。
- func escapeTicketLike(s string) string {
- r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
- return r.Replace(s)
- }
- const ticketColumns = `id, user_id, subject, status, created_at, updated_at, closed_at`
- // ListMine 用户工单列表(倒序,limit 上限 200)。
- func (s *TicketService) ListMine(ctx context.Context, userID int64) ([]SupportTicket, error) {
- if s == nil || s.db == nil {
- return nil, errors.New("ticket service unavailable")
- }
- rows, err := s.db.QueryContext(ctx, `
- SELECT `+ticketColumns+` FROM support_tickets
- WHERE user_id = $1 ORDER BY id DESC LIMIT 200`, userID)
- if err != nil {
- return nil, err
- }
- defer func() { _ = rows.Close() }()
- out := make([]SupportTicket, 0)
- for rows.Next() {
- t, err := scanTicketRow(rows.Scan)
- if err != nil {
- return nil, err
- }
- out = append(out, *t)
- }
- return out, rows.Err()
- }
- // ListAll 管理端列表:status 可选过滤,search 匹配主题/用户邮箱,offset 分页。
- func (s *TicketService) ListAll(ctx context.Context, status, search string, limit, offset int) ([]SupportTicket, error) {
- if s == nil || s.db == nil {
- return nil, errors.New("ticket service unavailable")
- }
- if limit <= 0 || limit > 500 {
- limit = 100
- }
- if offset < 0 {
- offset = 0
- }
- q := `
- SELECT t.id, t.user_id, t.subject, t.status, t.created_at, t.updated_at, t.closed_at,
- COALESCE(u.email, '')
- FROM support_tickets t
- LEFT JOIN users u ON u.id = t.user_id
- WHERE ($1 = '' OR t.status = $1)
- AND ($2 = '' OR t.subject ILIKE '%' || $2 || '%' ESCAPE '\' OR COALESCE(u.email, '') ILIKE '%' || $2 || '%' ESCAPE '\')
- ORDER BY t.id DESC LIMIT $3 OFFSET $4`
- rows, err := s.db.QueryContext(ctx, q, strings.TrimSpace(status), escapeTicketLike(strings.TrimSpace(search)), limit, offset)
- if err != nil {
- return nil, err
- }
- defer func() { _ = rows.Close() }()
- out := make([]SupportTicket, 0)
- for rows.Next() {
- var t SupportTicket
- if err := rows.Scan(&t.ID, &t.UserID, &t.Subject, &t.Status, &t.CreatedAt, &t.UpdatedAt, &t.ClosedAt, &t.UserEmail); err != nil {
- return nil, err
- }
- out = append(out, t)
- }
- return out, rows.Err()
- }
- // GetMine 用户查看自己的工单(含回复)。
- func (s *TicketService) GetMine(ctx context.Context, userID, id int64) (*SupportTicket, error) {
- t, err := s.getByID(ctx, id)
- if err != nil {
- return nil, err
- }
- if t.UserID != userID {
- return nil, ErrTicketNotFound
- }
- return s.withReplies(ctx, t)
- }
- // GetAny 管理端查看任意工单(含回复与用户邮箱)。
- func (s *TicketService) GetAny(ctx context.Context, id int64) (*SupportTicket, error) {
- t, err := s.getByID(ctx, id)
- if err != nil {
- return nil, err
- }
- var email string
- _ = s.db.QueryRowContext(ctx, `SELECT COALESCE(email,'') FROM users WHERE id = $1`, t.UserID).Scan(&email)
- t.UserEmail = email
- return s.withReplies(ctx, t)
- }
- func (s *TicketService) getByID(ctx context.Context, id int64) (*SupportTicket, error) {
- var t SupportTicket
- var closed sql.NullTime
- err := s.db.QueryRowContext(ctx, `
- SELECT `+ticketColumns+` FROM support_tickets WHERE id = $1`, id).
- Scan(&t.ID, &t.UserID, &t.Subject, &t.Status, &t.CreatedAt, &t.UpdatedAt, &closed)
- if err != nil {
- if errors.Is(err, sql.ErrNoRows) {
- return nil, ErrTicketNotFound
- }
- return nil, err
- }
- if closed.Valid {
- t.ClosedAt = &closed.Time
- }
- return &t, nil
- }
- func (s *TicketService) withReplies(ctx context.Context, t *SupportTicket) (*SupportTicket, error) {
- rows, err := s.db.QueryContext(ctx, `
- SELECT id, ticket_id, author_type, author_id, body, created_at
- FROM support_ticket_replies WHERE ticket_id = $1 ORDER BY id`, t.ID)
- if err != nil {
- return nil, err
- }
- defer func() { _ = rows.Close() }()
- t.Replies = make([]TicketReply, 0)
- for rows.Next() {
- var r TicketReply
- if err := rows.Scan(&r.ID, &r.TicketID, &r.Author, &r.AuthorID, &r.Body, &r.CreatedAt); err != nil {
- return nil, err
- }
- t.Replies = append(t.Replies, r)
- }
- return t, rows.Err()
- }
- // ReplyMine 用户追问(关闭后不可再回;追问后状态回到 open 待处理)。
- func (s *TicketService) ReplyMine(ctx context.Context, userID, id int64, body string) (*TicketReply, error) {
- t, err := s.GetMine(ctx, userID, id)
- if err != nil {
- return nil, err
- }
- return s.addReply(ctx, t, "user", userID, body, TicketStatusOpen)
- }
- // ReplyAny 管理员回复(状态置 answered)。
- func (s *TicketService) ReplyAny(ctx context.Context, adminID, id int64, body string) (*TicketReply, error) {
- t, err := s.getByID(ctx, id)
- if err != nil {
- return nil, err
- }
- return s.addReply(ctx, t, "admin", adminID, body, TicketStatusAnswered)
- }
- func (s *TicketService) addReply(ctx context.Context, t *SupportTicket, author string, authorID int64, body, nextStatus string) (*TicketReply, error) {
- if t.Status == TicketStatusClosed {
- return nil, ErrTicketClosed
- }
- body, err := validateTicketBody(body)
- if err != nil {
- return nil, err
- }
- tx, err := s.db.BeginTx(ctx, nil)
- if err != nil {
- return nil, err
- }
- defer func() { _ = tx.Rollback() }()
- var r TicketReply
- err = tx.QueryRowContext(ctx, `
- INSERT INTO support_ticket_replies (ticket_id, author_type, author_id, body, created_at)
- VALUES ($1, $2, $3, $4, NOW())
- RETURNING id, ticket_id, author_type, author_id, body, created_at`,
- t.ID, author, authorID, body).Scan(&r.ID, &r.TicketID, &r.Author, &r.AuthorID, &r.Body, &r.CreatedAt)
- if err != nil {
- return nil, err
- }
- if _, err := tx.ExecContext(ctx, `
- UPDATE support_tickets SET status = $2, updated_at = NOW(), closed_at = NULL WHERE id = $1`,
- t.ID, nextStatus); err != nil {
- return nil, err
- }
- if err := tx.Commit(); err != nil {
- return nil, err
- }
- return &r, nil
- }
- // CloseMine 用户关闭自己的工单。
- func (s *TicketService) CloseMine(ctx context.Context, userID, id int64) error {
- t, err := s.GetMine(ctx, userID, id)
- if err != nil {
- return err
- }
- return s.close(ctx, t.ID)
- }
- // CloseAny 管理员关闭任意工单。
- func (s *TicketService) CloseAny(ctx context.Context, id int64) error {
- t, err := s.getByID(ctx, id)
- if err != nil {
- return err
- }
- return s.close(ctx, t.ID)
- }
- func (s *TicketService) close(ctx context.Context, id int64) error {
- // 已关闭不再刷新 closed_at(幂等)。
- _, err := s.db.ExecContext(ctx, `
- UPDATE support_tickets SET status = 'closed', updated_at = NOW(), closed_at = COALESCE(closed_at, NOW())
- WHERE id = $1 AND status <> 'closed'`, id)
- return err
- }
- // OpenCount 未关闭工单数(管理端角标)。
- func (s *TicketService) OpenCount(ctx context.Context) (int64, error) {
- if s == nil || s.db == nil {
- return 0, nil
- }
- var n int64
- if err := s.db.QueryRowContext(ctx, `
- SELECT COUNT(*) FROM support_tickets WHERE status <> 'closed'`).Scan(&n); err != nil {
- return 0, err
- }
- return n, nil
- }
|