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()
This commit is contained in:
Your Name
2026-05-01 20:56:25 +08:00
parent bd2d848009
commit cf46b27610
103 changed files with 16428 additions and 0 deletions

View File

@@ -0,0 +1,490 @@
package integration
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/bridge/ai-customer-service/internal/domain/audit"
"github.com/bridge/ai-customer-service/internal/domain/session"
"github.com/bridge/ai-customer-service/internal/domain/ticket"
"github.com/bridge/ai-customer-service/internal/store/memory"
)
// --------------------------------------------------
// Mock infrastructure
// --------------------------------------------------
// sessionAuditRecorder mirrors the pattern from ticket_handler_test.go.
type sessionAuditRecorder struct {
events []audit.Event
mu sync.Mutex
}
func (r *sessionAuditRecorder) Add(_ context.Context, event audit.Event) error {
r.mu.Lock()
defer r.mu.Unlock()
r.events = append(r.events, event)
return nil
}
func (r *sessionAuditRecorder) eventsOfType(action string) []audit.Event {
r.mu.Lock()
defer r.mu.Unlock()
var out []audit.Event
for _, e := range r.events {
if e.Action == action {
out = append(out, e)
}
}
return out
}
// mockSessionService simulates the session service used by session handlers.
type mockSessionService struct {
mu sync.Mutex
sessions *memory.SessionStore
tickets *memory.TicketStore
audits *sessionAuditRecorder
calls []struct {
method string
args []string
}
}
func newMockSessionService(audits *sessionAuditRecorder) *mockSessionService {
return &mockSessionService{
sessions: memory.NewSessionStore(),
tickets: memory.NewTicketStore(),
audits: audits,
}
}
func (m *mockSessionService) GetSession(ctx context.Context, id string) (*session.Session, error) {
m.mu.Lock()
m.calls = append(m.calls, struct{ method string; args []string }{method: "GetSession", args: []string{id}})
m.mu.Unlock()
sessions := m.sessions.List()
for _, s := range sessions {
if s.ID == id {
return s, nil
}
}
return nil, nil
}
func (m *mockSessionService) UpdateSession(ctx context.Context, sess *session.Session) error {
m.mu.Lock()
m.calls = append(m.calls, struct{ method string; args []string }{method: "UpdateSession", args: []string{sess.ID}})
m.mu.Unlock()
return m.sessions.Save(ctx, sess)
}
func (m *mockSessionService) CreateTicket(ctx context.Context, t *ticket.Ticket) error {
m.mu.Lock()
m.calls = append(m.calls, struct{ method string; args []string }{method: "CreateTicket", args: []string{t.ID, string(t.Priority), t.SessionID}})
m.mu.Unlock()
return m.tickets.Create(ctx, t)
}
func (m *mockSessionService) lastCall() []string {
m.mu.Lock()
defer m.mu.Unlock()
if len(m.calls) == 0 {
return nil
}
return m.calls[len(m.calls)-1].args
}
// --------------------------------------------------
// Minimal SessionHandler implementation (to be wired into router by engineer)
// --------------------------------------------------
// SessionService defines what the handler needs from the service layer.
type SessionService interface {
GetSession(ctx context.Context, id string) (*session.Session, error)
UpdateSession(ctx context.Context, sess *session.Session) error
CreateTicket(ctx context.Context, t *ticket.Ticket) error
}
// SessionHandler handles session-related HTTP endpoints.
type SessionHandler struct {
service SessionService
audit sessionAuditRecorderInterface
now func() time.Time
}
type sessionAuditRecorderInterface interface {
Add(ctx context.Context, event audit.Event) error
}
// NewSessionHandler creates a new SessionHandler.
func NewSessionHandler(svc SessionService, auditRecorder sessionAuditRecorderInterface) *SessionHandler {
return &SessionHandler{service: svc, audit: auditRecorder, now: time.Now}
}
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": "CS_REQ_4009", "message": "session_id is required"}})
return
}
var reqBody struct {
Score int `json:"score"`
Note string `json:"note,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": map[string]any{"code": "CS_REQ_4001", "message": "invalid JSON"}})
return
}
if reqBody.Score < 1 || reqBody.Score > 5 {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": map[string]any{"code": "CS_SES_4004", "message": "score must be between 1 and 5"}})
return
}
sess, err := h.service.GetSession(r.Context(), sessionID)
if err != nil || sess == nil {
writeJSON(w, http.StatusNotFound, map[string]any{"error": map[string]any{"code": "CS_SES_4001", "message": "session not found"}})
return
}
// Record feedback audit event
now := h.now()
_ = h.audit.Add(r.Context(), audit.Event{
ID: fmt.Sprintf("fb-%d", now.UnixNano()),
Type: "session_feedback",
Action: "feedback",
SessionID: sessionID,
ActorID: sess.OpenID,
Payload: map[string]any{"score": reqBody.Score, "note": reqBody.Note},
CreatedAt: now,
})
writeJSON(w, http.StatusOK, map[string]any{"received": true})
}
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": "CS_REQ_4009", "message": "session_id is required"}})
return
}
var reqBody struct {
Reason string `json:"reason,omitempty"`
}
_ = json.NewDecoder(r.Body).Decode(&reqBody)
sess, err := h.service.GetSession(r.Context(), sessionID)
if err != nil || sess == nil {
writeJSON(w, http.StatusNotFound, map[string]any{"error": map[string]any{"code": "CS_SES_4001", "message": "session not found"}})
return
}
now := h.now()
ticketID := fmt.Sprintf("tkt-%s-%d", sessionID, now.UnixNano())
tkt := &ticket.Ticket{
ID: ticketID,
SessionID: sessionID,
UserID: sess.UserID,
Priority: ticket.PriorityP2,
Status: ticket.StatusOpen,
HandoffReason: reqBody.Reason,
ContextSnapshot: map[string]any{
"channel": sess.Channel,
"open_id": sess.OpenID,
},
CreatedAt: now,
UpdatedAt: now,
}
if err := h.service.CreateTicket(r.Context(), tkt); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": map[string]any{"code": "CS_SYS_5001", "message": "internal server error"}})
return
}
sess.Status = session.StatusHandoff
_ = h.service.UpdateSession(r.Context(), sess)
_ = h.audit.Add(r.Context(), audit.Event{
ID: fmt.Sprintf("ho-%d", now.UnixNano()),
Type: "session_handoff",
Action: "handoff",
SessionID: sessionID,
TicketID: ticketID,
ActorID: sess.OpenID,
Payload: map[string]any{"reason": reqBody.Reason},
CreatedAt: now,
})
writeJSON(w, http.StatusOK, map[string]any{"handoff": true, "ticket_id": ticketID})
}
func sessionPathParam(path string) string {
prefix := "/api/v1/customer-service/sessions/"
trimmed := path[len(prefix):]
if !strings.HasSuffix(trimmed, "/feedback") && !strings.HasSuffix(trimmed, "/handoff") {
return ""
}
trimmed = strings.TrimSuffix(trimmed, "/feedback")
trimmed = strings.TrimSuffix(trimmed, "/handoff")
return trimmed
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
// --------------------------------------------------
// Tests — POST sessions/{id}/feedback
// --------------------------------------------------
func TestSessionHandlerFeedback_Success(t *testing.T) {
auditRecorder := &sessionAuditRecorder{}
svc := newMockSessionService(auditRecorder)
now := time.Date(2026, 4, 30, 10, 0, 0, 0, time.UTC)
ctx := context.Background()
_, _ = svc.sessions.GetOrCreate(ctx, "widget", "u_feedback_ok", now)
sess, _ := svc.sessions.GetOrCreate(ctx, "widget", "u_feedback_ok", now)
sess.Status = session.StatusIdle
_ = svc.sessions.Save(ctx, sess)
h := NewSessionHandler(svc, auditRecorder)
h.now = func() time.Time { return now }
body := map[string]any{"score": 5, "note": "great service"}
bodyBytes, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/sessions/widget:u_feedback_ok/feedback", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
h.Feedback(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", resp.Code, resp.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
t.Fatalf("json decode error = %v", err)
}
if payload["received"] != true {
t.Fatalf("received = %v, want true", payload["received"])
}
// Verify audit was recorded
events := auditRecorder.eventsOfType("feedback")
if len(events) != 1 {
t.Fatalf("feedback audit events = %d, want 1", len(events))
}
if events[0].SessionID != "widget:u_feedback_ok" {
t.Fatalf("audit session_id = %s, want widget:u_feedback_ok", events[0].SessionID)
}
}
func TestSessionHandlerFeedback_SessionNotFound(t *testing.T) {
auditRecorder := &sessionAuditRecorder{}
svc := newMockSessionService(auditRecorder)
h := NewSessionHandler(svc, auditRecorder)
body := map[string]any{"score": 4}
bodyBytes, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/sessions/nonexistent-session/feedback", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
h.Feedback(resp, req)
if resp.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body: %s", resp.Code, resp.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
t.Fatalf("json decode error = %v", err)
}
errPayload := payload["error"].(map[string]any)
if errPayload["code"] != "CS_SES_4001" {
t.Fatalf("error code = %v, want CS_SES_4001", errPayload["code"])
}
}
func TestSessionHandlerFeedback_InvalidScore(t *testing.T) {
auditRecorder := &sessionAuditRecorder{}
svc := newMockSessionService(auditRecorder)
now := time.Date(2026, 4, 30, 10, 0, 0, 0, time.UTC)
ctx := context.Background()
_, _ = svc.sessions.GetOrCreate(ctx, "widget", "u_invalid_score", now)
h := NewSessionHandler(svc, auditRecorder)
h.now = func() time.Time { return now }
// Score too low (0)
body := map[string]any{"score": 0}
bodyBytes, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/sessions/widget:u_invalid_score/feedback", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
h.Feedback(resp, req)
if resp.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body: %s", resp.Code, resp.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
t.Fatalf("json decode error = %v", err)
}
errPayload := payload["error"].(map[string]any)
if errPayload["code"] != "CS_SES_4004" {
t.Fatalf("error code = %v, want CS_SES_4004", errPayload["code"])
}
// Score too high (6)
body2 := map[string]any{"score": 6}
bodyBytes2, _ := json.Marshal(body2)
req2 := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/sessions/widget:u_invalid_score/feedback", bytes.NewReader(bodyBytes2))
req2.Header.Set("Content-Type", "application/json")
resp2 := httptest.NewRecorder()
h.Feedback(resp2, req2)
if resp2.Code != http.StatusBadRequest {
t.Fatalf("status(score=6) = %d, want 400", resp2.Code)
}
}
// --------------------------------------------------
// Tests — POST sessions/{id}/handoff
// --------------------------------------------------
func TestSessionHandlerHandoff_Success(t *testing.T) {
auditRecorder := &sessionAuditRecorder{}
svc := newMockSessionService(auditRecorder)
now := time.Date(2026, 4, 30, 10, 0, 0, 0, time.UTC)
ctx := context.Background()
_, _ = svc.sessions.GetOrCreate(ctx, "widget", "u_handoff_ok", now)
sess, _ := svc.sessions.GetOrCreate(ctx, "widget", "u_handoff_ok", now)
sess.Status = session.StatusIdle
_ = svc.sessions.Save(ctx, sess)
h := NewSessionHandler(svc, auditRecorder)
h.now = func() time.Time { return now }
body := map[string]any{"reason": "manual transfer"}
bodyBytes, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/sessions/widget:u_handoff_ok/handoff", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
h.Handoff(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", resp.Code, resp.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
t.Fatalf("json decode error = %v", err)
}
if payload["handoff"] != true {
t.Fatalf("handoff = %v, want true", payload["handoff"])
}
ticketID, ok := payload["ticket_id"].(string)
if !ok || ticketID == "" {
t.Fatalf("ticket_id missing or empty, got %v", payload["ticket_id"])
}
// Verify session was updated to handoff status
updated := svc.sessions.List()
for _, s := range updated {
if s.ID == "widget:u_handoff_ok" && s.Status != session.StatusHandoff {
t.Fatalf("session status = %s, want handoff", s.Status)
}
}
}
func TestSessionHandlerHandoff_SessionNotFound(t *testing.T) {
auditRecorder := &sessionAuditRecorder{}
svc := newMockSessionService(auditRecorder)
h := NewSessionHandler(svc, auditRecorder)
body := map[string]any{"reason": "manual"}
bodyBytes, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/sessions/nonexistent-session/handoff", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
h.Handoff(resp, req)
if resp.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body: %s", resp.Code, resp.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
t.Fatalf("json decode error = %v", err)
}
errPayload := payload["error"].(map[string]any)
if errPayload["code"] != "CS_SES_4001" {
t.Fatalf("error code = %v, want CS_SES_4001", errPayload["code"])
}
}
func TestSessionHandlerHandoff_CreatesTicket(t *testing.T) {
auditRecorder := &sessionAuditRecorder{}
svc := newMockSessionService(auditRecorder)
now := time.Date(2026, 4, 30, 10, 0, 0, 0, time.UTC)
ctx := context.Background()
_, _ = svc.sessions.GetOrCreate(ctx, "telegram", "u_ticket_create", now)
sess, _ := svc.sessions.GetOrCreate(ctx, "telegram", "u_ticket_create", now)
sess.Status = session.StatusIdle
_ = svc.sessions.Save(ctx, sess)
h := NewSessionHandler(svc, auditRecorder)
h.now = func() time.Time { return now }
body := map[string]any{"reason": "customer requested human"}
bodyBytes, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/sessions/telegram:u_ticket_create/handoff", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
h.Handoff(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.Code)
}
var payload map[string]any
if err := json.Unmarshal(resp.Body.Bytes(), &payload); err != nil {
t.Fatalf("json decode error = %v", err)
}
ticketID, ok := payload["ticket_id"].(string)
if !ok || ticketID == "" {
t.Fatalf("ticket_id missing, got %v", payload["ticket_id"])
}
// Verify ticket was stored with correct fields
tickets := svc.tickets.List()
found := false
for _, tk := range tickets {
if tk.ID == ticketID {
found = true
if tk.SessionID != "telegram:u_ticket_create" {
t.Fatalf("ticket session_id = %s, want telegram:u_ticket_create", tk.SessionID)
}
if tk.Status != ticket.StatusOpen {
t.Fatalf("ticket status = %s, want open", tk.Status)
}
if tk.HandoffReason != "customer requested human" {
t.Fatalf("handoff_reason = %s, want 'customer requested human'", tk.HandoffReason)
}
break
}
}
if !found {
t.Fatalf("ticket %s not found in store", ticketID)
}
// Verify handoff audit event was recorded
events := auditRecorder.eventsOfType("handoff")
if len(events) != 1 {
t.Fatalf("handoff audit events = %d, want 1", len(events))
}
if events[0].TicketID != ticketID {
t.Fatalf("audit ticket_id = %s, want %s", events[0].TicketID, ticketID)
}
}