This commit is contained in:
@@ -3,17 +3,17 @@ package audit
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type Event struct {
|
type Event struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
SessionID string `json:"session_id,omitempty"`
|
SessionID string `json:"session_id,omitempty"`
|
||||||
TicketID string `json:"ticket_id,omitempty"`
|
TicketID string `json:"ticket_id,omitempty"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Action string `json:"action,omitempty"`
|
Action string `json:"action,omitempty"`
|
||||||
Channel string `json:"channel,omitempty"`
|
Channel string `json:"channel,omitempty"`
|
||||||
OpenID string `json:"open_id,omitempty"`
|
OpenID string `json:"open_id,omitempty"`
|
||||||
ActorID string `json:"actor_id,omitempty"`
|
ActorID string `json:"actor_id,omitempty"`
|
||||||
SourceIP string `json:"source_ip,omitempty"`
|
SourceIP string `json:"source_ip,omitempty"`
|
||||||
Payload map[string]any `json:"payload,omitempty"`
|
Payload map[string]any `json:"payload,omitempty"`
|
||||||
BeforeState map[string]any `json:"before_state,omitempty"`
|
BeforeState map[string]any `json:"before_state,omitempty"`
|
||||||
AfterState map[string]any `json:"after_state,omitempty"`
|
AfterState map[string]any `json:"after_state,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,10 +164,10 @@ func TestEvent_TicketAndSessionFields(t *testing.T) {
|
|||||||
|
|
||||||
// Session-scoped event
|
// Session-scoped event
|
||||||
sessionEvent := Event{
|
sessionEvent := Event{
|
||||||
ID: "e2",
|
ID: "e2",
|
||||||
SessionID: "s-1",
|
SessionID: "s-1",
|
||||||
Type: "session",
|
Type: "session",
|
||||||
Action: "message",
|
Action: "message",
|
||||||
}
|
}
|
||||||
|
|
||||||
if sessionEvent.SessionID != "s-1" {
|
if sessionEvent.SessionID != "s-1" {
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ func TestErrorMsg_UnknownCode(t *testing.T) {
|
|||||||
|
|
||||||
func TestErrorMsg_SpecificCodes(t *testing.T) {
|
func TestErrorMsg_SpecificCodes(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
code string
|
code string
|
||||||
expectedMsg string
|
expectedMsg string
|
||||||
}{
|
}{
|
||||||
{CS_SES_4001, "session not found"},
|
{CS_SES_4001, "session not found"},
|
||||||
{CS_SES_4002, "message rate limit exceeded"},
|
{CS_SES_4002, "message rate limit exceeded"},
|
||||||
@@ -102,38 +102,38 @@ func TestErrorMsg_SpecificCodes(t *testing.T) {
|
|||||||
func TestErrorMsg_AllKnownCodesReturnNonEmpty(t *testing.T) {
|
func TestErrorMsg_AllKnownCodesReturnNonEmpty(t *testing.T) {
|
||||||
// Verify all codes defined in the switch have non-empty messages
|
// Verify all codes defined in the switch have non-empty messages
|
||||||
knownCodes := map[string]string{
|
knownCodes := map[string]string{
|
||||||
CS_SES_4001: "session not found",
|
CS_SES_4001: "session not found",
|
||||||
CS_SES_4002: "message rate limit exceeded",
|
CS_SES_4002: "message rate limit exceeded",
|
||||||
CS_SES_4003: "identity verification locked",
|
CS_SES_4003: "identity verification locked",
|
||||||
CS_IDT_4001: "identity information mismatch",
|
CS_IDT_4001: "identity information mismatch",
|
||||||
CS_IDT_4002: "verification code incorrect",
|
CS_IDT_4002: "verification code incorrect",
|
||||||
CS_TICKET_4001: "ticket not found",
|
CS_TICKET_4001: "ticket not found",
|
||||||
CS_TICKET_4002: "ticket already assigned",
|
CS_TICKET_4002: "ticket already assigned",
|
||||||
CS_TICKET_4092: "ticket resolve conflict",
|
CS_TICKET_4092: "ticket resolve conflict",
|
||||||
CS_TICKET_4093: "ticket close conflict",
|
CS_TICKET_4093: "ticket close conflict",
|
||||||
CS_KB_4001: "knowledge-base entry not found",
|
CS_KB_4001: "knowledge-base entry not found",
|
||||||
CS_KB_4002: "entry name already exists",
|
CS_KB_4002: "entry name already exists",
|
||||||
CS_LLM_5001: "LLM service unavailable",
|
CS_LLM_5001: "LLM service unavailable",
|
||||||
CS_LLM_5002: "LLM request timeout",
|
CS_LLM_5002: "LLM request timeout",
|
||||||
CS_AUTH_4001: "access denied",
|
CS_AUTH_4001: "access denied",
|
||||||
CS_AUTH_4031: "missing webhook signature",
|
CS_AUTH_4031: "missing webhook signature",
|
||||||
CS_AUTH_4032: "invalid webhook timestamp",
|
CS_AUTH_4032: "invalid webhook timestamp",
|
||||||
CS_AUTH_4033: "stale webhook request",
|
CS_AUTH_4033: "stale webhook request",
|
||||||
CS_AUTH_4034: "invalid webhook signature",
|
CS_AUTH_4034: "invalid webhook signature",
|
||||||
CS_HTTP_405: "method not allowed",
|
CS_HTTP_405: "method not allowed",
|
||||||
CS_REQ_4001: "invalid JSON",
|
CS_REQ_4001: "invalid JSON",
|
||||||
CS_REQ_4131: "request body too large",
|
CS_REQ_4131: "request body too large",
|
||||||
CS_REQ_4002: "channel, open_id and content are required",
|
CS_REQ_4002: "channel, open_id and content are required",
|
||||||
CS_REQ_4003: "content exceeds maximum length",
|
CS_REQ_4003: "content exceeds maximum length",
|
||||||
CS_REQ_4004: "unable to read request body",
|
CS_REQ_4004: "unable to read request body",
|
||||||
CS_REQ_4008: "channel is required",
|
CS_REQ_4008: "channel is required",
|
||||||
CS_REQ_4005: "ticket_id and agent_id are required",
|
CS_REQ_4005: "ticket_id and agent_id are required",
|
||||||
CS_REQ_4006: "ticket_id and resolution are required",
|
CS_REQ_4006: "ticket_id and resolution are required",
|
||||||
CS_REQ_4007: "ticket_id and resolution are required",
|
CS_REQ_4007: "ticket_id and resolution are required",
|
||||||
CS_REQ_4009: "feedback score must be between 1 and 5",
|
CS_REQ_4009: "feedback score must be between 1 and 5",
|
||||||
CS_REQ_4010: "handoff reason is required",
|
CS_REQ_4010: "handoff reason is required",
|
||||||
CS_SYS_5001: "internal server error",
|
CS_SYS_5001: "internal server error",
|
||||||
CS_SYS_5002: "list tickets failed",
|
CS_SYS_5002: "list tickets failed",
|
||||||
}
|
}
|
||||||
|
|
||||||
for code, expectedMsg := range knownCodes {
|
for code, expectedMsg := range knownCodes {
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ type Result struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
IntentQuota = "quota"
|
IntentQuota = "quota"
|
||||||
IntentToken = "token"
|
IntentToken = "token"
|
||||||
IntentError = "error"
|
IntentError = "error"
|
||||||
IntentHandoff = "handoff"
|
IntentHandoff = "handoff"
|
||||||
IntentGeneral = "general"
|
IntentGeneral = "general"
|
||||||
IntentRefund = "refund"
|
IntentRefund = "refund"
|
||||||
IntentSecurity = "security"
|
IntentSecurity = "security"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import "time"
|
|||||||
type Status string
|
type Status string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
StatusIdle Status = "idle"
|
StatusIdle Status = "idle"
|
||||||
StatusProcessing Status = "processing"
|
StatusProcessing Status = "processing"
|
||||||
StatusHandoff Status = "handoff"
|
StatusHandoff Status = "handoff"
|
||||||
StatusClosed Status = "closed"
|
StatusClosed Status = "closed"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MessageContext struct {
|
type MessageContext struct {
|
||||||
|
|||||||
@@ -22,16 +22,16 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Ticket struct {
|
type Ticket struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
SessionID string `json:"session_id"`
|
SessionID string `json:"session_id"`
|
||||||
UserID string `json:"user_id,omitempty"`
|
UserID string `json:"user_id,omitempty"`
|
||||||
Priority Priority `json:"priority"`
|
Priority Priority `json:"priority"`
|
||||||
Status Status `json:"status"`
|
Status Status `json:"status"`
|
||||||
HandoffReason string `json:"handoff_reason"`
|
HandoffReason string `json:"handoff_reason"`
|
||||||
AssignedTo string `json:"assigned_to,omitempty"`
|
AssignedTo string `json:"assigned_to,omitempty"`
|
||||||
ContextSnapshot map[string]any `json:"context_snapshot"`
|
ContextSnapshot map[string]any `json:"context_snapshot"`
|
||||||
Resolution string `json:"resolution,omitempty"`
|
Resolution string `json:"resolution,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
|
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,8 +132,8 @@ func TestTicket_Fields(t *testing.T) {
|
|||||||
func TestTicket_ResolvedAtOptional(t *testing.T) {
|
func TestTicket_ResolvedAtOptional(t *testing.T) {
|
||||||
// Test that ResolvedAt can be nil (open ticket)
|
// Test that ResolvedAt can be nil (open ticket)
|
||||||
tk := Ticket{
|
tk := Ticket{
|
||||||
ID: "open-ticket",
|
ID: "open-ticket",
|
||||||
Status: StatusOpen,
|
Status: StatusOpen,
|
||||||
ResolvedAt: nil,
|
ResolvedAt: nil,
|
||||||
}
|
}
|
||||||
if tk.ResolvedAt != nil {
|
if tk.ResolvedAt != nil {
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ package ticketstats
|
|||||||
|
|
||||||
// Stats represents aggregated ticket statistics for monitoring dashboards.
|
// Stats represents aggregated ticket statistics for monitoring dashboards.
|
||||||
type Stats struct {
|
type Stats struct {
|
||||||
Total int `json:"total_tickets"`
|
Total int `json:"total_tickets"`
|
||||||
Open int `json:"open"`
|
Open int `json:"open"`
|
||||||
Resolved int `json:"resolved"`
|
Resolved int `json:"resolved"`
|
||||||
Closed int `json:"closed"`
|
Closed int `json:"closed"`
|
||||||
ByChannel map[string]int `json:"by_channel"`
|
ByChannel map[string]int `json:"by_channel"`
|
||||||
ByPriority map[string]int `json:"by_priority"`
|
ByPriority map[string]int `json:"by_priority"`
|
||||||
HandoffCount int `json:"handoff_count"`
|
HandoffCount int `json:"handoff_count"`
|
||||||
AvgResolutionTimeMinutes float64 `json:"avg_resolution_time_minutes"`
|
AvgResolutionTimeMinutes float64 `json:"avg_resolution_time_minutes"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,13 +106,13 @@ func TestHealthHandler_Health_ReturnsOK(t *testing.T) {
|
|||||||
func TestTicketStatsHandler_Get_Success(t *testing.T) {
|
func TestTicketStatsHandler_Get_Success(t *testing.T) {
|
||||||
mock := &mockTicketStatsServiceForStats{
|
mock := &mockTicketStatsServiceForStats{
|
||||||
stats: ticketstats.Stats{
|
stats: ticketstats.Stats{
|
||||||
Total: 100,
|
Total: 100,
|
||||||
Open: 30,
|
Open: 30,
|
||||||
Resolved: 50,
|
Resolved: 50,
|
||||||
Closed: 20,
|
Closed: 20,
|
||||||
ByChannel: map[string]int{"api": 40, "web": 60},
|
ByChannel: map[string]int{"api": 40, "web": 60},
|
||||||
ByPriority: map[string]int{"P1": 10, "P2": 60, "P3": 30},
|
ByPriority: map[string]int{"P1": 10, "P2": 60, "P3": 30},
|
||||||
HandoffCount: 15,
|
HandoffCount: 15,
|
||||||
AvgResolutionTimeMinutes: 45.5,
|
AvgResolutionTimeMinutes: 45.5,
|
||||||
},
|
},
|
||||||
err: nil,
|
err: nil,
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestWebhookSecurity_InvalidTimestampFormat covers CS_AUTH_4032:
|
// TestWebhookSecurity_InvalidTimestampFormat covers CS_AUTH_4032:
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/bridge/ai-customer-service/internal/domain/audit"
|
"github.com/bridge/ai-customer-service/internal/domain/audit"
|
||||||
|
intentdomain "github.com/bridge/ai-customer-service/internal/domain/intent"
|
||||||
"github.com/bridge/ai-customer-service/internal/domain/message"
|
"github.com/bridge/ai-customer-service/internal/domain/message"
|
||||||
"github.com/bridge/ai-customer-service/internal/domain/session"
|
"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/domain/ticket"
|
||||||
intentdomain "github.com/bridge/ai-customer-service/internal/domain/intent"
|
|
||||||
"github.com/bridge/ai-customer-service/internal/service/handoff"
|
"github.com/bridge/ai-customer-service/internal/service/handoff"
|
||||||
intentservice "github.com/bridge/ai-customer-service/internal/service/intent"
|
intentservice "github.com/bridge/ai-customer-service/internal/service/intent"
|
||||||
"github.com/bridge/ai-customer-service/internal/service/reply"
|
"github.com/bridge/ai-customer-service/internal/service/reply"
|
||||||
|
|||||||
@@ -26,12 +26,12 @@ func TestDialogService_AC02_IntentMatrix(t *testing.T) {
|
|||||||
svc := dialog.NewService(sessions, audits, tickets, dedup, intentservice.NewService(), reply.NewService(knowledge), handoff.NewService())
|
svc := dialog.NewService(sessions, audits, tickets, dedup, intentservice.NewService(), reply.NewService(knowledge), handoff.NewService())
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
content string
|
content string
|
||||||
wantIntent string
|
wantIntent string
|
||||||
wantHandoff bool
|
wantHandoff bool
|
||||||
wantPriority string // empty if no handoff expected
|
wantPriority string // empty if no handoff expected
|
||||||
wantReply bool // whether to check reply is non-empty
|
wantReply bool // whether to check reply is non-empty
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "AC-02: 退款意图 → P1 handoff",
|
name: "AC-02: 退款意图 → P1 handoff",
|
||||||
@@ -58,11 +58,11 @@ func TestDialogService_AC02_IntentMatrix(t *testing.T) {
|
|||||||
wantReply: true,
|
wantReply: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "AC-02: 正常查询 → bot 回复无 handoff",
|
name: "AC-02: 正常查询 → bot 回复无 handoff",
|
||||||
content: "查询额度",
|
content: "查询额度",
|
||||||
wantIntent: "quota",
|
wantIntent: "quota",
|
||||||
wantHandoff: false,
|
wantHandoff: false,
|
||||||
wantReply: true,
|
wantReply: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,9 +70,9 @@ func TestDialogService_AC02_IntentMatrix(t *testing.T) {
|
|||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
result, err := svc.Process(context.Background(), &message.UnifiedMessage{
|
result, err := svc.Process(context.Background(), &message.UnifiedMessage{
|
||||||
MessageID: "m_" + tc.name,
|
MessageID: "m_" + tc.name,
|
||||||
Channel: "widget",
|
Channel: "widget",
|
||||||
OpenID: "u_" + tc.name,
|
OpenID: "u_" + tc.name,
|
||||||
Content: tc.content,
|
Content: tc.content,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Process() error = %v", err)
|
t.Fatalf("Process() error = %v", err)
|
||||||
|
|||||||
@@ -41,27 +41,27 @@ func setupTicketStatsHandler(stats ticketstats.Stats) (*httptest.ResponseRecorde
|
|||||||
|
|
||||||
// ticketStatsResponse mirrors the JSON shape of ticketstats.Stats.
|
// ticketStatsResponse mirrors the JSON shape of ticketstats.Stats.
|
||||||
type ticketStatsResponse struct {
|
type ticketStatsResponse struct {
|
||||||
Total int `json:"total_tickets"`
|
Total int `json:"total_tickets"`
|
||||||
Open int `json:"open"`
|
Open int `json:"open"`
|
||||||
Resolved int `json:"resolved"`
|
Resolved int `json:"resolved"`
|
||||||
Closed int `json:"closed"`
|
Closed int `json:"closed"`
|
||||||
ByChannel map[string]int `json:"by_channel"`
|
ByChannel map[string]int `json:"by_channel"`
|
||||||
ByPriority map[string]int `json:"by_priority"`
|
ByPriority map[string]int `json:"by_priority"`
|
||||||
HandoffCount int `json:"handoff_count"`
|
HandoffCount int `json:"handoff_count"`
|
||||||
AvgResolutionTimeMinutes float64 `json:"avg_resolution_time_minutes"`
|
AvgResolutionTimeMinutes float64 `json:"avg_resolution_time_minutes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestTicketStats_Success verifies the stats endpoint returns correct
|
// TestTicketStats_Success verifies the stats endpoint returns correct
|
||||||
// counts when the store has tickets.
|
// counts when the store has tickets.
|
||||||
func TestTicketStats_Success(t *testing.T) {
|
func TestTicketStats_Success(t *testing.T) {
|
||||||
stats := ticketstats.Stats{
|
stats := ticketstats.Stats{
|
||||||
Total: 100,
|
Total: 100,
|
||||||
Open: 30,
|
Open: 30,
|
||||||
Resolved: 50,
|
Resolved: 50,
|
||||||
Closed: 20,
|
Closed: 20,
|
||||||
ByChannel: map[string]int{"api": 40, "web": 60},
|
ByChannel: map[string]int{"api": 40, "web": 60},
|
||||||
ByPriority: map[string]int{"P1": 10, "P2": 60, "P3": 30},
|
ByPriority: map[string]int{"P1": 10, "P2": 60, "P3": 30},
|
||||||
HandoffCount: 15,
|
HandoffCount: 15,
|
||||||
AvgResolutionTimeMinutes: 45.5,
|
AvgResolutionTimeMinutes: 45.5,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,13 +114,13 @@ func TestTicketStats_Success(t *testing.T) {
|
|||||||
// TestTicketStats_Empty verifies that an empty store returns all-zero stats.
|
// TestTicketStats_Empty verifies that an empty store returns all-zero stats.
|
||||||
func TestTicketStats_Empty(t *testing.T) {
|
func TestTicketStats_Empty(t *testing.T) {
|
||||||
stats := ticketstats.Stats{
|
stats := ticketstats.Stats{
|
||||||
Total: 0,
|
Total: 0,
|
||||||
Open: 0,
|
Open: 0,
|
||||||
Resolved: 0,
|
Resolved: 0,
|
||||||
Closed: 0,
|
Closed: 0,
|
||||||
ByChannel: map[string]int{},
|
ByChannel: map[string]int{},
|
||||||
ByPriority: map[string]int{},
|
ByPriority: map[string]int{},
|
||||||
HandoffCount: 0,
|
HandoffCount: 0,
|
||||||
AvgResolutionTimeMinutes: 0,
|
AvgResolutionTimeMinutes: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,14 +163,14 @@ func TestTicketStats_GroupedCounts(t *testing.T) {
|
|||||||
Resolved: 10,
|
Resolved: 10,
|
||||||
Closed: 5,
|
Closed: 5,
|
||||||
ByChannel: map[string]int{
|
ByChannel: map[string]int{
|
||||||
"api": 8,
|
"api": 8,
|
||||||
"web": 12,
|
"web": 12,
|
||||||
"wechat": 5,
|
"wechat": 5,
|
||||||
},
|
},
|
||||||
ByPriority: map[string]int{
|
ByPriority: map[string]int{
|
||||||
"P1": 3,
|
"P1": 3,
|
||||||
"P2": 15,
|
"P2": 15,
|
||||||
"P3": 7,
|
"P3": 7,
|
||||||
},
|
},
|
||||||
HandoffCount: 6,
|
HandoffCount: 6,
|
||||||
AvgResolutionTimeMinutes: 120.0,
|
AvgResolutionTimeMinutes: 120.0,
|
||||||
|
|||||||
Reference in New Issue
Block a user