Files
lijiaoqiao/projects/ai-customer-service/internal/http/handlers/session_handler.go
Your Name 687c4535f8 fix: P0-1 RateLimiter并发写安全 + P0-2工单操作错误码区分 + P1 rows.Close修复
P0-1 (limits.go): Allow()方法改为全程使用写锁保护counters map读写,避免RLock写入时的data race
P0-2 (ticket_workflow.go+ticket_handler.go): Assign/Resolve/Close操作先查询ticket存在性和状态,返回明确的CS_TICKET_4001/CS_TKT_4002/CS_TICKET_4092/CS_TICKET_4093错误码,handler根据错误前缀路由HTTP状态码
P1-1 (ticket_store.go): 移除GetStats中3处手动rows.Close(),只保留defer Close()
2026-05-01 20:56:25 +08:00

203 lines
6.6 KiB
Go

package handlers
import (
"context"
"encoding/json"
"fmt"
"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/session"
"github.com/bridge/ai-customer-service/internal/domain/ticket"
)
type SessionGetter interface {
GetByID(ctx context.Context, id string) (*session.Session, error)
}
type TicketCreator interface {
Create(ctx context.Context, t *ticket.Ticket) error
}
// SessionHandler handles session-related API endpoints: feedback and manual handoff.
type SessionHandler struct {
sessions SessionGetter
tickets TicketCreator
audits AuditRecorder
now func() time.Time
}
// NewSessionHandler creates a new SessionHandler.
func NewSessionHandler(sessions SessionGetter, tickets TicketCreator, audits AuditRecorder) *SessionHandler {
return &SessionHandler{
sessions: sessions,
tickets: tickets,
audits: audits,
now: time.Now,
}
}
// FeedbackRequest represents the feedback submission request body.
type FeedbackRequest struct {
Score int `json:"score"`
Comment string `json:"comment,omitempty"`
}
// Feedback handles POST /api/v1/customer-service/sessions/{id}/feedback
// Feedback is written directly to audit_log and does not update the session itself.
func (h *SessionHandler) Feedback(w http.ResponseWriter, r *http.Request) {
sessionID := sessionPathParam(r.URL.Path)
if sessionID == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": map[string]any{"code": cserrors.CS_REQ_4005, "message": cserrors.ErrorMsg(cserrors.CS_REQ_4005)}})
return
}
var req FeedbackRequest
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": map[string]any{"code": cserrors.CS_REQ_4001, "message": cserrors.ErrorMsg(cserrors.CS_REQ_4001)}})
return
}
// Validate score range (1-5)
if req.Score < 1 || req.Score > 5 {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": map[string]any{"code": cserrors.CS_REQ_4009, "message": cserrors.ErrorMsg(cserrors.CS_REQ_4009)}})
return
}
actorID := strings.TrimSpace(r.URL.Query().Get("actor_id"))
if actorID == "" {
actorID = "system"
}
sourceIP := clientIP(r.RemoteAddr)
now := h.now()
// Write feedback to audit log (P0 quality standard: audit failure only logs, does not return error)
feedbackPayload := map[string]any{
"score": req.Score,
"comment": req.Comment,
}
_ = h.audits.Add(r.Context(), audit.Event{
ID: newAuditID("feedback", now),
SessionID: sessionID,
Type: "feedback",
Action: "submit",
ActorID: actorID,
SourceIP: sourceIP,
Payload: feedbackPayload,
CreatedAt: now,
})
writeJSON(w, http.StatusOK, map[string]any{"session_id": sessionID, "submitted": true})
}
// HandoffRequest represents the manual handoff request body.
type HandoffRequest struct {
Reason string `json:"reason"`
Priority string `json:"priority,omitempty"`
}
// Handoff handles POST /api/v1/customer-service/sessions/{id}/handoff
// This is a客服后台主动发起的 manual handoff, not triggered by intent recognition.
func (h *SessionHandler) Handoff(w http.ResponseWriter, r *http.Request) {
sessionID := sessionPathParam(r.URL.Path)
if sessionID == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": map[string]any{"code": cserrors.CS_REQ_4005, "message": cserrors.ErrorMsg(cserrors.CS_REQ_4005)}})
return
}
var req HandoffRequest
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": map[string]any{"code": cserrors.CS_REQ_4001, "message": cserrors.ErrorMsg(cserrors.CS_REQ_4001)}})
return
}
req.Reason = strings.TrimSpace(req.Reason)
if req.Reason == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": map[string]any{"code": cserrors.CS_REQ_4010, "message": cserrors.ErrorMsg(cserrors.CS_REQ_4010)}})
return
}
// Verify session exists
sess, err := h.sessions.GetByID(r.Context(), sessionID)
if err != nil || sess == nil {
writeJSON(w, http.StatusNotFound, map[string]any{"error": map[string]any{"code": cserrors.CS_SES_4001, "message": cserrors.ErrorMsg(cserrors.CS_SES_4001)}})
return
}
// Determine priority
priority := ticket.Priority(strings.ToUpper(req.Priority))
if priority == "" {
priority = ticket.PriorityP2
}
actorID := strings.TrimSpace(r.URL.Query().Get("actor_id"))
if actorID == "" {
actorID = "system"
}
sourceIP := clientIP(r.RemoteAddr)
now := h.now()
// Create ticket for manual handoff
ticketID := fmt.Sprintf("%s-%d", sessionID, now.UnixNano())
tkt := &ticket.Ticket{
ID: ticketID,
SessionID: sessionID,
UserID: sess.UserID,
Priority: priority,
Status: ticket.StatusOpen,
HandoffReason: req.Reason,
ContextSnapshot: map[string]any{
"channel": sess.Channel,
"open_id": sess.OpenID,
"manual": true,
"actor_id": actorID,
"source": "customer_service_api",
},
CreatedAt: now,
UpdatedAt: now,
}
if err := h.tickets.Create(r.Context(), tkt); 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
}
// Audit the manual handoff (P0 quality standard: audit failure only logs, does not return error)
_ = h.audits.Add(r.Context(), audit.Event{
ID: newAuditID("handoff", now),
SessionID: sessionID,
TicketID: ticketID,
Type: "manual_handoff",
Action: "create",
ActorID: actorID,
SourceIP: sourceIP,
AfterState: map[string]any{"ticket_id": ticketID, "priority": string(priority), "reason": req.Reason},
CreatedAt: now,
})
writeJSON(w, http.StatusOK, map[string]any{"session_id": sessionID, "ticket_id": ticketID, "priority": string(priority)})
}
// sessionPathParam extracts the session ID from paths like
// /api/v1/customer-service/sessions/{id}/feedback or .../handoff
func sessionPathParam(path string) string {
prefix := "/api/v1/customer-service/sessions/"
trimmed := strings.TrimPrefix(path, prefix)
// Only accept paths ending in /feedback or /handoff
if !strings.HasSuffix(trimmed, "/feedback") && !strings.HasSuffix(trimmed, "/handoff") {
return ""
}
// Remove trailing /feedback or /handoff
trimmed = strings.TrimSuffix(trimmed, "/feedback")
trimmed = strings.TrimSuffix(trimmed, "/handoff")
trimmed = strings.Trim(trimmed, "/")
return trimmed
}