Files
ai-customer-service/internal/http/handlers/ticket_handler.go
Your Name cefbe946b2 fix(ticket_handler): 将 auditTicketChange 死代码接入 Assign/Resolve/Close
auditTicketChange (ticket_handler.go:104) 自定义义以来从未被调用:
- Assign/Resolve/Close 成功后均未记录状态变更审计日志
- 已有的单元测试在 mockTicketService 里单独记录事件,但 handler 层缺失

修改内容:
- Assign/Resolve/Close 成功后调用 h.auditTicketChange()
- auditTicketChange 新增 actorID 参数(原来硬编码为 system)
- 修改后 handler 层和 service 层各自记录一条 audit 日志(测试断言相应改为 len==2,取 [1])
- nil 保护保持不变(h==nil || h.audit==nil)

同时更新 ticket_handler_test.go:
- assign/resolve 测试断言从 len==1 改为 len==2,取最后一条
- 新增 TestTicketHandlerCloseAuditsStateChange 测试

handlers 覆盖率:85.9% → 87.1%
2026-05-01 13:29:00 +08:00

121 lines
5.8 KiB
Go

package handlers
import (
"context"
"net/http"
"strings"
"time"
"github.com/bridge/ai-customer-service/internal/domain/audit"
"github.com/bridge/ai-customer-service/internal/domain/error/cserrors"
"github.com/bridge/ai-customer-service/internal/domain/ticket"
)
type TicketService interface {
ListOpen(ctx context.Context, limit int) ([]ticket.Ticket, error)
GetByID(ctx context.Context, id string) (*ticket.Ticket, error)
Assign(ctx context.Context, ticketID, agentID, actorID, sourceIP string, now time.Time) error
Resolve(ctx context.Context, ticketID, resolution, actorID, sourceIP string, now time.Time) error
Close(ctx context.Context, ticketID, resolution, actorID, sourceIP string, now time.Time) error
}
type TicketHandler struct {
service TicketService
audit AuditRecorder
now func() time.Time
}
func NewTicketHandler(service TicketService, auditRecorder AuditRecorder) *TicketHandler {
return &TicketHandler{service: service, audit: auditRecorder, now: time.Now}
}
func (h *TicketHandler) List(w http.ResponseWriter, r *http.Request) {
items, err := h.service.ListOpen(r.Context(), 50)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": map[string]any{"code": cserrors.CS_SYS_5002, "message": cserrors.ErrorMsg(cserrors.CS_SYS_5002)}})
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
// P1-3: GET /api/v1/customer-service/tickets/{id} — ticket detail (Phase 1 minimum implementation)
func (h *TicketHandler) Get(w http.ResponseWriter, r *http.Request) {
ticketID := pathParam(r.URL.Path, "/api/v1/customer-service/tickets/", "")
if ticketID == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": map[string]any{"code": cserrors.CS_REQ_4005, "message": cserrors.ErrorMsg(cserrors.CS_REQ_4005)}})
return
}
tkt, err := h.service.GetByID(r.Context(), ticketID)
if err != nil || tkt == nil {
writeJSON(w, http.StatusNotFound, map[string]any{"error": map[string]any{"code": cserrors.CS_TICKET_4001, "message": cserrors.ErrorMsg(cserrors.CS_TICKET_4001)}})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ticket": tkt})
}
func (h *TicketHandler) Assign(w http.ResponseWriter, r *http.Request) {
ticketID := pathParam(r.URL.Path, "/api/v1/customer-service/tickets/", "/assign")
agentID := strings.TrimSpace(r.URL.Query().Get("agent_id"))
if ticketID == "" || agentID == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": map[string]any{"code": cserrors.CS_REQ_4005, "message": cserrors.ErrorMsg(cserrors.CS_REQ_4005)}})
return
}
actorID := strings.TrimSpace(r.URL.Query().Get("actor_id"))
sourceIP := clientIP(r.RemoteAddr)
if err := h.service.Assign(r.Context(), ticketID, agentID, actorID, sourceIP, h.now()); err != nil {
writeJSON(w, http.StatusConflict, map[string]any{"error": map[string]any{"code": cserrors.CS_TKT_4002, "message": cserrors.ErrorMsg(cserrors.CS_TKT_4002)}})
return
}
h.auditTicketChange(r.Context(), ticketID, "assign", actorID, map[string]any{"assigned_to": agentID, "status": ticket.StatusAssigned}, r.RemoteAddr)
writeJSON(w, http.StatusOK, map[string]any{"assigned": true})
}
func (h *TicketHandler) Resolve(w http.ResponseWriter, r *http.Request) {
ticketID := pathParam(r.URL.Path, "/api/v1/customer-service/tickets/", "/resolve")
resolution := strings.TrimSpace(r.URL.Query().Get("resolution"))
if ticketID == "" || resolution == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": map[string]any{"code": cserrors.CS_REQ_4006, "message": cserrors.ErrorMsg(cserrors.CS_REQ_4006)}})
return
}
actorID := strings.TrimSpace(r.URL.Query().Get("actor_id"))
sourceIP := clientIP(r.RemoteAddr)
if err := h.service.Resolve(r.Context(), ticketID, resolution, actorID, sourceIP, h.now()); err != nil {
writeJSON(w, http.StatusConflict, map[string]any{"error": map[string]any{"code": cserrors.CS_TICKET_4092, "message": cserrors.ErrorMsg(cserrors.CS_TICKET_4092)}})
return
}
h.auditTicketChange(r.Context(), ticketID, "resolve", actorID, map[string]any{"resolution": resolution, "status": ticket.StatusResolved}, r.RemoteAddr)
writeJSON(w, http.StatusOK, map[string]any{"resolved": true})
}
func (h *TicketHandler) Close(w http.ResponseWriter, r *http.Request) {
ticketID := pathParam(r.URL.Path, "/api/v1/customer-service/tickets/", "/close")
resolution := strings.TrimSpace(r.URL.Query().Get("resolution"))
if ticketID == "" || resolution == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": map[string]any{"code": cserrors.CS_REQ_4007, "message": cserrors.ErrorMsg(cserrors.CS_REQ_4007)}})
return
}
actorID := strings.TrimSpace(r.URL.Query().Get("actor_id"))
sourceIP := clientIP(r.RemoteAddr)
if err := h.service.Close(r.Context(), ticketID, resolution, actorID, sourceIP, h.now()); err != nil {
writeJSON(w, http.StatusConflict, map[string]any{"error": map[string]any{"code": cserrors.CS_TICKET_4093, "message": cserrors.ErrorMsg(cserrors.CS_TICKET_4093)}})
return
}
h.auditTicketChange(r.Context(), ticketID, "close", actorID, map[string]any{"resolution": resolution, "status": ticket.StatusClosed}, r.RemoteAddr)
writeJSON(w, http.StatusOK, map[string]any{"closed": true})
}
func (h *TicketHandler) auditTicketChange(ctx context.Context, ticketID, action, actorID string, after map[string]any, remoteAddr string) {
if h == nil || h.audit == nil {
return
}
now := h.now()
// P0 quality standard: audit write failure only logs, does not return error
_ = h.audit.Add(ctx, audit.Event{ID: newAuditID("audit", now), Type: "ticket_state_changed", Action: action, TicketID: ticketID, ActorID: actorID, SourceIP: clientIP(remoteAddr), AfterState: after, CreatedAt: now})
}
func pathParam(path, prefix, suffix string) string {
trimmed := strings.TrimPrefix(path, prefix)
trimmed = strings.TrimSuffix(trimmed, suffix)
trimmed = strings.Trim(trimmed, "/")
return trimmed
}