From fce0b5709da18eaaed9a93729a999625cb04cfaa Mon Sep 17 00:00:00 2001 From: mlogclub Date: Sat, 2 May 2026 18:49:10 +0800 Subject: [PATCH] refactor(ticket): contract backend ticket core --- cmd/generator/generator.go | 10 +- .../ai/runtime/graphs/create_ticket_graph.go | 7 +- .../graphs/prepare_ticket_draft_graph.go | 6 - internal/bootstrap/server.go | 2 - internal/builders/ticket_builder.go | 505 +---- internal/builders/ticket_builder_test.go | 89 + internal/builders/ticket_config_builder.go | 61 - .../dashboard/ticket_controller.go | 293 +-- .../ticket_priority_config_controller.go | 102 - .../ticket_resolution_code_controller.go | 85 - .../000003_init_ticket_priority_configs.go | 44 - internal/models/models.go | 155 +- .../pkg/dto/request/ticket_config_request.go | 37 - internal/pkg/dto/request/ticket_request.go | 133 +- .../dto/response/ticket_config_response.go | 22 - internal/pkg/dto/response/ticket_response.go | 164 +- internal/pkg/enums/ticket.go | 119 +- .../ticket_collaborator_repository.go | 83 - .../repositories/ticket_comment_repository.go | 102 - .../ticket_event_log_repository.go | 102 - .../repositories/ticket_mention_repository.go | 74 - .../ticket_no_sequence_repository.go | 67 + .../ticket_priority_config_repository.go | 81 - .../ticket_progress_repository.go | 101 + .../ticket_relation_repository.go | 106 - .../ticket_resolution_code_repository.go | 81 - .../ticket_sla_record_repository.go | 109 - .../repositories/ticket_watcher_repository.go | 109 - internal/services/cronx/cron.go | 11 - .../notification_event_handler_test.go | 2 +- .../services/ticket_collaborator_service.go | 54 - internal/services/ticket_comment_service.go | 67 - internal/services/ticket_event_log_service.go | 67 - internal/services/ticket_mention_service.go | 46 - .../services/ticket_no_sequence_service.go | 98 + .../ticket_priority_config_service.go | 162 -- internal/services/ticket_progress_service.go | 66 + internal/services/ticket_relation_service.go | 212 -- .../ticket_resolution_code_service.go | 134 -- internal/services/ticket_service.go | 1956 +++-------------- internal/services/ticket_service_test.go | 860 ++------ .../services/ticket_sla_record_service.go | 67 - internal/services/ticket_watcher_service.go | 67 - 43 files changed, 1103 insertions(+), 5615 deletions(-) create mode 100644 internal/builders/ticket_builder_test.go delete mode 100644 internal/builders/ticket_config_builder.go delete mode 100644 internal/controllers/dashboard/ticket_priority_config_controller.go delete mode 100644 internal/controllers/dashboard/ticket_resolution_code_controller.go delete mode 100644 internal/migration/000003_init_ticket_priority_configs.go delete mode 100644 internal/pkg/dto/request/ticket_config_request.go delete mode 100644 internal/pkg/dto/response/ticket_config_response.go delete mode 100644 internal/repositories/ticket_collaborator_repository.go delete mode 100644 internal/repositories/ticket_comment_repository.go delete mode 100644 internal/repositories/ticket_event_log_repository.go delete mode 100644 internal/repositories/ticket_mention_repository.go delete mode 100644 internal/repositories/ticket_priority_config_repository.go create mode 100644 internal/repositories/ticket_progress_repository.go delete mode 100644 internal/repositories/ticket_relation_repository.go delete mode 100644 internal/repositories/ticket_resolution_code_repository.go delete mode 100644 internal/repositories/ticket_sla_record_repository.go delete mode 100644 internal/repositories/ticket_watcher_repository.go delete mode 100644 internal/services/ticket_collaborator_service.go delete mode 100644 internal/services/ticket_comment_service.go delete mode 100644 internal/services/ticket_event_log_service.go delete mode 100644 internal/services/ticket_mention_service.go create mode 100644 internal/services/ticket_no_sequence_service.go delete mode 100644 internal/services/ticket_priority_config_service.go create mode 100644 internal/services/ticket_progress_service.go delete mode 100644 internal/services/ticket_relation_service.go delete mode 100644 internal/services/ticket_resolution_code_service.go delete mode 100644 internal/services/ticket_sla_record_service.go delete mode 100644 internal/services/ticket_watcher_service.go diff --git a/cmd/generator/generator.go b/cmd/generator/generator.go index 1c7b061..e5cdc3f 100644 --- a/cmd/generator/generator.go +++ b/cmd/generator/generator.go @@ -49,13 +49,9 @@ func main() { codegen.GetGenerateStruct(&models.ConversationEventLog{}), codegen.GetGenerateStruct(&models.Ticket{}), codegen.GetGenerateStruct(&models.TicketTag{}), - codegen.GetGenerateStruct(&models.TicketResolutionCode{}), - codegen.GetGenerateStruct(&models.TicketPriorityConfig{}), - codegen.GetGenerateStruct(&models.TicketComment{}), - codegen.GetGenerateStruct(&models.TicketWatcher{}), - codegen.GetGenerateStruct(&models.TicketEventLog{}), - codegen.GetGenerateStruct(&models.TicketSLARecord{}), - codegen.GetGenerateStruct(&models.TicketRelation{}), + codegen.GetGenerateStruct(&models.TicketProgress{}), + codegen.GetGenerateStruct(&models.TicketView{}), + codegen.GetGenerateStruct(&models.TicketNoSequence{}), codegen.GetGenerateStruct(&models.AgentProfile{}), codegen.GetGenerateStruct(&models.AgentTeam{}), codegen.GetGenerateStruct(&models.AgentTeamSchedule{}), diff --git a/internal/ai/runtime/graphs/create_ticket_graph.go b/internal/ai/runtime/graphs/create_ticket_graph.go index e55b8fa..41e588a 100644 --- a/internal/ai/runtime/graphs/create_ticket_graph.go +++ b/internal/ai/runtime/graphs/create_ticket_graph.go @@ -27,8 +27,6 @@ type CreateTicketGraphInterruptInfo struct { type createTicketGraphArgs struct { Title string `json:"title"` Description string `json:"description"` - Priority int64 `json:"priority"` - Severity int `json:"severity"` } func init() { @@ -100,8 +98,7 @@ func (g *CreateTicketGraph) Run(ctx context.Context, argumentsInJSON string) (st func (g *CreateTicketGraph) buildCreateRequest(argumentsInJSON string) (request.CreateTicketFromConversationRequest, error) { req := request.CreateTicketFromConversationRequest{ - ConversationID: g.conversation.ID, - SyncToConversation: true, + ConversationID: g.conversation.ID, } var args createTicketGraphArgs if strings.TrimSpace(argumentsInJSON) != "" { @@ -111,8 +108,6 @@ func (g *CreateTicketGraph) buildCreateRequest(argumentsInJSON string) (request. } req.Title = strings.TrimSpace(args.Title) req.Description = strings.TrimSpace(args.Description) - req.Priority = args.Priority - req.Severity = args.Severity if req.Title == "" { req.Title = strings.TrimSpace(g.conversation.LastMessageSummary) } diff --git a/internal/ai/runtime/graphs/prepare_ticket_draft_graph.go b/internal/ai/runtime/graphs/prepare_ticket_draft_graph.go index 4291e9f..5d67d45 100644 --- a/internal/ai/runtime/graphs/prepare_ticket_draft_graph.go +++ b/internal/ai/runtime/graphs/prepare_ticket_draft_graph.go @@ -18,16 +18,12 @@ type PrepareTicketDraftInput struct { Impact string `json:"impact"` ExpectedOutcome string `json:"expectedOutcome"` CurrentAttempt string `json:"currentAttempt"` - Priority int64 `json:"priority"` - Severity int `json:"severity"` } type PrepareTicketDraftResult struct { Ready bool `json:"ready"` Title string `json:"title"` Description string `json:"description"` - Priority int64 `json:"priority,omitempty"` - Severity int `json:"severity,omitempty"` MissingFields []string `json:"missingFields,omitempty"` FollowUpQuestions []string `json:"followUpQuestions,omitempty"` ConversationFacts []string `json:"conversationFacts,omitempty"` @@ -74,8 +70,6 @@ func (g *PrepareTicketDraftGraph) parseInput(argumentsInJSON string) (PrepareTic func buildPrepareTicketDraftResult(conversation models.Conversation, messages []models.Message, input PrepareTicketDraftInput) PrepareTicketDraftResult { result := PrepareTicketDraftResult{ - Priority: input.Priority, - Severity: input.Severity, MissingFields: make([]string, 0, 2), FollowUpQuestions: make([]string, 0, 2), ConversationFacts: buildConversationFacts(conversation, messages), diff --git a/internal/bootstrap/server.go b/internal/bootstrap/server.go index 030ae02..fdc54a0 100644 --- a/internal/bootstrap/server.go +++ b/internal/bootstrap/server.go @@ -119,8 +119,6 @@ func addRouter(app *iris.Application) { m.Party("/conversation").Handle(new(dashboard.ConversationController)) m.Party("/ticket").Handle(new(dashboard.TicketController)) m.Party("/notification").Handle(new(dashboard.NotificationController)) - m.Party("/ticket-resolution-code").Handle(new(dashboard.TicketResolutionCodeController)) - m.Party("/ticket-priority-config").Handle(new(dashboard.TicketPriorityConfigController)) m.Party("/quick-reply").Handle(new(dashboard.QuickReplyController)) m.Party("/channel").Handle(new(dashboard.ChannelController)) m.Party("/agent").Handle(new(dashboard.AgentController)) diff --git a/internal/builders/ticket_builder.go b/internal/builders/ticket_builder.go index 6d2db9e..a6fbe48 100644 --- a/internal/builders/ticket_builder.go +++ b/internal/builders/ticket_builder.go @@ -1,35 +1,23 @@ package builders import ( - "cs-agent/internal/models" - "cs-agent/internal/pkg/dto/response" - "cs-agent/internal/pkg/enums" - "cs-agent/internal/pkg/utils" - "cs-agent/internal/services" "encoding/json" "strings" - "github.com/mlogclub/simple/sqls" + "cs-agent/internal/models" + "cs-agent/internal/pkg/dto/response" + "cs-agent/internal/pkg/utils" + "cs-agent/internal/services" ) type TicketBuildContext struct { - TagsByTicketID map[int64][]models.Tag - Priorities map[int64]*models.TicketPriorityConfig - ResolutionCodes map[string]*models.TicketResolutionCode - Users map[int64]*models.User - Teams map[int64]*models.AgentTeam - Customers map[int64]*models.Customer - SLAByTicketID map[int64][]models.TicketSLARecord - WatchedTicketIDs map[int64]struct{} + TagsByTicketID map[int64][]models.Tag + Users map[int64]*models.User + Customers map[int64]*models.Customer } type TicketDetailBuildContext struct { - Users map[int64]*models.User - Teams map[int64]*models.AgentTeam - AgentProfiles map[int64]*models.AgentProfile - RelatedTickets map[int64]*models.Ticket - Customers map[int64]*models.Customer - AIAgents map[int64]*models.AIAgent + Users map[int64]*models.User } func BuildTicket(item *models.Ticket) *response.TicketResponse { @@ -41,100 +29,43 @@ func BuildTicketWithContext(item *models.Ticket, ctx *TicketBuildContext) *respo return nil } ret := &response.TicketResponse{ - ID: item.ID, - TicketNo: item.TicketNo, - Title: item.Title, - Description: item.Description, - Source: item.Source, - Channel: item.Channel, - CustomerID: item.CustomerID, - ConversationID: item.ConversationID, - Type: item.Type, - Priority: item.Priority, - Severity: item.Severity, - Status: item.Status, - CurrentTeamID: item.CurrentTeamID, - CurrentAssigneeID: item.CurrentAssigneeID, - PendingReason: item.PendingReason, - CloseReason: item.CloseReason, - ResolutionCode: item.ResolutionCode, - ResolutionSummary: item.ResolutionSummary, - FirstResponseAt: utils.FormatTimePtr(item.FirstResponseAt), - ResolvedAt: utils.FormatTimePtr(item.ResolvedAt), - ClosedAt: utils.FormatTimePtr(item.ClosedAt), - DueAt: utils.FormatTimePtr(item.DueAt), - NextReplyDeadlineAt: utils.FormatTimePtr(item.NextReplyDeadlineAt), - ResolveDeadlineAt: utils.FormatTimePtr(item.ResolveDeadlineAt), - ReopenedCount: item.ReopenedCount, - CreatedAt: utils.FormatTime(item.CreatedAt), - UpdatedAt: utils.FormatTime(item.UpdatedAt), - } - if ctx != nil { - if _, ok := ctx.WatchedTicketIDs[item.ID]; ok { - ret.WatchedByMe = true - } + ID: item.ID, + TicketNo: item.TicketNo, + Title: item.Title, + Description: item.Description, + Source: item.Source, + Channel: item.Channel, + CustomerID: item.CustomerID, + ConversationID: item.ConversationID, + Status: item.Status, + CurrentAssigneeID: item.CurrentAssigneeID, + CreatedBy: item.CreateUserID, + CreatedByName: item.CreateUserName, + HandledAt: utils.FormatTimePtr(item.HandledAt), + CreatedAt: utils.FormatTime(item.CreatedAt), + UpdatedAt: utils.FormatTime(item.UpdatedAt), } if ctx != nil && ctx.TagsByTicketID != nil { ret.Tags = BuildTagResponses(ctx.TagsByTicketID[item.ID]) } else { ret.Tags = BuildTagResponses(services.TicketService.GetTags(item.ID)) } - if item.Priority > 0 && ctx != nil && ctx.Priorities != nil { - if priority := ctx.Priorities[item.Priority]; priority != nil { - ret.PriorityName = priority.Name + if item.CurrentAssigneeID > 0 { + if ctx != nil && ctx.Users != nil { + ret.CurrentAssigneeName = buildTicketUserDisplayName(ctx.Users[item.CurrentAssigneeID]) } - } else if item.Priority > 0 { - if priority := services.TicketPriorityConfigService.Get(item.Priority); priority != nil { - ret.PriorityName = priority.Name + if ret.CurrentAssigneeName == "" { + ret.CurrentAssigneeName = buildTicketUserDisplayName(services.UserService.Get(item.CurrentAssigneeID)) } } - if item.ResolutionCode != "" && ctx != nil && ctx.ResolutionCodes != nil { - if code := ctx.ResolutionCodes[item.ResolutionCode]; code != nil { - ret.ResolutionCodeName = code.Name + if item.CustomerID > 0 { + if ctx != nil && ctx.Customers != nil { + ret.Customer = BuildCustomer(ctx.Customers[item.CustomerID]) } - } else if item.ResolutionCode != "" { - if code := services.TicketResolutionCodeService.Take("code = ? AND status <> ?", item.ResolutionCode, enums.StatusDeleted); code != nil { - ret.ResolutionCodeName = code.Name + if ret.Customer == nil { + ret.Customer = BuildCustomer(services.CustomerService.Get(item.CustomerID)) } } - if item.CurrentAssigneeID > 0 && ctx != nil && ctx.Users != nil { - if user := ctx.Users[item.CurrentAssigneeID]; user != nil { - ret.CurrentAssigneeName = user.Nickname - if ret.CurrentAssigneeName == "" { - ret.CurrentAssigneeName = user.Username - } - } - } else if item.CurrentAssigneeID > 0 { - if user := services.UserService.Get(item.CurrentAssigneeID); user != nil { - ret.CurrentAssigneeName = user.Nickname - if ret.CurrentAssigneeName == "" { - ret.CurrentAssigneeName = user.Username - } - } - } - if item.CurrentTeamID > 0 && ctx != nil && ctx.Teams != nil { - if team := ctx.Teams[item.CurrentTeamID]; team != nil { - ret.CurrentTeamName = team.Name - } - } else if item.CurrentTeamID > 0 { - if team := services.AgentTeamService.Get(item.CurrentTeamID); team != nil { - ret.CurrentTeamName = team.Name - } - } - if item.CustomerID > 0 && ctx != nil && ctx.Customers != nil { - ret.Customer = BuildCustomer(ctx.Customers[item.CustomerID]) - } else if item.CustomerID > 0 { - ret.Customer = BuildCustomer(services.CustomerService.Get(item.CustomerID)) - } - if ctx != nil && ctx.SLAByTicketID != nil { - ret.SLA = BuildTicketSLAList(ctx.SLAByTicketID[item.ID]) - } else { - ret.SLA = BuildTicketSLAList( - services.TicketSLARecordService.Find( - sqls.NewCnd().Eq("ticket_id", item.ID).Asc("id"), - ), - ) - } return ret } @@ -155,343 +86,79 @@ func BuildTicketListWithContext(list []models.Ticket, ctx *TicketBuildContext) [ return results } -func BuildTicketComment(item *models.TicketComment) *response.TicketCommentResponse { - return BuildTicketCommentWithContext(item, nil) +func BuildTicketProgress(item *models.TicketProgress) *response.TicketProgressResponse { + return BuildTicketProgressWithContext(item, nil) } -func BuildTicketCommentWithContext(item *models.TicketComment, ctx *TicketDetailBuildContext) *response.TicketCommentResponse { +func BuildTicketProgressWithContext(item *models.TicketProgress, ctx *TicketDetailBuildContext) *response.TicketProgressResponse { if item == nil { return nil } - ret := &response.TicketCommentResponse{ - ID: item.ID, - TicketID: item.TicketID, - CommentType: item.CommentType, - AuthorType: item.AuthorType, - AuthorID: item.AuthorID, - ContentType: item.ContentType, - Content: item.Content, - Payload: item.Payload, - CreatedAt: utils.FormatTime(item.CreatedAt), + ret := &response.TicketProgressResponse{ + ID: item.ID, + TicketID: item.TicketID, + Content: item.Content, + AuthorID: item.AuthorID, + CreatedAt: utils.FormatTime(item.CreatedAt), + } + if item.AuthorID > 0 { + if ctx != nil && ctx.Users != nil { + ret.AuthorName = buildTicketUserDisplayName(ctx.Users[item.AuthorID]) + } + if ret.AuthorName == "" { + ret.AuthorName = buildTicketUserDisplayName(services.UserService.Get(item.AuthorID)) + } } - ret.AuthorName = buildTicketOperatorName(item.AuthorType, item.AuthorID, ctx) return ret } -func BuildTicketCommentList(list []models.TicketComment) []response.TicketCommentResponse { - return BuildTicketCommentListWithContext(list, nil) -} - -func BuildTicketCommentListWithContext(list []models.TicketComment, ctx *TicketDetailBuildContext) []response.TicketCommentResponse { +func BuildTicketProgressListWithContext(list []models.TicketProgress, ctx *TicketDetailBuildContext) []response.TicketProgressResponse { if len(list) == 0 { return nil } - results := make([]response.TicketCommentResponse, 0, len(list)) + results := make([]response.TicketProgressResponse, 0, len(list)) for i := range list { - if item := BuildTicketCommentWithContext(&list[i], ctx); item != nil { + if item := BuildTicketProgressWithContext(&list[i], ctx); item != nil { results = append(results, *item) } } return results } -func BuildTicketEventLog(item *models.TicketEventLog) *response.TicketEventLogResponse { - return BuildTicketEventLogWithContext(item, nil) -} - -func BuildTicketEventLogWithContext(item *models.TicketEventLog, ctx *TicketDetailBuildContext) *response.TicketEventLogResponse { - if item == nil { - return nil - } - return &response.TicketEventLogResponse{ - ID: item.ID, - TicketID: item.TicketID, - EventType: item.EventType, - OperatorType: item.OperatorType, - OperatorID: item.OperatorID, - OperatorName: buildTicketOperatorName(item.OperatorType, item.OperatorID, ctx), - OldValue: item.OldValue, - NewValue: item.NewValue, - Content: item.Content, - Payload: item.Payload, - CreatedAt: utils.FormatTime(item.CreatedAt), - } -} - -func BuildTicketEventLogList(list []models.TicketEventLog) []response.TicketEventLogResponse { - return BuildTicketEventLogListWithContext(list, nil) -} - -func BuildTicketEventLogListWithContext(list []models.TicketEventLog, ctx *TicketDetailBuildContext) []response.TicketEventLogResponse { - if len(list) == 0 { - return nil - } - results := make([]response.TicketEventLogResponse, 0, len(list)) - for i := range list { - if item := BuildTicketEventLogWithContext(&list[i], ctx); item != nil { - results = append(results, *item) - } - } - return results -} - -func BuildTicketSLAList(list []models.TicketSLARecord) []response.TicketSLAResponse { - if len(list) == 0 { - return nil - } - results := make([]response.TicketSLAResponse, 0, len(list)) - for i := range list { - item := &list[i] - results = append(results, response.TicketSLAResponse{ - SLAType: item.SLAType, - TargetMinutes: item.TargetMinutes, - Status: item.Status, - StartedAt: utils.FormatTimePtr(item.StartedAt), - PausedAt: utils.FormatTimePtr(item.PausedAt), - StoppedAt: utils.FormatTimePtr(item.StoppedAt), - BreachedAt: utils.FormatTimePtr(item.BreachedAt), - ElapsedMin: item.ElapsedMin, - }) - } - return results -} - func BuildTicketDetail(aggregate *services.TicketDetailAggregate) *response.TicketDetailResponse { - return BuildTicketDetailWithContext(aggregate, nil) -} - -func BuildTicketDetailWithContext(aggregate *services.TicketDetailAggregate, ctx *TicketDetailBuildContext) *response.TicketDetailResponse { if aggregate == nil || aggregate.Ticket == nil { return nil } - if ctx == nil { - users := make(map[int64]*models.User, len(aggregate.Users)+len(aggregate.OperatorUsers)) - for id, item := range aggregate.Users { - users[id] = item - } - for id, item := range aggregate.OperatorUsers { - users[id] = item - } - ctx = &TicketDetailBuildContext{ - Users: users, - Teams: aggregate.Teams, - AgentProfiles: aggregate.AgentProfiles, - RelatedTickets: aggregate.RelatedMap, - Customers: aggregate.OperatorCustomers, - AIAgents: aggregate.OperatorAIAgents, - } - } - ret := &response.TicketDetailResponse{ - Ticket: *BuildTicket(aggregate.Ticket), - Watchers: BuildTicketWatcherListWithContext(aggregate.Watchers, ctx), - Collaborators: BuildTicketCollaboratorListWithContext(aggregate.Collaborators, ctx), - RelatedTickets: BuildTicketRelationListWithContext(aggregate.RelatedTickets, ctx), - } - if len(aggregate.Comments) > 0 { - ret.Comments = make([]response.TicketCommentResponse, 0, len(aggregate.Comments)) - for i := range aggregate.Comments { - if item := BuildTicketCommentWithContext(&aggregate.Comments[i], ctx); item != nil { - ret.Comments = append(ret.Comments, *item) - } - } - } - if len(aggregate.Events) > 0 { - ret.Events = make([]response.TicketEventLogResponse, 0, len(aggregate.Events)) - for i := range aggregate.Events { - if item := BuildTicketEventLogWithContext(&aggregate.Events[i], ctx); item != nil { - ret.Events = append(ret.Events, *item) - } - } + ctx := &TicketBuildContext{ + TagsByTicketID: map[int64][]models.Tag{aggregate.Ticket.ID: aggregate.Tags}, + Users: aggregate.Users, + Customers: map[int64]*models.Customer{}, } if aggregate.Customer != nil { - ret.Ticket.Customer = BuildCustomer(aggregate.Customer) + ctx.Customers[aggregate.Customer.ID] = aggregate.Customer } - ret.Ticket.Tags = BuildTagResponses(aggregate.Tags) - ret.Ticket.SLA = BuildTicketSLAList(aggregate.SLAs) - for i := range ret.Comments { - if ret.Comments[i].AuthorName == "" { - ret.Comments[i].AuthorName = buildTicketOperatorName(ret.Comments[i].AuthorType, ret.Comments[i].AuthorID, ctx) - } - } - for i := range ret.Events { - if ret.Events[i].OperatorName == "" { - ret.Events[i].OperatorName = buildTicketOperatorName(ret.Events[i].OperatorType, ret.Events[i].OperatorID, ctx) - } + ret := &response.TicketDetailResponse{ + Ticket: *BuildTicketWithContext(aggregate.Ticket, ctx), } + ret.Progresses = BuildTicketProgressListWithContext(aggregate.Progresses, &TicketDetailBuildContext{Users: aggregate.Users}) return ret } -func BuildTicketRelation(item *models.TicketRelation) *response.TicketRelationResponse { - return BuildTicketRelationWithContext(item, nil) -} - -func BuildTicketRelationWithContext(item *models.TicketRelation, ctx *TicketDetailBuildContext) *response.TicketRelationResponse { - if item == nil { - return nil - } - ret := &response.TicketRelationResponse{ - ID: item.ID, - TicketID: item.TicketID, - RelatedTicketID: item.RelatedTicketID, - RelationType: item.RelationType, - } - related := (*models.Ticket)(nil) - if ctx != nil && ctx.RelatedTickets != nil { - related = ctx.RelatedTickets[item.RelatedTicketID] - } - if related == nil { - related = services.TicketService.Get(item.RelatedTicketID) - } - if related != nil { - ret.RelatedTicketNo = related.TicketNo - ret.RelatedTicketTitle = related.Title - ret.RelatedTicketStatus = related.Status - ret.UpdatedAt = utils.FormatTime(related.UpdatedAt) - if related.CurrentTeamID > 0 && ctx != nil && ctx.Teams != nil { - if team := ctx.Teams[related.CurrentTeamID]; team != nil { - ret.CurrentTeamName = team.Name - } - } else if related.CurrentTeamID > 0 { - if team := services.AgentTeamService.Get(related.CurrentTeamID); team != nil { - ret.CurrentTeamName = team.Name - } - } - if related.CurrentAssigneeID > 0 && ctx != nil && ctx.Users != nil { - ret.CurrentAssigneeName = buildTicketUserDisplayName(ctx.Users[related.CurrentAssigneeID]) - } else if related.CurrentAssigneeID > 0 { - if user := services.UserService.Get(related.CurrentAssigneeID); user != nil { - ret.CurrentAssigneeName = user.Nickname - if ret.CurrentAssigneeName == "" { - ret.CurrentAssigneeName = user.Username - } - } - } - } - return ret -} - -func BuildTicketRelationList(list []models.TicketRelation) []response.TicketRelationResponse { - return BuildTicketRelationListWithContext(list, nil) -} - -func BuildTicketRelationListWithContext(list []models.TicketRelation, ctx *TicketDetailBuildContext) []response.TicketRelationResponse { - if len(list) == 0 { - return nil - } - results := make([]response.TicketRelationResponse, 0, len(list)) - for i := range list { - if item := BuildTicketRelationWithContext(&list[i], ctx); item != nil { - results = append(results, *item) - } - } - return results -} - func BuildTicketSummary(summary *services.TicketSummaryAggregate) *response.TicketSummaryResponse { if summary == nil { return nil } return &response.TicketSummaryResponse{ - All: summary.All, - Mine: summary.Mine, - Watching: summary.Watching, - Collaboration: summary.Collaboration, - Participating: summary.Participating, - Mentioned: summary.Mentioned, - Unassigned: summary.Unassigned, - PendingCustomer: summary.PendingCustomer, - PendingInternal: summary.PendingInternal, - Overdue: summary.Overdue, + All: summary.All, + Pending: summary.Pending, + InProgress: summary.InProgress, + Done: summary.Done, + Unassigned: summary.Unassigned, + Mine: summary.Mine, + Stale: summary.Stale, } } -func BuildTicketRiskOverview(overview *services.TicketRiskOverviewAggregate) *response.TicketRiskOverviewResponse { - if overview == nil { - return nil - } - ret := &response.TicketRiskOverviewResponse{ - Overdue: overview.Overdue, - HighRisk: overview.HighRisk, - Unassigned: overview.Unassigned, - PendingInternal: overview.PendingInternal, - PendingCustomer: overview.PendingCustomer, - RiskWindowMins: overview.RiskWindowMins, - } - if len(overview.Reasons) > 0 { - ret.Reasons = make([]response.TicketRiskReasonResponse, 0, len(overview.Reasons)) - for _, item := range overview.Reasons { - ret.Reasons = append(ret.Reasons, response.TicketRiskReasonResponse{ - Code: item.Code, - Title: item.Title, - Description: item.Description, - Count: item.Count, - }) - } - } - return ret -} - -func BuildTicketWatcherList(list []models.TicketWatcher) []response.TicketWatcherResponse { - return BuildTicketWatcherListWithContext(list, nil) -} - -func BuildTicketWatcherListWithContext(list []models.TicketWatcher, ctx *TicketDetailBuildContext) []response.TicketWatcherResponse { - if len(list) == 0 { - return nil - } - results := make([]response.TicketWatcherResponse, 0, len(list)) - for i := range list { - item := &list[i] - out := response.TicketWatcherResponse{ - ID: item.ID, - UserID: item.UserID, - } - if ctx != nil && ctx.Users != nil { - out.UserName = buildTicketUserDisplayName(ctx.Users[item.UserID]) - } else if user := services.UserService.Get(item.UserID); user != nil { - out.UserName = buildTicketUserDisplayName(user) - } - results = append(results, out) - } - return results -} - -func BuildTicketCollaboratorList(list []models.TicketCollaborator) []response.TicketCollaboratorResponse { - return BuildTicketCollaboratorListWithContext(list, nil) -} - -func BuildTicketCollaboratorListWithContext(list []models.TicketCollaborator, ctx *TicketDetailBuildContext) []response.TicketCollaboratorResponse { - if len(list) == 0 { - return nil - } - results := make([]response.TicketCollaboratorResponse, 0, len(list)) - for i := range list { - item := &list[i] - out := response.TicketCollaboratorResponse{ - ID: item.ID, - UserID: item.UserID, - } - if ctx != nil && ctx.Users != nil { - out.UserName = buildTicketUserDisplayName(ctx.Users[item.UserID]) - } else if user := services.UserService.Get(item.UserID); user != nil { - out.UserName = buildTicketUserDisplayName(user) - } - if ctx != nil && ctx.AgentProfiles != nil { - if profile := ctx.AgentProfiles[item.UserID]; profile != nil && profile.TeamID > 0 { - if team := ctx.Teams[profile.TeamID]; team != nil { - out.TeamName = team.Name - } - } - } else if profile := services.AgentProfileService.GetByUserID(item.UserID); profile != nil && profile.TeamID > 0 { - if team := services.AgentTeamService.Get(profile.TeamID); team != nil { - out.TeamName = team.Name - } - } - results = append(results, out) - } - return results -} - func BuildTicketView(item *models.TicketView) *response.TicketViewResponse { if item == nil { return nil @@ -529,39 +196,3 @@ func buildTicketUserDisplayName(user *models.User) string { } return user.Username } - -func buildTicketOperatorName(senderType enums.IMSenderType, senderID int64, ctx *TicketDetailBuildContext) string { - if senderID <= 0 { - return "" - } - switch senderType { - case enums.IMSenderTypeAgent: - if ctx != nil && ctx.Users != nil { - if user := ctx.Users[senderID]; user != nil { - return buildTicketUserDisplayName(user) - } - } - if user := services.UserService.Get(senderID); user != nil { - return buildTicketUserDisplayName(user) - } - case enums.IMSenderTypeCustomer: - if ctx != nil && ctx.Customers != nil { - if customer := ctx.Customers[senderID]; customer != nil { - return customer.Name - } - } - if customer := services.CustomerService.Get(senderID); customer != nil { - return customer.Name - } - case enums.IMSenderTypeAI: - if ctx != nil && ctx.AIAgents != nil { - if ai := ctx.AIAgents[senderID]; ai != nil { - return ai.Name - } - } - if ai := services.AIAgentService.Get(senderID); ai != nil { - return ai.Name - } - } - return "" -} diff --git a/internal/builders/ticket_builder_test.go b/internal/builders/ticket_builder_test.go new file mode 100644 index 0000000..46ab069 --- /dev/null +++ b/internal/builders/ticket_builder_test.go @@ -0,0 +1,89 @@ +package builders + +import ( + "testing" + "time" + + "cs-agent/internal/models" + "cs-agent/internal/pkg/enums" +) + +func TestBuildLightweightTicket(t *testing.T) { + now := time.Date(2026, 5, 2, 12, 30, 0, 0, time.Local) + ticket := &models.Ticket{ + ID: 12, + TicketNo: "TK202605020001", + Title: "登录失败", + Description: "客户反馈无法登录", + Source: enums.TicketSourceManual, + Channel: "web", + CustomerID: 3, + ConversationID: 4, + Status: enums.TicketStatusPending, + CurrentAssigneeID: 5, + AuditFields: models.AuditFields{ + CreateUserID: 1, + CreateUserName: "admin", + CreatedAt: now, + UpdatedAt: now, + }, + } + ctx := &TicketBuildContext{ + TagsByTicketID: map[int64][]models.Tag{ + 12: {{ID: 8, Name: "登录", Status: enums.StatusOk}}, + }, + Users: map[int64]*models.User{ + 5: {ID: 5, Username: "agent", Nickname: "客服"}, + }, + Customers: map[int64]*models.Customer{ + 3: {ID: 3, Name: "客户"}, + }, + } + + out := BuildTicketWithContext(ticket, ctx) + if out == nil { + t.Fatalf("expected ticket response") + } + if out.ID != ticket.ID || out.TicketNo != ticket.TicketNo || out.Status != ticket.Status { + t.Fatalf("unexpected ticket response: %+v", out) + } + if out.CurrentAssigneeName != "客服" { + t.Fatalf("expected assignee name, got %q", out.CurrentAssigneeName) + } + if len(out.Tags) != 1 || out.Tags[0].ID != 8 { + t.Fatalf("expected tag response, got %+v", out.Tags) + } + if out.Customer == nil || out.Customer.ID != 3 { + t.Fatalf("expected customer response, got %+v", out.Customer) + } +} + +func TestBuildTicketProgress(t *testing.T) { + now := time.Date(2026, 5, 2, 12, 30, 0, 0, time.Local) + progress := &models.TicketProgress{ + ID: 1, + TicketID: 2, + Content: "已联系客户", + AuthorID: 3, + CreatedAt: now, + } + ctx := &TicketDetailBuildContext{ + Users: map[int64]*models.User{ + 3: {ID: 3, Username: "agent", Nickname: "客服"}, + }, + } + + out := BuildTicketProgressWithContext(progress, ctx) + if out == nil { + t.Fatalf("expected progress response") + } + if out.ID != progress.ID || out.TicketID != progress.TicketID || out.Content != progress.Content { + t.Fatalf("unexpected progress response: %+v", out) + } + if out.AuthorName != "客服" { + t.Fatalf("expected author name, got %q", out.AuthorName) + } + if out.CreatedAt == "" { + t.Fatalf("expected createdAt to be formatted") + } +} diff --git a/internal/builders/ticket_config_builder.go b/internal/builders/ticket_config_builder.go deleted file mode 100644 index 17d48b9..0000000 --- a/internal/builders/ticket_config_builder.go +++ /dev/null @@ -1,61 +0,0 @@ -package builders - -import ( - "cs-agent/internal/models" - "cs-agent/internal/pkg/dto/response" -) - -func BuildTicketResolutionCode(item *models.TicketResolutionCode) *response.TicketResolutionCodeResponse { - if item == nil { - return nil - } - return &response.TicketResolutionCodeResponse{ - ID: item.ID, - Name: item.Name, - Code: item.Code, - SortNo: item.SortNo, - Status: item.Status, - Remark: item.Remark, - } -} - -func BuildTicketResolutionCodeList(list []models.TicketResolutionCode) []response.TicketResolutionCodeResponse { - if len(list) == 0 { - return make([]response.TicketResolutionCodeResponse, 0) - } - ret := make([]response.TicketResolutionCodeResponse, 0, len(list)) - for i := range list { - if item := BuildTicketResolutionCode(&list[i]); item != nil { - ret = append(ret, *item) - } - } - return ret -} - -func BuildTicketPriorityConfig(item *models.TicketPriorityConfig) *response.TicketPriorityConfigResponse { - if item == nil { - return nil - } - return &response.TicketPriorityConfigResponse{ - ID: item.ID, - Name: item.Name, - SortNo: item.SortNo, - FirstResponseMinutes: item.FirstResponseMinutes, - ResolutionMinutes: item.ResolutionMinutes, - Status: item.Status, - Remark: item.Remark, - } -} - -func BuildTicketPriorityConfigList(list []models.TicketPriorityConfig) []response.TicketPriorityConfigResponse { - if len(list) == 0 { - return make([]response.TicketPriorityConfigResponse, 0) - } - ret := make([]response.TicketPriorityConfigResponse, 0, len(list)) - for i := range list { - if item := BuildTicketPriorityConfig(&list[i]); item != nil { - ret = append(ret, *item) - } - } - return ret -} diff --git a/internal/controllers/dashboard/ticket_controller.go b/internal/controllers/dashboard/ticket_controller.go index abe760c..b50e375 100644 --- a/internal/controllers/dashboard/ticket_controller.go +++ b/internal/controllers/dashboard/ticket_controller.go @@ -2,12 +2,10 @@ package dashboard import ( "strings" - "time" "cs-agent/internal/builders" "cs-agent/internal/pkg/constants" "cs-agent/internal/pkg/dto/request" - "cs-agent/internal/pkg/enums" "cs-agent/internal/services" "github.com/kataras/iris/v12" @@ -26,13 +24,11 @@ func (c *TicketController) AnyList() *web.JsonResult { } cnd := params.NewPagedSqlCnd(c.Ctx, params.QueryFilter{ParamName: "status"}, - params.QueryFilter{ParamName: "priority"}, - params.QueryFilter{ParamName: "severity"}, - params.QueryFilter{ParamName: "currentTeamId"}, params.QueryFilter{ParamName: "currentAssigneeId"}, params.QueryFilter{ParamName: "customerId"}, params.QueryFilter{ParamName: "conversationId"}, params.QueryFilter{ParamName: "source"}, + params.QueryFilter{ParamName: "channel"}, ).Desc("updated_at").Desc("id") if keyword, _ := params.Get(c.Ctx, "keyword"); strings.TrimSpace(keyword) != "" { keyword = "%" + strings.TrimSpace(keyword) + "%" @@ -41,47 +37,21 @@ func (c *TicketController) AnyList() *web.JsonResult { if tagID, _ := params.GetInt64(c.Ctx, "tagId"); tagID > 0 { cnd.Where("id IN (SELECT ticket_id FROM t_ticket_tag WHERE tag_id = ?)", tagID) } - if watching, _ := params.Get(c.Ctx, "watching"); watching == "1" || strings.EqualFold(watching, "true") { - cnd.Where("id IN (SELECT ticket_id FROM t_ticket_watcher WHERE user_id = ?)", operator.UserID) - } - if collaborating, _ := params.Get(c.Ctx, "collaborating"); collaborating == "1" || strings.EqualFold(collaborating, "true") { - cnd.Where("id IN (SELECT ticket_id FROM t_ticket_collaborator WHERE user_id = ?)", operator.UserID) - } - if collaboration, _ := params.Get(c.Ctx, "collaboration"); collaboration == "1" || strings.EqualFold(collaboration, "true") { - cnd.Where( - "(id IN (SELECT ticket_id FROM t_ticket_collaborator WHERE user_id = ?) OR id IN (SELECT ticket_id FROM t_ticket_mention WHERE mentioned_user_id = ?))", - operator.UserID, - operator.UserID, - ) - } - if mentioned, _ := params.Get(c.Ctx, "mentioned"); mentioned == "1" || strings.EqualFold(mentioned, "true") { - cnd.Where("id IN (SELECT ticket_id FROM t_ticket_mention WHERE mentioned_user_id = ?)", operator.UserID) - } if mine, _ := params.Get(c.Ctx, "mine"); mine == "1" || strings.EqualFold(mine, "true") { cnd.Eq("current_assignee_id", operator.UserID) } if unassigned, _ := params.Get(c.Ctx, "unassigned"); unassigned == "1" || strings.EqualFold(unassigned, "true") { cnd.Eq("current_assignee_id", 0) } - if overdue, _ := params.Get(c.Ctx, "overdue"); overdue == "1" || strings.EqualFold(overdue, "true") { - cnd.In("status", []string{"new", "open", "pending_customer", "pending_internal"}) - cnd.Where("resolve_deadline_at IS NOT NULL") - cnd.Where("resolve_deadline_at < ?", time.Now()) - } aggregate, err := services.TicketService.FindPageAggregateByCnd(cnd, operator.UserID) if err != nil { return web.JsonError(err) } return web.JsonData(&web.PageResult{ Results: builders.BuildTicketListWithContext(aggregate.List, &builders.TicketBuildContext{ - TagsByTicketID: aggregate.TagsByTicketID, - Priorities: aggregate.Priorities, - ResolutionCodes: aggregate.ResolutionCodes, - Users: aggregate.Users, - Teams: aggregate.Teams, - Customers: aggregate.Customers, - SLAByTicketID: aggregate.SLAByTicketID, - WatchedTicketIDs: aggregate.WatchedTicketIDs, + TagsByTicketID: aggregate.TagsByTicketID, + Users: aggregate.Users, + Customers: aggregate.Customers, }), Page: aggregate.Paging, }) @@ -95,44 +65,6 @@ func (c *TicketController) AnySummary() *web.JsonResult { return web.JsonData(builders.BuildTicketSummary(services.TicketService.GetSummary(operator))) } -func (c *TicketController) AnyRisk_overview() *web.JsonResult { - if _, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketView); err != nil { - return web.JsonError(err) - } - teamID, _ := params.GetInt64(c.Ctx, "currentTeamId") - riskWindowMins, _ := params.GetInt(c.Ctx, "riskWindowMins") - return web.JsonData(builders.BuildTicketRiskOverview(services.TicketService.GetRiskOverview(teamID, riskWindowMins))) -} - -func (c *TicketController) AnyRisk_list() *web.JsonResult { - operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketView) - if err != nil { - return web.JsonError(err) - } - riskType, _ := params.Get(c.Ctx, "riskType") - teamID, _ := params.GetInt64(c.Ctx, "currentTeamId") - riskWindowMins, _ := params.GetInt(c.Ctx, "riskWindowMins") - page, _ := params.GetInt(c.Ctx, "page") - limit, _ := params.GetInt(c.Ctx, "limit") - aggregate, err := services.TicketService.GetRiskPageAggregate(riskType, teamID, riskWindowMins, page, limit, operator.UserID) - if err != nil { - return web.JsonError(err) - } - return web.JsonData(&web.PageResult{ - Results: builders.BuildTicketListWithContext(aggregate.List, &builders.TicketBuildContext{ - TagsByTicketID: aggregate.TagsByTicketID, - Priorities: aggregate.Priorities, - ResolutionCodes: aggregate.ResolutionCodes, - Users: aggregate.Users, - Teams: aggregate.Teams, - Customers: aggregate.Customers, - SLAByTicketID: aggregate.SLAByTicketID, - WatchedTicketIDs: aggregate.WatchedTicketIDs, - }), - Page: aggregate.Paging, - }) -} - func (c *TicketController) AnyView_list() *web.JsonResult { operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketView) if err != nil { @@ -230,21 +162,6 @@ func (c *TicketController) PostUpdate() *web.JsonResult { return web.JsonSuccess() } -func (c *TicketController) PostLink_customer() *web.JsonResult { - operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketUpdate) - if err != nil { - return web.JsonError(err) - } - req := request.LinkTicketCustomerRequest{} - if err := params.ReadJSON(c.Ctx, &req); err != nil { - return web.JsonError(err) - } - if err := services.TicketService.LinkTicketCustomer(req.TicketID, req.CustomerID, operator); err != nil { - return web.JsonError(err) - } - return web.JsonSuccess() -} - func (c *TicketController) PostAssign() *web.JsonResult { operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketAssign) if err != nil { @@ -290,212 +207,18 @@ func (c *TicketController) PostChange_status() *web.JsonResult { return web.JsonSuccess() } -func (c *TicketController) PostBatch_change_status() *web.JsonResult { - operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketChangeStatus) - if err != nil { - return web.JsonError(err) - } - req := request.BatchChangeTicketStatusRequest{} - if err := params.ReadJSON(c.Ctx, &req); err != nil { - return web.JsonError(err) - } - if err := services.TicketService.BatchChangeStatus(req, operator); err != nil { - return web.JsonError(err) - } - return web.JsonSuccess() -} - -func (c *TicketController) PostReply() *web.JsonResult { - operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketReply) - if err != nil { - return web.JsonError(err) - } - req := request.ReplyTicketRequest{} - if err := params.ReadJSON(c.Ctx, &req); err != nil { - return web.JsonError(err) - } - item, err := services.TicketService.ReplyTicket(req, operator) - if err != nil { - return web.JsonError(err) - } - return web.JsonData(builders.BuildTicketComment(item)) -} - -func (c *TicketController) PostInternal_note() *web.JsonResult { - operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketReply) - if err != nil { - return web.JsonError(err) - } - req := request.InternalNoteRequest{} - if err := params.ReadJSON(c.Ctx, &req); err != nil { - return web.JsonError(err) - } - item, err := services.TicketService.AddInternalNote(req, operator) - if err != nil { - return web.JsonError(err) - } - return web.JsonData(builders.BuildTicketComment(item)) -} - -func (c *TicketController) PostClose() *web.JsonResult { - operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketClose) - if err != nil { - return web.JsonError(err) - } - req := request.CloseTicketRequest{} - if err := params.ReadJSON(c.Ctx, &req); err != nil { - return web.JsonError(err) - } - if err := services.TicketService.CloseTicket(req, operator); err != nil { - return web.JsonError(err) - } - return web.JsonSuccess() -} - -func (c *TicketController) PostReopen() *web.JsonResult { - operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketReopen) - if err != nil { - return web.JsonError(err) - } - req := request.ReopenTicketRequest{} - if err := params.ReadJSON(c.Ctx, &req); err != nil { - return web.JsonError(err) - } - if err := services.TicketService.ReopenTicket(req, operator); err != nil { - return web.JsonError(err) - } - return web.JsonSuccess() -} - -func (c *TicketController) PostWatch() *web.JsonResult { - operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketView) - if err != nil { - return web.JsonError(err) - } - req := request.WatchTicketRequest{} - if err := params.ReadJSON(c.Ctx, &req); err != nil { - return web.JsonError(err) - } - if err := services.TicketService.WatchTicket(req.TicketID, operator); err != nil { - return web.JsonError(err) - } - return web.JsonSuccess() -} - -func (c *TicketController) PostUnwatch() *web.JsonResult { - operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketView) - if err != nil { - return web.JsonError(err) - } - req := request.WatchTicketRequest{} - if err := params.ReadJSON(c.Ctx, &req); err != nil { - return web.JsonError(err) - } - if err := services.TicketService.UnwatchTicket(req.TicketID, operator); err != nil { - return web.JsonError(err) - } - return web.JsonSuccess() -} - -func (c *TicketController) PostBatch_watch() *web.JsonResult { - operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketView) - if err != nil { - return web.JsonError(err) - } - req := request.BatchWatchTicketRequest{} - if err := params.ReadJSON(c.Ctx, &req); err != nil { - return web.JsonError(err) - } - if err := services.TicketService.BatchWatchTickets(req, operator); err != nil { - return web.JsonError(err) - } - return web.JsonSuccess() -} - -func (c *TicketController) PostAdd_relation() *web.JsonResult { +func (c *TicketController) PostAdd_progress() *web.JsonResult { operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketUpdate) if err != nil { return web.JsonError(err) } - req := request.AddTicketRelationRequest{} + req := request.CreateTicketProgressRequest{} if err := params.ReadJSON(c.Ctx, &req); err != nil { return web.JsonError(err) } - relatedTicketID := req.RelatedTicketID - if relatedTicketID <= 0 && strings.TrimSpace(req.RelatedTicketNo) != "" { - if relatedTicket := services.TicketService.Take("ticket_no = ?", strings.TrimSpace(req.RelatedTicketNo)); relatedTicket != nil { - relatedTicketID = relatedTicket.ID - } - } - if err := services.TicketRelationService.AddRelation(req.TicketID, relatedTicketID, enums.TicketRelationType(strings.TrimSpace(req.RelationType)), operator); err != nil { - return web.JsonError(err) - } - return web.JsonSuccess() -} - -func (c *TicketController) PostDelete_relation() *web.JsonResult { - operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketUpdate) + item, err := services.TicketService.AddProgress(req, operator) if err != nil { return web.JsonError(err) } - req := request.DeleteTicketRelationRequest{} - if err := params.ReadJSON(c.Ctx, &req); err != nil { - return web.JsonError(err) - } - if err := services.TicketRelationService.DeleteRelation(req.TicketID, req.RelationID, operator); err != nil { - return web.JsonError(err) - } - return web.JsonSuccess() -} - -func (c *TicketController) PostAdd_collaborator() *web.JsonResult { - operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketUpdate) - if err != nil { - return web.JsonError(err) - } - req := request.AddTicketCollaboratorRequest{} - if err := params.ReadJSON(c.Ctx, &req); err != nil { - return web.JsonError(err) - } - if err := services.TicketService.AddCollaborator(req.TicketID, req.UserID, operator); err != nil { - return web.JsonError(err) - } - return web.JsonSuccess() -} - -func (c *TicketController) PostDelete_collaborator() *web.JsonResult { - operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketUpdate) - if err != nil { - return web.JsonError(err) - } - req := request.DeleteTicketCollaboratorRequest{} - if err := params.ReadJSON(c.Ctx, &req); err != nil { - return web.JsonError(err) - } - if err := services.TicketService.RemoveCollaborator(req.TicketID, req.CollaboratorID, operator); err != nil { - return web.JsonError(err) - } - return web.JsonSuccess() -} - -func (c *TicketController) AnyComment_list() *web.JsonResult { - if _, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketView); err != nil { - return web.JsonError(err) - } - ticketID, _ := params.GetInt64(c.Ctx, "ticketId") - if ticketID <= 0 { - return web.JsonData(&web.PageResult{Results: []any{}, Page: params.GetPaging(c.Ctx)}) - } - cnd := params.NewPagedSqlCnd(c.Ctx, params.QueryFilter{ParamName: "ticketId"}).Asc("id") - results, paging := services.TicketCommentService.FindPageByCnd(cnd) - return web.JsonData(&web.PageResult{Results: builders.BuildTicketCommentList(results), Page: paging}) -} - -func (c *TicketController) AnyEvent_list() *web.JsonResult { - if _, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketView); err != nil { - return web.JsonError(err) - } - cnd := params.NewPagedSqlCnd(c.Ctx, params.QueryFilter{ParamName: "ticketId"}).Desc("id") - list, paging := services.TicketEventLogService.FindPageByCnd(cnd) - return web.JsonData(&web.PageResult{Results: builders.BuildTicketEventLogList(list), Page: paging}) + return web.JsonData(builders.BuildTicketProgress(item)) } diff --git a/internal/controllers/dashboard/ticket_priority_config_controller.go b/internal/controllers/dashboard/ticket_priority_config_controller.go deleted file mode 100644 index feeec1e..0000000 --- a/internal/controllers/dashboard/ticket_priority_config_controller.go +++ /dev/null @@ -1,102 +0,0 @@ -package dashboard - -import ( - "cs-agent/internal/builders" - "cs-agent/internal/pkg/constants" - "cs-agent/internal/pkg/dto/request" - "cs-agent/internal/pkg/enums" - "cs-agent/internal/services" - "strings" - - "github.com/kataras/iris/v12" - "github.com/mlogclub/simple/sqls" - "github.com/mlogclub/simple/web" - "github.com/mlogclub/simple/web/params" -) - -type TicketPriorityConfigController struct{ Ctx iris.Context } - -func (c *TicketPriorityConfigController) AnyList() *web.JsonResult { - if _, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketPriorityConfigView); err != nil { - return web.JsonError(err) - } - cnd := sqls.NewCnd().Asc("sort_no").Asc("id") - if status, ok := params.Get(c.Ctx, "status"); ok && strings.TrimSpace(status) != "" { - cnd.Eq("status", status) - } else { - cnd.Where("status <> ?", enums.StatusDeleted) - } - if name, ok := params.Get(c.Ctx, "name"); ok && strings.TrimSpace(name) != "" { - cnd.Where("name LIKE ?", "%"+strings.TrimSpace(name)+"%") - } - list := services.TicketPriorityConfigService.Find(cnd) - return web.JsonData(builders.BuildTicketPriorityConfigList(list)) -} - -func (c *TicketPriorityConfigController) GetList_all() *web.JsonResult { - if _, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketPriorityConfigView); err != nil { - return web.JsonError(err) - } - list := services.TicketPriorityConfigService.Find(sqls.NewCnd().Eq("status", enums.StatusOk).Asc("sort_no").Asc("id")) - return web.JsonData(builders.BuildTicketPriorityConfigList(list)) -} - -func (c *TicketPriorityConfigController) PostCreate() *web.JsonResult { - operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketPriorityConfigCreate) - if err != nil { - return web.JsonError(err) - } - req := request.CreateTicketPriorityConfigRequest{} - if err := params.ReadJSON(c.Ctx, &req); err != nil { - return web.JsonError(err) - } - item, err := services.TicketPriorityConfigService.CreateTicketPriorityConfig(req, operator) - if err != nil { - return web.JsonError(err) - } - return web.JsonData(builders.BuildTicketPriorityConfig(item)) -} - -func (c *TicketPriorityConfigController) PostUpdate() *web.JsonResult { - operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketPriorityConfigUpdate) - if err != nil { - return web.JsonError(err) - } - req := request.UpdateTicketPriorityConfigRequest{} - if err := params.ReadJSON(c.Ctx, &req); err != nil { - return web.JsonError(err) - } - if err := services.TicketPriorityConfigService.UpdateTicketPriorityConfig(req, operator); err != nil { - return web.JsonError(err) - } - return web.JsonSuccess() -} - -func (c *TicketPriorityConfigController) PostUpdate_sort() *web.JsonResult { - if _, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketPriorityConfigUpdate); err != nil { - return web.JsonError(err) - } - var ids []int64 - if err := c.Ctx.ReadJSON(&ids); err != nil { - return web.JsonError(err) - } - if err := services.TicketPriorityConfigService.UpdateSort(ids); err != nil { - return web.JsonError(err) - } - return web.JsonSuccess() -} - -func (c *TicketPriorityConfigController) PostDelete() *web.JsonResult { - operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketPriorityConfigDelete) - if err != nil { - return web.JsonError(err) - } - req := request.DeleteTicketPriorityConfigRequest{} - if err := params.ReadJSON(c.Ctx, &req); err != nil { - return web.JsonError(err) - } - if err := services.TicketPriorityConfigService.DeleteTicketPriorityConfig(req.ID, operator); err != nil { - return web.JsonError(err) - } - return web.JsonSuccess() -} diff --git a/internal/controllers/dashboard/ticket_resolution_code_controller.go b/internal/controllers/dashboard/ticket_resolution_code_controller.go deleted file mode 100644 index 38751e2..0000000 --- a/internal/controllers/dashboard/ticket_resolution_code_controller.go +++ /dev/null @@ -1,85 +0,0 @@ -package dashboard - -import ( - "cs-agent/internal/builders" - "cs-agent/internal/pkg/constants" - "cs-agent/internal/pkg/dto/request" - "cs-agent/internal/pkg/enums" - "cs-agent/internal/services" - - "github.com/kataras/iris/v12" - "github.com/mlogclub/simple/sqls" - "github.com/mlogclub/simple/web" - "github.com/mlogclub/simple/web/params" -) - -type TicketResolutionCodeController struct{ Ctx iris.Context } - -func (c *TicketResolutionCodeController) AnyList() *web.JsonResult { - if _, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketResolutionCodeView); err != nil { - return web.JsonError(err) - } - cnd := params.NewPagedSqlCnd(c.Ctx, - params.QueryFilter{ParamName: "status"}, - params.QueryFilter{ParamName: "name", Op: params.Like}, - ).Asc("sort_no").Desc("id") - if _, ok := params.Get(c.Ctx, "status"); !ok { - cnd.Where("status <> ?", enums.StatusDeleted) - } - list, paging := services.TicketResolutionCodeService.FindPageByCnd(cnd) - return web.JsonData(&web.PageResult{Results: builders.BuildTicketResolutionCodeList(list), Page: paging}) -} - -func (c *TicketResolutionCodeController) GetList_all() *web.JsonResult { - if _, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketResolutionCodeView); err != nil { - return web.JsonError(err) - } - list := services.TicketResolutionCodeService.Find(sqls.NewCnd().Eq("status", enums.StatusOk).Asc("sort_no").Desc("id")) - return web.JsonData(builders.BuildTicketResolutionCodeList(list)) -} - -func (c *TicketResolutionCodeController) PostCreate() *web.JsonResult { - operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketResolutionCodeCreate) - if err != nil { - return web.JsonError(err) - } - req := request.CreateTicketResolutionCodeRequest{} - if err := params.ReadJSON(c.Ctx, &req); err != nil { - return web.JsonError(err) - } - item, err := services.TicketResolutionCodeService.CreateTicketResolutionCode(req, operator) - if err != nil { - return web.JsonError(err) - } - return web.JsonData(builders.BuildTicketResolutionCode(item)) -} - -func (c *TicketResolutionCodeController) PostUpdate() *web.JsonResult { - operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketResolutionCodeUpdate) - if err != nil { - return web.JsonError(err) - } - req := request.UpdateTicketResolutionCodeRequest{} - if err := params.ReadJSON(c.Ctx, &req); err != nil { - return web.JsonError(err) - } - if err := services.TicketResolutionCodeService.UpdateTicketResolutionCode(req, operator); err != nil { - return web.JsonError(err) - } - return web.JsonSuccess() -} - -func (c *TicketResolutionCodeController) PostDelete() *web.JsonResult { - operator, err := services.AuthService.RequirePermission(c.Ctx, constants.PermissionTicketResolutionCodeDelete) - if err != nil { - return web.JsonError(err) - } - req := request.DeleteTicketResolutionCodeRequest{} - if err := params.ReadJSON(c.Ctx, &req); err != nil { - return web.JsonError(err) - } - if err := services.TicketResolutionCodeService.DeleteTicketResolutionCode(req.ID, operator); err != nil { - return web.JsonError(err) - } - return web.JsonSuccess() -} diff --git a/internal/migration/000003_init_ticket_priority_configs.go b/internal/migration/000003_init_ticket_priority_configs.go deleted file mode 100644 index 08de076..0000000 --- a/internal/migration/000003_init_ticket_priority_configs.go +++ /dev/null @@ -1,44 +0,0 @@ -package migration - -import ( - "time" - - "cs-agent/internal/models" - "cs-agent/internal/pkg/constants" - "cs-agent/internal/pkg/enums" - - "github.com/mlogclub/simple/sqls" -) - -func init() { - register(3, "init ticket priority configs", func() error { - return sqls.WithTransaction(func(ctx *sqls.TxContext) error { - var total int64 - if err := ctx.Tx.Model(&models.TicketPriorityConfig{}).Where("status <> ?", enums.StatusDeleted).Count(&total).Error; err != nil { - return err - } - if total == 0 { - now := time.Now() - items := []*models.TicketPriorityConfig{ - {Name: "普通", SortNo: 10, FirstResponseMinutes: 30, ResolutionMinutes: 1440, Status: enums.StatusOk}, - {Name: "高", SortNo: 20, FirstResponseMinutes: 10, ResolutionMinutes: 240, Status: enums.StatusOk}, - {Name: "紧急", SortNo: 30, FirstResponseMinutes: 5, ResolutionMinutes: 120, Status: enums.StatusOk}, - } - for _, item := range items { - item.AuditFields = models.AuditFields{ - CreatedAt: now, - CreateUserID: constants.SystemAuditUserID, - CreateUserName: constants.SystemAuditUserName, - UpdatedAt: now, - UpdateUserID: constants.SystemAuditUserID, - UpdateUserName: constants.SystemAuditUserName, - } - if err := ctx.Tx.Create(item).Error; err != nil { - return err - } - } - } - return nil - }) - }) -} diff --git a/internal/models/models.go b/internal/models/models.go index 7fff0b8..019d07e 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -8,8 +8,6 @@ import ( // Models 注册所有需要迁移和代码生成的模型。 var Models = []any{ &Migration{}, - &TicketNoSequence{}, - &TicketView{}, &User{}, &UserIdentity{}, &Company{}, @@ -39,15 +37,9 @@ var Models = []any{ &ConversationEventLog{}, &Ticket{}, &TicketTag{}, - &TicketResolutionCode{}, - &TicketPriorityConfig{}, - &TicketComment{}, - &TicketWatcher{}, - &TicketCollaborator{}, - &TicketMention{}, - &TicketEventLog{}, - &TicketSLARecord{}, - &TicketRelation{}, + &TicketProgress{}, + &TicketView{}, + &TicketNoSequence{}, &Notification{}, &AIAgent{}, &Channel{}, @@ -568,35 +560,19 @@ type ConversationEventLog struct { CreatedAt time.Time `gorm:"type:datetime;not null;index"` } -// Ticket 客服工单主档。 +// Ticket 客服问题记录。 type Ticket struct { - ID int64 `gorm:"primaryKey;autoIncrement"` - TicketNo string `gorm:"type:varchar(64);not null;default:'';uniqueIndex"` - Title string `gorm:"type:varchar(255);not null;default:'';index"` - Description string `gorm:"type:text"` - Source enums.TicketSource `gorm:"type:varchar(50);not null;default:'';index"` - Channel string `gorm:"type:varchar(50);not null;default:'';index"` - CustomerID int64 `gorm:"type:bigint;not null;default:0;index"` - ConversationID int64 `gorm:"type:bigint;not null;default:0;index"` - Type string `gorm:"type:varchar(50);not null;default:'';index"` - Priority int64 `gorm:"type:bigint;not null;default:0;index"` - Severity enums.TicketSeverity `gorm:"type:int;not null;default:1;index"` - Status enums.TicketStatus `gorm:"type:varchar(50);not null;default:'new';index"` - CurrentTeamID int64 `gorm:"type:bigint;not null;default:0;index"` - CurrentAssigneeID int64 `gorm:"type:bigint;not null;default:0;index"` - PendingReason string `gorm:"type:varchar(255);not null;default:''"` - CloseReason string `gorm:"type:varchar(255);not null;default:''"` - ResolutionCode string `gorm:"type:varchar(100);not null;default:''"` - ResolutionSummary string `gorm:"type:text"` - FirstResponseAt *time.Time `gorm:"type:datetime;index"` - ResolvedAt *time.Time `gorm:"type:datetime;index"` - ClosedAt *time.Time `gorm:"type:datetime;index"` - DueAt *time.Time `gorm:"type:datetime;index"` - NextReplyDeadlineAt *time.Time `gorm:"type:datetime;index"` - ResolveDeadlineAt *time.Time `gorm:"type:datetime;index"` - ReopenedCount int `gorm:"type:int;not null;default:0"` - CustomFieldsJSON string `gorm:"type:text"` - ExtraJSON string `gorm:"type:text"` + ID int64 `gorm:"primaryKey;autoIncrement"` + TicketNo string `gorm:"type:varchar(64);not null;default:'';uniqueIndex"` + Title string `gorm:"type:varchar(255);not null;default:'';index"` + Description string `gorm:"type:text"` + Source enums.TicketSource `gorm:"type:varchar(50);not null;default:'';index"` + Channel string `gorm:"type:varchar(50);not null;default:'';index"` + CustomerID int64 `gorm:"type:bigint;not null;default:0;index"` + ConversationID int64 `gorm:"type:bigint;not null;default:0;index"` + Status enums.TicketStatus `gorm:"type:varchar(50);not null;default:'pending';index"` + CurrentAssigneeID int64 `gorm:"type:bigint;not null;default:0;index"` + HandledAt *time.Time `gorm:"type:datetime;index"` AuditFields } @@ -608,106 +584,15 @@ type TicketTag struct { AuditFields } -// TicketResolutionCode 工单解决码。 -type TicketResolutionCode struct { - ID int64 `gorm:"primaryKey;autoIncrement"` - Name string `gorm:"type:varchar(100);not null;default:'';index"` - Code string `gorm:"type:varchar(100);not null;default:'';uniqueIndex"` - SortNo int `gorm:"type:int;not null;default:0;index"` - Status enums.Status `gorm:"type:int;not null;default:0;index"` - Remark string `gorm:"type:text"` - AuditFields -} - -// TicketPriorityConfig 工单优先级配置。 -type TicketPriorityConfig struct { - ID int64 `gorm:"primaryKey;autoIncrement"` - Name string `gorm:"type:varchar(100);not null;default:'';uniqueIndex"` - SortNo int `gorm:"type:int;not null;default:0;index"` - FirstResponseMinutes int `gorm:"type:int;not null;default:0"` - ResolutionMinutes int `gorm:"type:int;not null;default:0"` - Status enums.Status `gorm:"type:int;not null;default:0;index"` - Remark string `gorm:"type:text"` - AuditFields -} - -// TicketComment 工单评论。 -type TicketComment struct { - ID int64 `gorm:"primaryKey;autoIncrement"` - TicketID int64 `gorm:"type:bigint;not null;index"` - CommentType enums.TicketCommentType `gorm:"type:varchar(50);not null;default:'';index"` - AuthorType enums.IMSenderType `gorm:"type:varchar(30);not null;default:'';index"` - AuthorID int64 `gorm:"type:bigint;not null;default:0;index"` - ContentType string `gorm:"type:varchar(30);not null;default:''"` - Content string `gorm:"type:text"` - Payload string `gorm:"type:text"` - CreatedAt time.Time `gorm:"type:datetime;not null;index"` -} - -// TicketWatcher 工单关注人。 -type TicketWatcher struct { +// TicketProgress 工单处理进展。 +type TicketProgress struct { ID int64 `gorm:"primaryKey;autoIncrement"` - TicketID int64 `gorm:"type:bigint;not null;index;uniqueIndex:uk_ticket_watcher"` - UserID int64 `gorm:"type:bigint;not null;index;uniqueIndex:uk_ticket_watcher"` + TicketID int64 `gorm:"type:bigint;not null;index"` + Content string `gorm:"type:text"` + AuthorID int64 `gorm:"type:bigint;not null;default:0;index"` CreatedAt time.Time `gorm:"type:datetime;not null;index"` } -// TicketCollaborator 工单协作人。 -type TicketCollaborator struct { - ID int64 `gorm:"primaryKey;autoIncrement"` - TicketID int64 `gorm:"type:bigint;not null;index;uniqueIndex:uk_ticket_collaborator"` - UserID int64 `gorm:"type:bigint;not null;index;uniqueIndex:uk_ticket_collaborator"` - CreatedAt time.Time `gorm:"type:datetime;not null;index"` -} - -// TicketMention 工单提及记录。 -type TicketMention struct { - ID int64 `gorm:"primaryKey;autoIncrement"` - TicketID int64 `gorm:"type:bigint;not null;index;uniqueIndex:uk_ticket_mention"` - CommentID int64 `gorm:"type:bigint;not null;index;uniqueIndex:uk_ticket_mention"` - MentionedUserID int64 `gorm:"type:bigint;not null;index;uniqueIndex:uk_ticket_mention"` - CreatedAt time.Time `gorm:"type:datetime;not null;index"` -} - -// TicketEventLog 工单事件日志。 -type TicketEventLog struct { - ID int64 `gorm:"primaryKey;autoIncrement"` - TicketID int64 `gorm:"type:bigint;not null;index"` - EventType enums.TicketEventType `gorm:"type:varchar(50);not null;default:'';index"` - OperatorType enums.IMSenderType `gorm:"type:varchar(30);not null;default:'';index"` - OperatorID int64 `gorm:"type:bigint;not null;default:0;index"` - OldValue string `gorm:"type:text"` - NewValue string `gorm:"type:text"` - Content string `gorm:"type:text"` - Payload string `gorm:"type:text"` - CreatedAt time.Time `gorm:"type:datetime;not null;index"` -} - -// TicketSLARecord 工单 SLA 记录。 -type TicketSLARecord struct { - ID int64 `gorm:"primaryKey;autoIncrement"` - TicketID int64 `gorm:"type:bigint;not null;index"` - SLAType enums.TicketSLAType `gorm:"type:varchar(50);not null;default:'';index"` - TargetMinutes int `gorm:"type:int;not null;default:0"` - Status enums.TicketSLAStatus `gorm:"type:varchar(30);not null;default:'';index"` - StartedAt *time.Time `gorm:"type:datetime;index"` - PausedAt *time.Time `gorm:"type:datetime;index"` - StoppedAt *time.Time `gorm:"type:datetime;index"` - BreachedAt *time.Time `gorm:"type:datetime;index"` - ElapsedMin int `gorm:"type:int;not null;default:0"` - CreatedAt time.Time `gorm:"type:datetime;not null;index"` - UpdatedAt time.Time `gorm:"type:datetime;not null;index"` -} - -// TicketRelation 工单关联关系。 -type TicketRelation struct { - ID int64 `gorm:"primaryKey;autoIncrement"` - TicketID int64 `gorm:"type:bigint;not null;index"` - RelatedTicketID int64 `gorm:"type:bigint;not null;index"` - RelationType enums.TicketRelationType `gorm:"type:varchar(30);not null;default:'';index"` - CreatedAt time.Time `gorm:"type:datetime;not null;index"` -} - // AgentProfile 客服档案。 type AgentProfile struct { ID int64 `gorm:"primaryKey;autoIncrement"` // ID 为客服档案主键。 diff --git a/internal/pkg/dto/request/ticket_config_request.go b/internal/pkg/dto/request/ticket_config_request.go deleted file mode 100644 index 4e2763f..0000000 --- a/internal/pkg/dto/request/ticket_config_request.go +++ /dev/null @@ -1,37 +0,0 @@ -package request - -import "cs-agent/internal/pkg/enums" - -type CreateTicketResolutionCodeRequest struct { - Name string `json:"name"` - Code string `json:"code"` - SortNo int `json:"sortNo"` - Status enums.Status `json:"status"` - Remark string `json:"remark"` -} - -type UpdateTicketResolutionCodeRequest struct { - ID int64 `json:"id"` - CreateTicketResolutionCodeRequest -} - -type DeleteTicketResolutionCodeRequest struct { - ID int64 `json:"id"` -} - -type CreateTicketPriorityConfigRequest struct { - Name string `json:"name"` - FirstResponseMinutes int `json:"firstResponseMinutes"` - ResolutionMinutes int `json:"resolutionMinutes"` - Status enums.Status `json:"status"` - Remark string `json:"remark"` -} - -type UpdateTicketPriorityConfigRequest struct { - ID int64 `json:"id"` - CreateTicketPriorityConfigRequest -} - -type DeleteTicketPriorityConfigRequest struct { - ID int64 `json:"id"` -} diff --git a/internal/pkg/dto/request/ticket_request.go b/internal/pkg/dto/request/ticket_request.go index 607de32..f622eeb 100644 --- a/internal/pkg/dto/request/ticket_request.go +++ b/internal/pkg/dto/request/ticket_request.go @@ -1,143 +1,54 @@ package request type CreateTicketRequest struct { - Title string `json:"title"` - Description string `json:"description"` - Source string `json:"source"` - Channel string `json:"channel"` - CustomerID int64 `json:"customerId"` - ConversationID int64 `json:"conversationId"` - TagIDs []int64 `json:"tagIds"` - Type string `json:"type"` - Priority int64 `json:"priority"` - Severity int `json:"severity"` - CurrentTeamID int64 `json:"currentTeamId"` - CurrentAssigneeID int64 `json:"currentAssigneeId"` - DueAt string `json:"dueAt"` - CustomFields map[string]any `json:"customFields"` + Title string `json:"title"` + Description string `json:"description"` + Source string `json:"source"` + Channel string `json:"channel"` + CustomerID int64 `json:"customerId"` + ConversationID int64 `json:"conversationId"` + TagIDs []int64 `json:"tagIds"` + CurrentAssigneeID int64 `json:"currentAssigneeId"` } type CreateTicketFromConversationRequest struct { - ConversationID int64 `json:"conversationId"` - Title string `json:"title"` - Description string `json:"description"` - TagIDs []int64 `json:"tagIds"` - Priority int64 `json:"priority"` - Severity int `json:"severity"` - CurrentTeamID int64 `json:"currentTeamId"` - CurrentAssigneeID int64 `json:"currentAssigneeId"` - SyncToConversation bool `json:"syncToConversation"` - CustomFields map[string]any `json:"customFields"` + ConversationID int64 `json:"conversationId"` + Title string `json:"title"` + Description string `json:"description"` + TagIDs []int64 `json:"tagIds"` + CurrentAssigneeID int64 `json:"currentAssigneeId"` } type UpdateTicketRequest struct { - TicketID int64 `json:"ticketId"` - Title string `json:"title"` - Description string `json:"description"` - TagIDs []int64 `json:"tagIds"` - Type string `json:"type"` - Priority int64 `json:"priority"` - Severity int `json:"severity"` - CurrentTeamID int64 `json:"currentTeamId"` - CurrentAssigneeID int64 `json:"currentAssigneeId"` - DueAt string `json:"dueAt"` - CustomFields map[string]any `json:"customFields"` + TicketID int64 `json:"ticketId"` + Title string `json:"title"` + Description string `json:"description"` + TagIDs []int64 `json:"tagIds"` + CurrentAssigneeID int64 `json:"currentAssigneeId"` } type AssignTicketRequest struct { TicketID int64 `json:"ticketId"` ToUserID int64 `json:"toUserId"` - ToTeamID int64 `json:"toTeamId"` Reason string `json:"reason"` } type ChangeTicketStatusRequest struct { - TicketID int64 `json:"ticketId"` - Status string `json:"status"` - PendingReason string `json:"pendingReason"` - CloseReason string `json:"closeReason"` - ResolutionCode string `json:"resolutionCode"` - ResolutionSummary string `json:"resolutionSummary"` - Reason string `json:"reason"` -} - -type ReplyTicketRequest struct { - TicketID int64 `json:"ticketId"` - ContentType string `json:"contentType"` - Content string `json:"content"` - Payload string `json:"payload"` -} - -type InternalNoteRequest struct { - TicketID int64 `json:"ticketId"` - ContentType string `json:"contentType"` - Content string `json:"content"` - Payload string `json:"payload"` -} - -type CloseTicketRequest struct { - TicketID int64 `json:"ticketId"` - CloseReason string `json:"closeReason"` -} - -type ReopenTicketRequest struct { TicketID int64 `json:"ticketId"` - Reason string `json:"reason"` + Status string `json:"status"` } -type WatchTicketRequest struct { - TicketID int64 `json:"ticketId"` +type CreateTicketProgressRequest struct { + TicketID int64 `json:"ticketId"` + Content string `json:"content"` } type BatchAssignTicketRequest struct { TicketIDs []int64 `json:"ticketIds"` ToUserID int64 `json:"toUserId"` - ToTeamID int64 `json:"toTeamId"` Reason string `json:"reason"` } -type BatchChangeTicketStatusRequest struct { - TicketIDs []int64 `json:"ticketIds"` - Status string `json:"status"` - PendingReason string `json:"pendingReason"` - CloseReason string `json:"closeReason"` - ResolutionCode string `json:"resolutionCode"` - ResolutionSummary string `json:"resolutionSummary"` - Reason string `json:"reason"` -} - -type BatchWatchTicketRequest struct { - TicketIDs []int64 `json:"ticketIds"` - Watched bool `json:"watched"` -} - -type AddTicketRelationRequest struct { - TicketID int64 `json:"ticketId"` - RelatedTicketID int64 `json:"relatedTicketId"` - RelatedTicketNo string `json:"relatedTicketNo"` - RelationType string `json:"relationType"` -} - -type DeleteTicketRelationRequest struct { - TicketID int64 `json:"ticketId"` - RelationID int64 `json:"relationId"` -} - -type AddTicketCollaboratorRequest struct { - TicketID int64 `json:"ticketId"` - UserID int64 `json:"userId"` -} - -type DeleteTicketCollaboratorRequest struct { - TicketID int64 `json:"ticketId"` - CollaboratorID int64 `json:"collaboratorId"` -} - -type LinkTicketCustomerRequest struct { - TicketID int64 `json:"ticketId"` - CustomerID int64 `json:"customerId"` -} - type SaveTicketViewRequest struct { ID int64 `json:"id"` Name string `json:"name"` diff --git a/internal/pkg/dto/response/ticket_config_response.go b/internal/pkg/dto/response/ticket_config_response.go deleted file mode 100644 index 9bb4b0e..0000000 --- a/internal/pkg/dto/response/ticket_config_response.go +++ /dev/null @@ -1,22 +0,0 @@ -package response - -import "cs-agent/internal/pkg/enums" - -type TicketResolutionCodeResponse struct { - ID int64 `json:"id"` - Name string `json:"name"` - Code string `json:"code"` - SortNo int `json:"sortNo"` - Status enums.Status `json:"status"` - Remark string `json:"remark"` -} - -type TicketPriorityConfigResponse struct { - ID int64 `json:"id"` - Name string `json:"name"` - SortNo int `json:"sortNo"` - FirstResponseMinutes int `json:"firstResponseMinutes"` - ResolutionMinutes int `json:"resolutionMinutes"` - Status enums.Status `json:"status"` - Remark string `json:"remark"` -} diff --git a/internal/pkg/dto/response/ticket_response.go b/internal/pkg/dto/response/ticket_response.go index 6d5d864..65067e4 100644 --- a/internal/pkg/dto/response/ticket_response.go +++ b/internal/pkg/dto/response/ticket_response.go @@ -2,145 +2,49 @@ package response import "cs-agent/internal/pkg/enums" -type TicketSLAResponse struct { - SLAType enums.TicketSLAType `json:"slaType"` - TargetMinutes int `json:"targetMinutes"` - Status enums.TicketSLAStatus `json:"status"` - StartedAt string `json:"startedAt,omitempty"` - PausedAt string `json:"pausedAt,omitempty"` - StoppedAt string `json:"stoppedAt,omitempty"` - BreachedAt string `json:"breachedAt,omitempty"` - ElapsedMin int `json:"elapsedMin"` -} - -type TicketWatcherResponse struct { - ID int64 `json:"id"` - UserID int64 `json:"userId"` - UserName string `json:"userName,omitempty"` -} - -type TicketCommentResponse struct { - ID int64 `json:"id"` - TicketID int64 `json:"ticketId"` - CommentType enums.TicketCommentType `json:"commentType"` - AuthorType enums.IMSenderType `json:"authorType"` - AuthorID int64 `json:"authorId"` - AuthorName string `json:"authorName,omitempty"` - ContentType string `json:"contentType"` - Content string `json:"content"` - Payload string `json:"payload,omitempty"` - CreatedAt string `json:"createdAt,omitempty"` -} - -type TicketEventLogResponse struct { - ID int64 `json:"id"` - TicketID int64 `json:"ticketId"` - EventType enums.TicketEventType `json:"eventType"` - OperatorType enums.IMSenderType `json:"operatorType"` - OperatorID int64 `json:"operatorId"` - OperatorName string `json:"operatorName,omitempty"` - OldValue string `json:"oldValue,omitempty"` - NewValue string `json:"newValue,omitempty"` - Content string `json:"content,omitempty"` - Payload string `json:"payload,omitempty"` - CreatedAt string `json:"createdAt,omitempty"` +type TicketProgressResponse struct { + ID int64 `json:"id"` + TicketID int64 `json:"ticketId"` + Content string `json:"content"` + AuthorID int64 `json:"authorId"` + AuthorName string `json:"authorName,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` } type TicketResponse struct { - ID int64 `json:"id"` - TicketNo string `json:"ticketNo"` - Title string `json:"title"` - Description string `json:"description"` - Source enums.TicketSource `json:"source"` - Channel string `json:"channel"` - CustomerID int64 `json:"customerId"` - ConversationID int64 `json:"conversationId"` - Tags []TagResponse `json:"tags,omitempty"` - Type string `json:"type"` - Priority int64 `json:"priority"` - PriorityName string `json:"priorityName,omitempty"` - Severity enums.TicketSeverity `json:"severity"` - Status enums.TicketStatus `json:"status"` - CurrentTeamID int64 `json:"currentTeamId"` - CurrentTeamName string `json:"currentTeamName,omitempty"` - CurrentAssigneeID int64 `json:"currentAssigneeId"` - CurrentAssigneeName string `json:"currentAssigneeName,omitempty"` - WatchedByMe bool `json:"watchedByMe"` - PendingReason string `json:"pendingReason,omitempty"` - CloseReason string `json:"closeReason,omitempty"` - ResolutionCode string `json:"resolutionCode,omitempty"` - ResolutionCodeName string `json:"resolutionCodeName,omitempty"` - ResolutionSummary string `json:"resolutionSummary,omitempty"` - FirstResponseAt string `json:"firstResponseAt,omitempty"` - ResolvedAt string `json:"resolvedAt,omitempty"` - ClosedAt string `json:"closedAt,omitempty"` - DueAt string `json:"dueAt,omitempty"` - NextReplyDeadlineAt string `json:"nextReplyDeadlineAt,omitempty"` - ResolveDeadlineAt string `json:"resolveDeadlineAt,omitempty"` - ReopenedCount int `json:"reopenedCount"` - CreatedAt string `json:"createdAt,omitempty"` - UpdatedAt string `json:"updatedAt,omitempty"` - Customer *CustomerResponse `json:"customer,omitempty"` - SLA []TicketSLAResponse `json:"sla,omitempty"` + ID int64 `json:"id"` + TicketNo string `json:"ticketNo"` + Title string `json:"title"` + Description string `json:"description"` + Source enums.TicketSource `json:"source"` + Channel string `json:"channel"` + CustomerID int64 `json:"customerId"` + ConversationID int64 `json:"conversationId"` + Tags []TagResponse `json:"tags,omitempty"` + Status enums.TicketStatus `json:"status"` + CurrentAssigneeID int64 `json:"currentAssigneeId"` + CurrentAssigneeName string `json:"currentAssigneeName,omitempty"` + CreatedBy int64 `json:"createdBy"` + CreatedByName string `json:"createdByName,omitempty"` + HandledAt string `json:"handledAt,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` + Customer *CustomerResponse `json:"customer,omitempty"` } type TicketDetailResponse struct { - Ticket TicketResponse `json:"ticket"` - Watchers []TicketWatcherResponse `json:"watchers,omitempty"` - Collaborators []TicketCollaboratorResponse `json:"collaborators,omitempty"` - Comments []TicketCommentResponse `json:"comments,omitempty"` - Events []TicketEventLogResponse `json:"events,omitempty"` - RelatedTickets []TicketRelationResponse `json:"relatedTickets,omitempty"` + Ticket TicketResponse `json:"ticket"` + Progresses []TicketProgressResponse `json:"progresses,omitempty"` } type TicketSummaryResponse struct { - All int64 `json:"all"` - Mine int64 `json:"mine"` - Watching int64 `json:"watching"` - Collaboration int64 `json:"collaboration"` - Participating int64 `json:"participating"` - Mentioned int64 `json:"mentioned"` - Unassigned int64 `json:"unassigned"` - PendingCustomer int64 `json:"pendingCustomer"` - PendingInternal int64 `json:"pendingInternal"` - Overdue int64 `json:"overdue"` -} - -type TicketRiskReasonResponse struct { - Code string `json:"code"` - Title string `json:"title"` - Description string `json:"description"` - Count int64 `json:"count"` -} - -type TicketRiskOverviewResponse struct { - Overdue int64 `json:"overdue"` - HighRisk int64 `json:"highRisk"` - Unassigned int64 `json:"unassigned"` - PendingInternal int64 `json:"pendingInternal"` - PendingCustomer int64 `json:"pendingCustomer"` - RiskWindowMins int `json:"riskWindowMins"` - Reasons []TicketRiskReasonResponse `json:"reasons,omitempty"` -} - -type TicketRelationResponse struct { - ID int64 `json:"id"` - TicketID int64 `json:"ticketId"` - RelatedTicketID int64 `json:"relatedTicketId"` - RelationType enums.TicketRelationType `json:"relationType"` - RelatedTicketNo string `json:"relatedTicketNo,omitempty"` - RelatedTicketTitle string `json:"relatedTicketTitle,omitempty"` - RelatedTicketStatus enums.TicketStatus `json:"relatedTicketStatus,omitempty"` - CurrentTeamName string `json:"currentTeamName,omitempty"` - CurrentAssigneeName string `json:"currentAssigneeName,omitempty"` - UpdatedAt string `json:"updatedAt,omitempty"` -} - -type TicketCollaboratorResponse struct { - ID int64 `json:"id"` - UserID int64 `json:"userId"` - UserName string `json:"userName,omitempty"` - TeamName string `json:"teamName,omitempty"` + All int64 `json:"all"` + Pending int64 `json:"pending"` + InProgress int64 `json:"inProgress"` + Done int64 `json:"done"` + Unassigned int64 `json:"unassigned"` + Mine int64 `json:"mine"` + Stale int64 `json:"stale"` } type TicketViewResponse struct { diff --git a/internal/pkg/enums/ticket.go b/internal/pkg/enums/ticket.go index 6fbf2b6..66e1092 100644 --- a/internal/pkg/enums/ticket.go +++ b/internal/pkg/enums/ticket.go @@ -3,33 +3,21 @@ package enums type TicketStatus string const ( - TicketStatusNew TicketStatus = "new" - TicketStatusOpen TicketStatus = "open" - TicketStatusPendingCustomer TicketStatus = "pending_customer" - TicketStatusPendingInternal TicketStatus = "pending_internal" - TicketStatusResolved TicketStatus = "resolved" - TicketStatusClosed TicketStatus = "closed" - TicketStatusCancelled TicketStatus = "cancelled" + TicketStatusPending TicketStatus = "pending" + TicketStatusInProgress TicketStatus = "in_progress" + TicketStatusDone TicketStatus = "done" ) var TicketStatusValues = []TicketStatus{ - TicketStatusNew, - TicketStatusOpen, - TicketStatusPendingCustomer, - TicketStatusPendingInternal, - TicketStatusResolved, - TicketStatusClosed, - TicketStatusCancelled, + TicketStatusPending, + TicketStatusInProgress, + TicketStatusDone, } var ticketStatusLabelMap = map[TicketStatus]string{ - TicketStatusNew: "新建", - TicketStatusOpen: "处理中", - TicketStatusPendingCustomer: "待客户反馈", - TicketStatusPendingInternal: "待内部处理", - TicketStatusResolved: "已解决", - TicketStatusClosed: "已关闭", - TicketStatusCancelled: "已取消", + TicketStatusPending: "待处理", + TicketStatusInProgress: "处理中", + TicketStatusDone: "已处理", } func GetTicketStatusLabel(status TicketStatus) string { @@ -45,55 +33,16 @@ func IsValidTicketStatus(status string) bool { return false } -type TicketSeverity int - -const ( - TicketSeverityMinor TicketSeverity = 1 - TicketSeverityMajor TicketSeverity = 2 - TicketSeverityCritical TicketSeverity = 3 -) - -var TicketSeverityValues = []TicketSeverity{ - TicketSeverityMinor, - TicketSeverityMajor, - TicketSeverityCritical, -} - -var ticketSeverityLabelMap = map[TicketSeverity]string{ - TicketSeverityMinor: "轻微", - TicketSeverityMajor: "严重", - TicketSeverityCritical: "致命", -} - -func GetTicketSeverityLabel(severity TicketSeverity) string { - return ticketSeverityLabelMap[severity] -} - -func IsValidTicketSeverity(severity int) bool { - for _, item := range TicketSeverityValues { - if int(item) == severity { - return true - } - } - return false -} - type TicketSource string const ( TicketSourceManual TicketSource = "manual" TicketSourceConversation TicketSource = "conversation" - TicketSourcePortal TicketSource = "portal" - TicketSourceAPI TicketSource = "api" - TicketSourceRule TicketSource = "rule" ) var TicketSourceValues = []TicketSource{ TicketSourceManual, TicketSourceConversation, - TicketSourcePortal, - TicketSourceAPI, - TicketSourceRule, } func IsValidTicketSource(source string) bool { @@ -104,53 +53,3 @@ func IsValidTicketSource(source string) bool { } return false } - -type TicketCommentType string - -const ( - TicketCommentTypePublicReply TicketCommentType = "public_reply" - TicketCommentTypeInternalNote TicketCommentType = "internal_note" - TicketCommentTypeSystemLog TicketCommentType = "system_log" -) - -type TicketEventType string - -const ( - TicketEventTypeCreated TicketEventType = "created" - TicketEventTypeUpdated TicketEventType = "updated" - TicketEventTypeAssigned TicketEventType = "assigned" - TicketEventTypeTransferred TicketEventType = "transferred" - TicketEventTypeStatusChanged TicketEventType = "status_changed" - TicketEventTypeReplied TicketEventType = "replied" - TicketEventTypeInternalNoted TicketEventType = "internal_noted" - TicketEventTypeClosed TicketEventType = "closed" - TicketEventTypeReopened TicketEventType = "reopened" - TicketEventTypeSLABreached TicketEventType = "sla_breached" - TicketEventTypeLinkedConversation TicketEventType = "linked_conversation" - TicketEventTypeMentioned TicketEventType = "mentioned" -) - -type TicketSLAType string - -const ( - TicketSLATypeFirstResponse TicketSLAType = "first_response" - TicketSLATypeResolution TicketSLAType = "resolution" -) - -type TicketSLAStatus string - -const ( - TicketSLAStatusRunning TicketSLAStatus = "running" - TicketSLAStatusPaused TicketSLAStatus = "paused" - TicketSLAStatusCompleted TicketSLAStatus = "completed" - TicketSLAStatusBreached TicketSLAStatus = "breached" -) - -type TicketRelationType string - -const ( - TicketRelationTypeDuplicate TicketRelationType = "duplicate" - TicketRelationTypeRelated TicketRelationType = "related" - TicketRelationTypeParent TicketRelationType = "parent" - TicketRelationTypeChild TicketRelationType = "child" -) diff --git a/internal/repositories/ticket_collaborator_repository.go b/internal/repositories/ticket_collaborator_repository.go deleted file mode 100644 index cea1cd7..0000000 --- a/internal/repositories/ticket_collaborator_repository.go +++ /dev/null @@ -1,83 +0,0 @@ -package repositories - -import ( - "cs-agent/internal/models" - - "github.com/mlogclub/simple/sqls" - "github.com/mlogclub/simple/web/params" - "gorm.io/gorm" -) - -var TicketCollaboratorRepository = newTicketCollaboratorRepository() - -func newTicketCollaboratorRepository() *ticketCollaboratorRepository { - return &ticketCollaboratorRepository{} -} - -type ticketCollaboratorRepository struct { -} - -func (r *ticketCollaboratorRepository) TakeByTicketIDAndUserID(db *gorm.DB, ticketID, userID int64) *models.TicketCollaborator { - ret := &models.TicketCollaborator{} - if err := db.Take(ret, "ticket_id = ? AND user_id = ?", ticketID, userID).Error; err != nil { - return nil - } - return ret -} - -func (r *ticketCollaboratorRepository) Get(db *gorm.DB, id int64) *models.TicketCollaborator { - ret := &models.TicketCollaborator{} - if err := db.First(ret, "id = ?", id).Error; err != nil { - return nil - } - return ret -} - -func (r *ticketCollaboratorRepository) Take(db *gorm.DB, where ...interface{}) *models.TicketCollaborator { - ret := &models.TicketCollaborator{} - if err := db.Take(ret, where...).Error; err != nil { - return nil - } - return ret -} - -func (r *ticketCollaboratorRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketCollaborator) { - cnd.Find(db, &list) - return -} - -func (r *ticketCollaboratorRepository) FindOne(db *gorm.DB, cnd *sqls.Cnd) *models.TicketCollaborator { - ret := &models.TicketCollaborator{} - if err := cnd.FindOne(db, &ret); err != nil { - return nil - } - return ret -} - -func (r *ticketCollaboratorRepository) FindPageByParams(db *gorm.DB, params *params.QueryParams) (list []models.TicketCollaborator, paging *sqls.Paging) { - return r.FindPageByCnd(db, ¶ms.Cnd) -} - -func (r *ticketCollaboratorRepository) FindPageByCnd(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketCollaborator, paging *sqls.Paging) { - cnd.Find(db, &list) - count := cnd.Count(db, &models.TicketCollaborator{}) - - paging = &sqls.Paging{ - Page: cnd.Paging.Page, - Limit: cnd.Paging.Limit, - Total: count, - } - return -} - -func (r *ticketCollaboratorRepository) Count(db *gorm.DB, cnd *sqls.Cnd) int64 { - return cnd.Count(db, &models.TicketCollaborator{}) -} - -func (r *ticketCollaboratorRepository) Create(db *gorm.DB, t *models.TicketCollaborator) error { - return db.Create(t).Error -} - -func (r *ticketCollaboratorRepository) Delete(db *gorm.DB, id int64) { - db.Delete(&models.TicketCollaborator{}, "id = ?", id) -} diff --git a/internal/repositories/ticket_comment_repository.go b/internal/repositories/ticket_comment_repository.go deleted file mode 100644 index 8cfaab3..0000000 --- a/internal/repositories/ticket_comment_repository.go +++ /dev/null @@ -1,102 +0,0 @@ -package repositories - -import ( - "cs-agent/internal/models" - - "github.com/mlogclub/simple/sqls" - "github.com/mlogclub/simple/web/params" - "gorm.io/gorm" -) - -var TicketCommentRepository = newTicketCommentRepository() - -func newTicketCommentRepository() *ticketCommentRepository { - return &ticketCommentRepository{} -} - -type ticketCommentRepository struct { -} - -func (r *ticketCommentRepository) Get(db *gorm.DB, id int64) *models.TicketComment { - ret := &models.TicketComment{} - if err := db.First(ret, "id = ?", id).Error; err != nil { - return nil - } - return ret -} - -func (r *ticketCommentRepository) Take(db *gorm.DB, where ...interface{}) *models.TicketComment { - ret := &models.TicketComment{} - if err := db.Take(ret, where...).Error; err != nil { - return nil - } - return ret -} - -func (r *ticketCommentRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketComment) { - cnd.Find(db, &list) - return -} - -func (r *ticketCommentRepository) FindOne(db *gorm.DB, cnd *sqls.Cnd) *models.TicketComment { - ret := &models.TicketComment{} - if err := cnd.FindOne(db, &ret); err != nil { - return nil - } - return ret -} - -func (r *ticketCommentRepository) FindPageByParams(db *gorm.DB, params *params.QueryParams) (list []models.TicketComment, paging *sqls.Paging) { - return r.FindPageByCnd(db, ¶ms.Cnd) -} - -func (r *ticketCommentRepository) FindPageByCnd(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketComment, paging *sqls.Paging) { - cnd.Find(db, &list) - count := cnd.Count(db, &models.TicketComment{}) - - paging = &sqls.Paging{ - Page: cnd.Paging.Page, - Limit: cnd.Paging.Limit, - Total: count, - } - return -} - -func (r *ticketCommentRepository) FindBySql(db *gorm.DB, sqlStr string, paramArr... interface{}) (list []models.TicketComment) { - db.Raw(sqlStr, paramArr...).Scan(&list) - return -} - -func (r *ticketCommentRepository) CountBySql(db *gorm.DB, sqlStr string, paramArr... interface{}) (count int64) { - db.Raw(sqlStr, paramArr...).Count(&count) - return -} - -func (r *ticketCommentRepository) Count(db *gorm.DB, cnd *sqls.Cnd) int64 { - return cnd.Count(db, &models.TicketComment{}) -} - -func (r *ticketCommentRepository) Create(db *gorm.DB, t *models.TicketComment) (err error) { - err = db.Create(t).Error - return -} - -func (r *ticketCommentRepository) Update(db *gorm.DB, t *models.TicketComment) (err error) { - err = db.Save(t).Error - return -} - -func (r *ticketCommentRepository) Updates(db *gorm.DB, id int64, columns map[string]interface{}) (err error) { - err = db.Model(&models.TicketComment{}).Where("id = ?", id).Updates(columns).Error - return -} - -func (r *ticketCommentRepository) UpdateColumn(db *gorm.DB, id int64, name string, value interface{}) (err error) { - err = db.Model(&models.TicketComment{}).Where("id = ?", id).UpdateColumn(name, value).Error - return -} - -func (r *ticketCommentRepository) Delete(db *gorm.DB, id int64) { - db.Delete(&models.TicketComment{}, "id = ?", id) -} - diff --git a/internal/repositories/ticket_event_log_repository.go b/internal/repositories/ticket_event_log_repository.go deleted file mode 100644 index e8b30ca..0000000 --- a/internal/repositories/ticket_event_log_repository.go +++ /dev/null @@ -1,102 +0,0 @@ -package repositories - -import ( - "cs-agent/internal/models" - - "github.com/mlogclub/simple/sqls" - "github.com/mlogclub/simple/web/params" - "gorm.io/gorm" -) - -var TicketEventLogRepository = newTicketEventLogRepository() - -func newTicketEventLogRepository() *ticketEventLogRepository { - return &ticketEventLogRepository{} -} - -type ticketEventLogRepository struct { -} - -func (r *ticketEventLogRepository) Get(db *gorm.DB, id int64) *models.TicketEventLog { - ret := &models.TicketEventLog{} - if err := db.First(ret, "id = ?", id).Error; err != nil { - return nil - } - return ret -} - -func (r *ticketEventLogRepository) Take(db *gorm.DB, where ...interface{}) *models.TicketEventLog { - ret := &models.TicketEventLog{} - if err := db.Take(ret, where...).Error; err != nil { - return nil - } - return ret -} - -func (r *ticketEventLogRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketEventLog) { - cnd.Find(db, &list) - return -} - -func (r *ticketEventLogRepository) FindOne(db *gorm.DB, cnd *sqls.Cnd) *models.TicketEventLog { - ret := &models.TicketEventLog{} - if err := cnd.FindOne(db, &ret); err != nil { - return nil - } - return ret -} - -func (r *ticketEventLogRepository) FindPageByParams(db *gorm.DB, params *params.QueryParams) (list []models.TicketEventLog, paging *sqls.Paging) { - return r.FindPageByCnd(db, ¶ms.Cnd) -} - -func (r *ticketEventLogRepository) FindPageByCnd(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketEventLog, paging *sqls.Paging) { - cnd.Find(db, &list) - count := cnd.Count(db, &models.TicketEventLog{}) - - paging = &sqls.Paging{ - Page: cnd.Paging.Page, - Limit: cnd.Paging.Limit, - Total: count, - } - return -} - -func (r *ticketEventLogRepository) FindBySql(db *gorm.DB, sqlStr string, paramArr... interface{}) (list []models.TicketEventLog) { - db.Raw(sqlStr, paramArr...).Scan(&list) - return -} - -func (r *ticketEventLogRepository) CountBySql(db *gorm.DB, sqlStr string, paramArr... interface{}) (count int64) { - db.Raw(sqlStr, paramArr...).Count(&count) - return -} - -func (r *ticketEventLogRepository) Count(db *gorm.DB, cnd *sqls.Cnd) int64 { - return cnd.Count(db, &models.TicketEventLog{}) -} - -func (r *ticketEventLogRepository) Create(db *gorm.DB, t *models.TicketEventLog) (err error) { - err = db.Create(t).Error - return -} - -func (r *ticketEventLogRepository) Update(db *gorm.DB, t *models.TicketEventLog) (err error) { - err = db.Save(t).Error - return -} - -func (r *ticketEventLogRepository) Updates(db *gorm.DB, id int64, columns map[string]interface{}) (err error) { - err = db.Model(&models.TicketEventLog{}).Where("id = ?", id).Updates(columns).Error - return -} - -func (r *ticketEventLogRepository) UpdateColumn(db *gorm.DB, id int64, name string, value interface{}) (err error) { - err = db.Model(&models.TicketEventLog{}).Where("id = ?", id).UpdateColumn(name, value).Error - return -} - -func (r *ticketEventLogRepository) Delete(db *gorm.DB, id int64) { - db.Delete(&models.TicketEventLog{}, "id = ?", id) -} - diff --git a/internal/repositories/ticket_mention_repository.go b/internal/repositories/ticket_mention_repository.go deleted file mode 100644 index bc814f9..0000000 --- a/internal/repositories/ticket_mention_repository.go +++ /dev/null @@ -1,74 +0,0 @@ -package repositories - -import ( - "cs-agent/internal/models" - - "github.com/mlogclub/simple/sqls" - "github.com/mlogclub/simple/web/params" - "gorm.io/gorm" -) - -var TicketMentionRepository = newTicketMentionRepository() - -func newTicketMentionRepository() *ticketMentionRepository { - return &ticketMentionRepository{} -} - -type ticketMentionRepository struct { -} - -func (r *ticketMentionRepository) TakeByCommentAndUserID(db *gorm.DB, ticketID, commentID, userID int64) *models.TicketMention { - ret := &models.TicketMention{} - if err := db.Take(ret, "ticket_id = ? AND comment_id = ? AND mentioned_user_id = ?", ticketID, commentID, userID).Error; err != nil { - return nil - } - return ret -} - -func (r *ticketMentionRepository) Get(db *gorm.DB, id int64) *models.TicketMention { - ret := &models.TicketMention{} - if err := db.First(ret, "id = ?", id).Error; err != nil { - return nil - } - return ret -} - -func (r *ticketMentionRepository) Take(db *gorm.DB, where ...interface{}) *models.TicketMention { - ret := &models.TicketMention{} - if err := db.Take(ret, where...).Error; err != nil { - return nil - } - return ret -} - -func (r *ticketMentionRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketMention) { - cnd.Find(db, &list) - return -} - -func (r *ticketMentionRepository) FindOne(db *gorm.DB, cnd *sqls.Cnd) *models.TicketMention { - ret := &models.TicketMention{} - if err := cnd.FindOne(db, &ret); err != nil { - return nil - } - return ret -} - -func (r *ticketMentionRepository) FindPageByParams(db *gorm.DB, params *params.QueryParams) (list []models.TicketMention, paging *sqls.Paging) { - return r.FindPageByCnd(db, ¶ms.Cnd) -} - -func (r *ticketMentionRepository) FindPageByCnd(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketMention, paging *sqls.Paging) { - cnd.Find(db, &list) - count := cnd.Count(db, &models.TicketMention{}) - paging = &sqls.Paging{Page: cnd.Paging.Page, Limit: cnd.Paging.Limit, Total: count} - return -} - -func (r *ticketMentionRepository) Count(db *gorm.DB, cnd *sqls.Cnd) int64 { - return cnd.Count(db, &models.TicketMention{}) -} - -func (r *ticketMentionRepository) Create(db *gorm.DB, t *models.TicketMention) error { - return db.Create(t).Error -} diff --git a/internal/repositories/ticket_no_sequence_repository.go b/internal/repositories/ticket_no_sequence_repository.go index 83128bb..b480507 100644 --- a/internal/repositories/ticket_no_sequence_repository.go +++ b/internal/repositories/ticket_no_sequence_repository.go @@ -4,6 +4,8 @@ import ( "cs-agent/internal/models" "time" + "github.com/mlogclub/simple/sqls" + "github.com/mlogclub/simple/web/params" "gorm.io/gorm" ) @@ -15,6 +17,55 @@ func newTicketNoSequenceRepository() *ticketNoSequenceRepository { type ticketNoSequenceRepository struct{} +func (r *ticketNoSequenceRepository) Get(db *gorm.DB, id int64) *models.TicketNoSequence { + ret := &models.TicketNoSequence{} + if err := db.First(ret, "id = ?", id).Error; err != nil { + return nil + } + return ret +} + +func (r *ticketNoSequenceRepository) Take(db *gorm.DB, where ...any) *models.TicketNoSequence { + ret := &models.TicketNoSequence{} + if err := db.Take(ret, where...).Error; err != nil { + return nil + } + return ret +} + +func (r *ticketNoSequenceRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketNoSequence) { + cnd.Find(db, &list) + return +} + +func (r *ticketNoSequenceRepository) FindOne(db *gorm.DB, cnd *sqls.Cnd) *models.TicketNoSequence { + ret := &models.TicketNoSequence{} + if err := cnd.FindOne(db, &ret); err != nil { + return nil + } + return ret +} + +func (r *ticketNoSequenceRepository) FindPageByParams(db *gorm.DB, params *params.QueryParams) (list []models.TicketNoSequence, paging *sqls.Paging) { + return r.FindPageByCnd(db, ¶ms.Cnd) +} + +func (r *ticketNoSequenceRepository) FindPageByCnd(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketNoSequence, paging *sqls.Paging) { + cnd.Find(db, &list) + count := cnd.Count(db, &models.TicketNoSequence{}) + + paging = &sqls.Paging{ + Page: cnd.Paging.Page, + Limit: cnd.Paging.Limit, + Total: count, + } + return +} + +func (r *ticketNoSequenceRepository) Count(db *gorm.DB, cnd *sqls.Cnd) int64 { + return cnd.Count(db, &models.TicketNoSequence{}) +} + func (r *ticketNoSequenceRepository) GetByDateKey(db *gorm.DB, dateKey string) *models.TicketNoSequence { ret := &models.TicketNoSequence{} if err := db.Take(ret, "date_key = ?", dateKey).Error; err != nil { @@ -27,6 +78,22 @@ func (r *ticketNoSequenceRepository) Create(db *gorm.DB, t *models.TicketNoSeque return db.Create(t).Error } +func (r *ticketNoSequenceRepository) Update(db *gorm.DB, t *models.TicketNoSequence) error { + return db.Save(t).Error +} + +func (r *ticketNoSequenceRepository) Updates(db *gorm.DB, id int64, columns map[string]any) error { + return db.Model(&models.TicketNoSequence{}).Where("id = ?", id).Updates(columns).Error +} + +func (r *ticketNoSequenceRepository) UpdateColumn(db *gorm.DB, id int64, name string, value any) error { + return db.Model(&models.TicketNoSequence{}).Where("id = ?", id).UpdateColumn(name, value).Error +} + +func (r *ticketNoSequenceRepository) Delete(db *gorm.DB, id int64) { + db.Delete(&models.TicketNoSequence{}, "id = ?", id) +} + func (r *ticketNoSequenceRepository) UpdateNextSeq(db *gorm.DB, id int64, currentSeq, nextSeq int64, updatedAt time.Time) (bool, error) { result := db.Model(&models.TicketNoSequence{}). Where("id = ? AND next_seq = ?", id, currentSeq). diff --git a/internal/repositories/ticket_priority_config_repository.go b/internal/repositories/ticket_priority_config_repository.go deleted file mode 100644 index 218597a..0000000 --- a/internal/repositories/ticket_priority_config_repository.go +++ /dev/null @@ -1,81 +0,0 @@ -package repositories - -import ( - "cs-agent/internal/models" - - "github.com/mlogclub/simple/sqls" - "github.com/mlogclub/simple/web/params" - "gorm.io/gorm" -) - -var TicketPriorityConfigRepository = newTicketPriorityConfigRepository() - -func newTicketPriorityConfigRepository() *ticketPriorityConfigRepository { - return &ticketPriorityConfigRepository{} -} - -type ticketPriorityConfigRepository struct{} - -func (r *ticketPriorityConfigRepository) Get(db *gorm.DB, id int64) *models.TicketPriorityConfig { - ret := &models.TicketPriorityConfig{} - if err := db.First(ret, id).Error; err != nil { - return nil - } - return ret -} - -func (r *ticketPriorityConfigRepository) Take(db *gorm.DB, where ...interface{}) *models.TicketPriorityConfig { - ret := &models.TicketPriorityConfig{} - if err := db.Take(ret, where...).Error; err != nil { - return nil - } - return ret -} - -func (r *ticketPriorityConfigRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketPriorityConfig) { - cnd.Find(db, &list) - return -} - -func (r *ticketPriorityConfigRepository) FindOne(db *gorm.DB, cnd *sqls.Cnd) *models.TicketPriorityConfig { - ret := &models.TicketPriorityConfig{} - if err := cnd.FindOne(db, ret); err != nil { - return nil - } - return ret -} - -func (r *ticketPriorityConfigRepository) FindPageByParams(db *gorm.DB, queryParams *params.QueryParams) (list []models.TicketPriorityConfig, paging *sqls.Paging) { - return r.FindPageByCnd(db, &queryParams.Cnd) -} - -func (r *ticketPriorityConfigRepository) FindPageByCnd(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketPriorityConfig, paging *sqls.Paging) { - cnd.Find(db, &list) - count := cnd.Count(db, &models.TicketPriorityConfig{}) - paging = &sqls.Paging{Page: cnd.Paging.Page, Limit: cnd.Paging.Limit, Total: count} - return -} - -func (r *ticketPriorityConfigRepository) Count(db *gorm.DB, cnd *sqls.Cnd) int64 { - return cnd.Count(db, &models.TicketPriorityConfig{}) -} - -func (r *ticketPriorityConfigRepository) Create(db *gorm.DB, t *models.TicketPriorityConfig) error { - return db.Create(t).Error -} - -func (r *ticketPriorityConfigRepository) Update(db *gorm.DB, t *models.TicketPriorityConfig) error { - return db.Save(t).Error -} - -func (r *ticketPriorityConfigRepository) Updates(db *gorm.DB, id int64, columns map[string]any) error { - return db.Model(&models.TicketPriorityConfig{}).Where("id = ?", id).Updates(columns).Error -} - -func (r *ticketPriorityConfigRepository) UpdateColumn(db *gorm.DB, id int64, name string, value any) error { - return db.Model(&models.TicketPriorityConfig{}).Where("id = ?", id).UpdateColumn(name, value).Error -} - -func (r *ticketPriorityConfigRepository) Delete(db *gorm.DB, id int64) error { - return db.Delete(&models.TicketPriorityConfig{}, "id = ?", id).Error -} diff --git a/internal/repositories/ticket_progress_repository.go b/internal/repositories/ticket_progress_repository.go new file mode 100644 index 0000000..2fc6345 --- /dev/null +++ b/internal/repositories/ticket_progress_repository.go @@ -0,0 +1,101 @@ +package repositories + +import ( + "cs-agent/internal/models" + + "github.com/mlogclub/simple/sqls" + "github.com/mlogclub/simple/web/params" + "gorm.io/gorm" +) + +var TicketProgressRepository = newTicketProgressRepository() + +func newTicketProgressRepository() *ticketProgressRepository { + return &ticketProgressRepository{} +} + +type ticketProgressRepository struct { +} + +func (r *ticketProgressRepository) Get(db *gorm.DB, id int64) *models.TicketProgress { + ret := &models.TicketProgress{} + if err := db.First(ret, "id = ?", id).Error; err != nil { + return nil + } + return ret +} + +func (r *ticketProgressRepository) Take(db *gorm.DB, where ...any) *models.TicketProgress { + ret := &models.TicketProgress{} + if err := db.Take(ret, where...).Error; err != nil { + return nil + } + return ret +} + +func (r *ticketProgressRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketProgress) { + cnd.Find(db, &list) + return +} + +func (r *ticketProgressRepository) FindOne(db *gorm.DB, cnd *sqls.Cnd) *models.TicketProgress { + ret := &models.TicketProgress{} + if err := cnd.FindOne(db, &ret); err != nil { + return nil + } + return ret +} + +func (r *ticketProgressRepository) FindPageByParams(db *gorm.DB, params *params.QueryParams) (list []models.TicketProgress, paging *sqls.Paging) { + return r.FindPageByCnd(db, ¶ms.Cnd) +} + +func (r *ticketProgressRepository) FindPageByCnd(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketProgress, paging *sqls.Paging) { + cnd.Find(db, &list) + count := cnd.Count(db, &models.TicketProgress{}) + + paging = &sqls.Paging{ + Page: cnd.Paging.Page, + Limit: cnd.Paging.Limit, + Total: count, + } + return +} + +func (r *ticketProgressRepository) FindBySql(db *gorm.DB, sqlStr string, paramArr ...any) (list []models.TicketProgress) { + db.Raw(sqlStr, paramArr...).Scan(&list) + return +} + +func (r *ticketProgressRepository) CountBySql(db *gorm.DB, sqlStr string, paramArr ...any) (count int64) { + db.Raw(sqlStr, paramArr...).Count(&count) + return +} + +func (r *ticketProgressRepository) Count(db *gorm.DB, cnd *sqls.Cnd) int64 { + return cnd.Count(db, &models.TicketProgress{}) +} + +func (r *ticketProgressRepository) Create(db *gorm.DB, t *models.TicketProgress) (err error) { + err = db.Create(t).Error + return +} + +func (r *ticketProgressRepository) Update(db *gorm.DB, t *models.TicketProgress) (err error) { + err = db.Save(t).Error + return +} + +func (r *ticketProgressRepository) Updates(db *gorm.DB, id int64, columns map[string]any) (err error) { + err = db.Model(&models.TicketProgress{}).Where("id = ?", id).Updates(columns).Error + return +} + +func (r *ticketProgressRepository) UpdateColumn(db *gorm.DB, id int64, name string, value any) (err error) { + err = db.Model(&models.TicketProgress{}).Where("id = ?", id).UpdateColumn(name, value).Error + return +} + +func (r *ticketProgressRepository) Delete(db *gorm.DB, id int64) { + db.Delete(&models.TicketProgress{}, "id = ?", id) +} diff --git a/internal/repositories/ticket_relation_repository.go b/internal/repositories/ticket_relation_repository.go deleted file mode 100644 index 48396dc..0000000 --- a/internal/repositories/ticket_relation_repository.go +++ /dev/null @@ -1,106 +0,0 @@ -package repositories - -import ( - "cs-agent/internal/models" - - "github.com/mlogclub/simple/sqls" - "github.com/mlogclub/simple/web/params" - "gorm.io/gorm" -) - -var TicketRelationRepository = newTicketRelationRepository() - -func newTicketRelationRepository() *ticketRelationRepository { - return &ticketRelationRepository{} -} - -type ticketRelationRepository struct { -} - -func (r *ticketRelationRepository) Get(db *gorm.DB, id int64) *models.TicketRelation { - ret := &models.TicketRelation{} - if err := db.First(ret, "id = ?", id).Error; err != nil { - return nil - } - return ret -} - -func (r *ticketRelationRepository) Take(db *gorm.DB, where ...interface{}) *models.TicketRelation { - ret := &models.TicketRelation{} - if err := db.Take(ret, where...).Error; err != nil { - return nil - } - return ret -} - -func (r *ticketRelationRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketRelation) { - cnd.Find(db, &list) - return -} - -func (r *ticketRelationRepository) FindOne(db *gorm.DB, cnd *sqls.Cnd) *models.TicketRelation { - ret := &models.TicketRelation{} - if err := cnd.FindOne(db, &ret); err != nil { - return nil - } - return ret -} - -func (r *ticketRelationRepository) FindPageByParams(db *gorm.DB, params *params.QueryParams) (list []models.TicketRelation, paging *sqls.Paging) { - return r.FindPageByCnd(db, ¶ms.Cnd) -} - -func (r *ticketRelationRepository) FindPageByCnd(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketRelation, paging *sqls.Paging) { - cnd.Find(db, &list) - count := cnd.Count(db, &models.TicketRelation{}) - - paging = &sqls.Paging{ - Page: cnd.Paging.Page, - Limit: cnd.Paging.Limit, - Total: count, - } - return -} - -func (r *ticketRelationRepository) FindBySql(db *gorm.DB, sqlStr string, paramArr ...interface{}) (list []models.TicketRelation) { - db.Raw(sqlStr, paramArr...).Scan(&list) - return -} - -func (r *ticketRelationRepository) CountBySql(db *gorm.DB, sqlStr string, paramArr ...interface{}) (count int64) { - db.Raw(sqlStr, paramArr...).Count(&count) - return -} - -func (r *ticketRelationRepository) Count(db *gorm.DB, cnd *sqls.Cnd) int64 { - return cnd.Count(db, &models.TicketRelation{}) -} - -func (r *ticketRelationRepository) Create(db *gorm.DB, t *models.TicketRelation) (err error) { - err = db.Create(t).Error - return -} - -func (r *ticketRelationRepository) Update(db *gorm.DB, t *models.TicketRelation) (err error) { - err = db.Save(t).Error - return -} - -func (r *ticketRelationRepository) Updates(db *gorm.DB, id int64, columns map[string]interface{}) (err error) { - err = db.Model(&models.TicketRelation{}).Where("id = ?", id).Updates(columns).Error - return -} - -func (r *ticketRelationRepository) UpdateColumn(db *gorm.DB, id int64, name string, value interface{}) (err error) { - err = db.Model(&models.TicketRelation{}).Where("id = ?", id).UpdateColumn(name, value).Error - return -} - -func (r *ticketRelationRepository) Delete(db *gorm.DB, id int64) { - db.Delete(&models.TicketRelation{}, "id = ?", id) -} - -func (r *ticketRelationRepository) DeleteByTicketRelation(db *gorm.DB, ticketID, relatedTicketID int64, relationType string) error { - return db.Where("ticket_id = ? AND related_ticket_id = ? AND relation_type = ?", ticketID, relatedTicketID, relationType). - Delete(&models.TicketRelation{}).Error -} diff --git a/internal/repositories/ticket_resolution_code_repository.go b/internal/repositories/ticket_resolution_code_repository.go deleted file mode 100644 index 4665160..0000000 --- a/internal/repositories/ticket_resolution_code_repository.go +++ /dev/null @@ -1,81 +0,0 @@ -package repositories - -import ( - "cs-agent/internal/models" - - "github.com/mlogclub/simple/sqls" - "github.com/mlogclub/simple/web/params" - "gorm.io/gorm" -) - -var TicketResolutionCodeRepository = newTicketResolutionCodeRepository() - -func newTicketResolutionCodeRepository() *ticketResolutionCodeRepository { - return &ticketResolutionCodeRepository{} -} - -type ticketResolutionCodeRepository struct{} - -func (r *ticketResolutionCodeRepository) Get(db *gorm.DB, id int64) *models.TicketResolutionCode { - ret := &models.TicketResolutionCode{} - if err := db.First(ret, "id = ?", id).Error; err != nil { - return nil - } - return ret -} - -func (r *ticketResolutionCodeRepository) Take(db *gorm.DB, where ...interface{}) *models.TicketResolutionCode { - ret := &models.TicketResolutionCode{} - if err := db.Take(ret, where...).Error; err != nil { - return nil - } - return ret -} - -func (r *ticketResolutionCodeRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketResolutionCode) { - cnd.Find(db, &list) - return -} - -func (r *ticketResolutionCodeRepository) FindOne(db *gorm.DB, cnd *sqls.Cnd) *models.TicketResolutionCode { - ret := &models.TicketResolutionCode{} - if err := cnd.FindOne(db, &ret); err != nil { - return nil - } - return ret -} - -func (r *ticketResolutionCodeRepository) FindPageByParams(db *gorm.DB, params *params.QueryParams) (list []models.TicketResolutionCode, paging *sqls.Paging) { - return r.FindPageByCnd(db, ¶ms.Cnd) -} - -func (r *ticketResolutionCodeRepository) FindPageByCnd(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketResolutionCode, paging *sqls.Paging) { - cnd.Find(db, &list) - count := cnd.Count(db, &models.TicketResolutionCode{}) - paging = &sqls.Paging{Page: cnd.Paging.Page, Limit: cnd.Paging.Limit, Total: count} - return -} - -func (r *ticketResolutionCodeRepository) Count(db *gorm.DB, cnd *sqls.Cnd) int64 { - return cnd.Count(db, &models.TicketResolutionCode{}) -} - -func (r *ticketResolutionCodeRepository) Create(db *gorm.DB, t *models.TicketResolutionCode) error { - return db.Create(t).Error -} - -func (r *ticketResolutionCodeRepository) Update(db *gorm.DB, t *models.TicketResolutionCode) error { - return db.Save(t).Error -} - -func (r *ticketResolutionCodeRepository) Updates(db *gorm.DB, id int64, columns map[string]interface{}) error { - return db.Model(&models.TicketResolutionCode{}).Where("id = ?", id).Updates(columns).Error -} - -func (r *ticketResolutionCodeRepository) UpdateColumn(db *gorm.DB, id int64, name string, value interface{}) error { - return db.Model(&models.TicketResolutionCode{}).Where("id = ?", id).UpdateColumn(name, value).Error -} - -func (r *ticketResolutionCodeRepository) Delete(db *gorm.DB, id int64) { - db.Delete(&models.TicketResolutionCode{}, "id = ?", id) -} diff --git a/internal/repositories/ticket_sla_record_repository.go b/internal/repositories/ticket_sla_record_repository.go deleted file mode 100644 index 0e564ae..0000000 --- a/internal/repositories/ticket_sla_record_repository.go +++ /dev/null @@ -1,109 +0,0 @@ -package repositories - -import ( - "cs-agent/internal/models" - - "github.com/mlogclub/simple/sqls" - "github.com/mlogclub/simple/web/params" - "gorm.io/gorm" -) - -var TicketSLARecordRepository = newTicketSLARecordRepository() - -func newTicketSLARecordRepository() *ticketSLARecordRepository { - return &ticketSLARecordRepository{} -} - -type ticketSLARecordRepository struct { -} - -func (r *ticketSLARecordRepository) TakeByTicketIDAndType(db *gorm.DB, ticketID int64, slaType string) *models.TicketSLARecord { - ret := &models.TicketSLARecord{} - if err := db.Take(ret, "ticket_id = ? AND sla_type = ?", ticketID, slaType).Error; err != nil { - return nil - } - return ret -} - -func (r *ticketSLARecordRepository) Get(db *gorm.DB, id int64) *models.TicketSLARecord { - ret := &models.TicketSLARecord{} - if err := db.First(ret, "id = ?", id).Error; err != nil { - return nil - } - return ret -} - -func (r *ticketSLARecordRepository) Take(db *gorm.DB, where ...interface{}) *models.TicketSLARecord { - ret := &models.TicketSLARecord{} - if err := db.Take(ret, where...).Error; err != nil { - return nil - } - return ret -} - -func (r *ticketSLARecordRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketSLARecord) { - cnd.Find(db, &list) - return -} - -func (r *ticketSLARecordRepository) FindOne(db *gorm.DB, cnd *sqls.Cnd) *models.TicketSLARecord { - ret := &models.TicketSLARecord{} - if err := cnd.FindOne(db, &ret); err != nil { - return nil - } - return ret -} - -func (r *ticketSLARecordRepository) FindPageByParams(db *gorm.DB, params *params.QueryParams) (list []models.TicketSLARecord, paging *sqls.Paging) { - return r.FindPageByCnd(db, ¶ms.Cnd) -} - -func (r *ticketSLARecordRepository) FindPageByCnd(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketSLARecord, paging *sqls.Paging) { - cnd.Find(db, &list) - count := cnd.Count(db, &models.TicketSLARecord{}) - - paging = &sqls.Paging{ - Page: cnd.Paging.Page, - Limit: cnd.Paging.Limit, - Total: count, - } - return -} - -func (r *ticketSLARecordRepository) FindBySql(db *gorm.DB, sqlStr string, paramArr ...interface{}) (list []models.TicketSLARecord) { - db.Raw(sqlStr, paramArr...).Scan(&list) - return -} - -func (r *ticketSLARecordRepository) CountBySql(db *gorm.DB, sqlStr string, paramArr ...interface{}) (count int64) { - db.Raw(sqlStr, paramArr...).Count(&count) - return -} - -func (r *ticketSLARecordRepository) Count(db *gorm.DB, cnd *sqls.Cnd) int64 { - return cnd.Count(db, &models.TicketSLARecord{}) -} - -func (r *ticketSLARecordRepository) Create(db *gorm.DB, t *models.TicketSLARecord) (err error) { - err = db.Create(t).Error - return -} - -func (r *ticketSLARecordRepository) Update(db *gorm.DB, t *models.TicketSLARecord) (err error) { - err = db.Save(t).Error - return -} - -func (r *ticketSLARecordRepository) Updates(db *gorm.DB, id int64, columns map[string]interface{}) (err error) { - err = db.Model(&models.TicketSLARecord{}).Where("id = ?", id).Updates(columns).Error - return -} - -func (r *ticketSLARecordRepository) UpdateColumn(db *gorm.DB, id int64, name string, value interface{}) (err error) { - err = db.Model(&models.TicketSLARecord{}).Where("id = ?", id).UpdateColumn(name, value).Error - return -} - -func (r *ticketSLARecordRepository) Delete(db *gorm.DB, id int64) { - db.Delete(&models.TicketSLARecord{}, "id = ?", id) -} diff --git a/internal/repositories/ticket_watcher_repository.go b/internal/repositories/ticket_watcher_repository.go deleted file mode 100644 index 85a0ada..0000000 --- a/internal/repositories/ticket_watcher_repository.go +++ /dev/null @@ -1,109 +0,0 @@ -package repositories - -import ( - "cs-agent/internal/models" - - "github.com/mlogclub/simple/sqls" - "github.com/mlogclub/simple/web/params" - "gorm.io/gorm" -) - -var TicketWatcherRepository = newTicketWatcherRepository() - -func newTicketWatcherRepository() *ticketWatcherRepository { - return &ticketWatcherRepository{} -} - -type ticketWatcherRepository struct { -} - -func (r *ticketWatcherRepository) TakeByTicketIDAndUserID(db *gorm.DB, ticketID, userID int64) *models.TicketWatcher { - ret := &models.TicketWatcher{} - if err := db.Take(ret, "ticket_id = ? AND user_id = ?", ticketID, userID).Error; err != nil { - return nil - } - return ret -} - -func (r *ticketWatcherRepository) Get(db *gorm.DB, id int64) *models.TicketWatcher { - ret := &models.TicketWatcher{} - if err := db.First(ret, "id = ?", id).Error; err != nil { - return nil - } - return ret -} - -func (r *ticketWatcherRepository) Take(db *gorm.DB, where ...interface{}) *models.TicketWatcher { - ret := &models.TicketWatcher{} - if err := db.Take(ret, where...).Error; err != nil { - return nil - } - return ret -} - -func (r *ticketWatcherRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketWatcher) { - cnd.Find(db, &list) - return -} - -func (r *ticketWatcherRepository) FindOne(db *gorm.DB, cnd *sqls.Cnd) *models.TicketWatcher { - ret := &models.TicketWatcher{} - if err := cnd.FindOne(db, &ret); err != nil { - return nil - } - return ret -} - -func (r *ticketWatcherRepository) FindPageByParams(db *gorm.DB, params *params.QueryParams) (list []models.TicketWatcher, paging *sqls.Paging) { - return r.FindPageByCnd(db, ¶ms.Cnd) -} - -func (r *ticketWatcherRepository) FindPageByCnd(db *gorm.DB, cnd *sqls.Cnd) (list []models.TicketWatcher, paging *sqls.Paging) { - cnd.Find(db, &list) - count := cnd.Count(db, &models.TicketWatcher{}) - - paging = &sqls.Paging{ - Page: cnd.Paging.Page, - Limit: cnd.Paging.Limit, - Total: count, - } - return -} - -func (r *ticketWatcherRepository) FindBySql(db *gorm.DB, sqlStr string, paramArr ...interface{}) (list []models.TicketWatcher) { - db.Raw(sqlStr, paramArr...).Scan(&list) - return -} - -func (r *ticketWatcherRepository) CountBySql(db *gorm.DB, sqlStr string, paramArr ...interface{}) (count int64) { - db.Raw(sqlStr, paramArr...).Count(&count) - return -} - -func (r *ticketWatcherRepository) Count(db *gorm.DB, cnd *sqls.Cnd) int64 { - return cnd.Count(db, &models.TicketWatcher{}) -} - -func (r *ticketWatcherRepository) Create(db *gorm.DB, t *models.TicketWatcher) (err error) { - err = db.Create(t).Error - return -} - -func (r *ticketWatcherRepository) Update(db *gorm.DB, t *models.TicketWatcher) (err error) { - err = db.Save(t).Error - return -} - -func (r *ticketWatcherRepository) Updates(db *gorm.DB, id int64, columns map[string]interface{}) (err error) { - err = db.Model(&models.TicketWatcher{}).Where("id = ?", id).Updates(columns).Error - return -} - -func (r *ticketWatcherRepository) UpdateColumn(db *gorm.DB, id int64, name string, value interface{}) (err error) { - err = db.Model(&models.TicketWatcher{}).Where("id = ?", id).UpdateColumn(name, value).Error - return -} - -func (r *ticketWatcherRepository) Delete(db *gorm.DB, id int64) { - db.Delete(&models.TicketWatcher{}, "id = ?", id) -} diff --git a/internal/services/cronx/cron.go b/internal/services/cronx/cron.go index 8f035ec..271eba2 100644 --- a/internal/services/cronx/cron.go +++ b/internal/services/cronx/cron.go @@ -28,17 +28,6 @@ func Init() { } }) - addFunc(c, "@every 1m", func() { - count, err := services.TicketService.ScanAndMarkBreachedSLAs(200) - if err != nil { - slog.Warn("scan breached ticket slas failed", "error", err) - return - } - if count > 0 { - slog.Info("ticket sla breached scan completed", "breachedCount", count) - } - }) - c.Start() } diff --git a/internal/services/event_handlers/notification_event_handler_test.go b/internal/services/event_handlers/notification_event_handler_test.go index 5a41f1e..fbdf9d8 100644 --- a/internal/services/event_handlers/notification_event_handler_test.go +++ b/internal/services/event_handlers/notification_event_handler_test.go @@ -23,7 +23,7 @@ func TestTicketAssignedInAppNotification(t *testing.T) { TicketNo: "TK202604280001", Title: "退款处理", Source: enums.TicketSourceManual, - Status: enums.TicketStatusOpen, + Status: enums.TicketStatusPending, CurrentAssigneeID: 11, AuditFields: models.AuditFields{ CreatedAt: time.Now(), diff --git a/internal/services/ticket_collaborator_service.go b/internal/services/ticket_collaborator_service.go deleted file mode 100644 index 2a0933e..0000000 --- a/internal/services/ticket_collaborator_service.go +++ /dev/null @@ -1,54 +0,0 @@ -package services - -import ( - "cs-agent/internal/models" - "cs-agent/internal/repositories" - - "github.com/mlogclub/simple/sqls" - "github.com/mlogclub/simple/web/params" -) - -var TicketCollaboratorService = newTicketCollaboratorService() - -func newTicketCollaboratorService() *ticketCollaboratorService { - return &ticketCollaboratorService{} -} - -type ticketCollaboratorService struct { -} - -func (s *ticketCollaboratorService) Get(id int64) *models.TicketCollaborator { - return repositories.TicketCollaboratorRepository.Get(sqls.DB(), id) -} - -func (s *ticketCollaboratorService) Take(where ...interface{}) *models.TicketCollaborator { - return repositories.TicketCollaboratorRepository.Take(sqls.DB(), where...) -} - -func (s *ticketCollaboratorService) Find(cnd *sqls.Cnd) []models.TicketCollaborator { - return repositories.TicketCollaboratorRepository.Find(sqls.DB(), cnd) -} - -func (s *ticketCollaboratorService) FindOne(cnd *sqls.Cnd) *models.TicketCollaborator { - return repositories.TicketCollaboratorRepository.FindOne(sqls.DB(), cnd) -} - -func (s *ticketCollaboratorService) FindPageByParams(params *params.QueryParams) (list []models.TicketCollaborator, paging *sqls.Paging) { - return repositories.TicketCollaboratorRepository.FindPageByParams(sqls.DB(), params) -} - -func (s *ticketCollaboratorService) FindPageByCnd(cnd *sqls.Cnd) (list []models.TicketCollaborator, paging *sqls.Paging) { - return repositories.TicketCollaboratorRepository.FindPageByCnd(sqls.DB(), cnd) -} - -func (s *ticketCollaboratorService) Count(cnd *sqls.Cnd) int64 { - return repositories.TicketCollaboratorRepository.Count(sqls.DB(), cnd) -} - -func (s *ticketCollaboratorService) Create(t *models.TicketCollaborator) error { - return repositories.TicketCollaboratorRepository.Create(sqls.DB(), t) -} - -func (s *ticketCollaboratorService) Delete(id int64) { - repositories.TicketCollaboratorRepository.Delete(sqls.DB(), id) -} diff --git a/internal/services/ticket_comment_service.go b/internal/services/ticket_comment_service.go deleted file mode 100644 index c9e744a..0000000 --- a/internal/services/ticket_comment_service.go +++ /dev/null @@ -1,67 +0,0 @@ -package services - -import ( - "cs-agent/internal/models" - "cs-agent/internal/repositories" - - "github.com/mlogclub/simple/sqls" - "github.com/mlogclub/simple/web/params" -) - -var TicketCommentService = newTicketCommentService() - -func newTicketCommentService() *ticketCommentService { - return &ticketCommentService{} -} - -type ticketCommentService struct { -} - -func (s *ticketCommentService) Get(id int64) *models.TicketComment { - return repositories.TicketCommentRepository.Get(sqls.DB(), id) -} - -func (s *ticketCommentService) Take(where ...interface{}) *models.TicketComment { - return repositories.TicketCommentRepository.Take(sqls.DB(), where...) -} - -func (s *ticketCommentService) Find(cnd *sqls.Cnd) []models.TicketComment { - return repositories.TicketCommentRepository.Find(sqls.DB(), cnd) -} - -func (s *ticketCommentService) FindOne(cnd *sqls.Cnd) *models.TicketComment { - return repositories.TicketCommentRepository.FindOne(sqls.DB(), cnd) -} - -func (s *ticketCommentService) FindPageByParams(params *params.QueryParams) (list []models.TicketComment, paging *sqls.Paging) { - return repositories.TicketCommentRepository.FindPageByParams(sqls.DB(), params) -} - -func (s *ticketCommentService) FindPageByCnd(cnd *sqls.Cnd) (list []models.TicketComment, paging *sqls.Paging) { - return repositories.TicketCommentRepository.FindPageByCnd(sqls.DB(), cnd) -} - -func (s *ticketCommentService) Count(cnd *sqls.Cnd) int64 { - return repositories.TicketCommentRepository.Count(sqls.DB(), cnd) -} - -func (s *ticketCommentService) Create(t *models.TicketComment) error { - return repositories.TicketCommentRepository.Create(sqls.DB(), t) -} - -func (s *ticketCommentService) Update(t *models.TicketComment) error { - return repositories.TicketCommentRepository.Update(sqls.DB(), t) -} - -func (s *ticketCommentService) Updates(id int64, columns map[string]interface{}) error { - return repositories.TicketCommentRepository.Updates(sqls.DB(), id, columns) -} - -func (s *ticketCommentService) UpdateColumn(id int64, name string, value interface{}) error { - return repositories.TicketCommentRepository.UpdateColumn(sqls.DB(), id, name, value) -} - -func (s *ticketCommentService) Delete(id int64) { - repositories.TicketCommentRepository.Delete(sqls.DB(), id) -} - diff --git a/internal/services/ticket_event_log_service.go b/internal/services/ticket_event_log_service.go deleted file mode 100644 index 62790fb..0000000 --- a/internal/services/ticket_event_log_service.go +++ /dev/null @@ -1,67 +0,0 @@ -package services - -import ( - "cs-agent/internal/models" - "cs-agent/internal/repositories" - - "github.com/mlogclub/simple/sqls" - "github.com/mlogclub/simple/web/params" -) - -var TicketEventLogService = newTicketEventLogService() - -func newTicketEventLogService() *ticketEventLogService { - return &ticketEventLogService{} -} - -type ticketEventLogService struct { -} - -func (s *ticketEventLogService) Get(id int64) *models.TicketEventLog { - return repositories.TicketEventLogRepository.Get(sqls.DB(), id) -} - -func (s *ticketEventLogService) Take(where ...interface{}) *models.TicketEventLog { - return repositories.TicketEventLogRepository.Take(sqls.DB(), where...) -} - -func (s *ticketEventLogService) Find(cnd *sqls.Cnd) []models.TicketEventLog { - return repositories.TicketEventLogRepository.Find(sqls.DB(), cnd) -} - -func (s *ticketEventLogService) FindOne(cnd *sqls.Cnd) *models.TicketEventLog { - return repositories.TicketEventLogRepository.FindOne(sqls.DB(), cnd) -} - -func (s *ticketEventLogService) FindPageByParams(params *params.QueryParams) (list []models.TicketEventLog, paging *sqls.Paging) { - return repositories.TicketEventLogRepository.FindPageByParams(sqls.DB(), params) -} - -func (s *ticketEventLogService) FindPageByCnd(cnd *sqls.Cnd) (list []models.TicketEventLog, paging *sqls.Paging) { - return repositories.TicketEventLogRepository.FindPageByCnd(sqls.DB(), cnd) -} - -func (s *ticketEventLogService) Count(cnd *sqls.Cnd) int64 { - return repositories.TicketEventLogRepository.Count(sqls.DB(), cnd) -} - -func (s *ticketEventLogService) Create(t *models.TicketEventLog) error { - return repositories.TicketEventLogRepository.Create(sqls.DB(), t) -} - -func (s *ticketEventLogService) Update(t *models.TicketEventLog) error { - return repositories.TicketEventLogRepository.Update(sqls.DB(), t) -} - -func (s *ticketEventLogService) Updates(id int64, columns map[string]interface{}) error { - return repositories.TicketEventLogRepository.Updates(sqls.DB(), id, columns) -} - -func (s *ticketEventLogService) UpdateColumn(id int64, name string, value interface{}) error { - return repositories.TicketEventLogRepository.UpdateColumn(sqls.DB(), id, name, value) -} - -func (s *ticketEventLogService) Delete(id int64) { - repositories.TicketEventLogRepository.Delete(sqls.DB(), id) -} - diff --git a/internal/services/ticket_mention_service.go b/internal/services/ticket_mention_service.go deleted file mode 100644 index b9f440e..0000000 --- a/internal/services/ticket_mention_service.go +++ /dev/null @@ -1,46 +0,0 @@ -package services - -import ( - "cs-agent/internal/models" - "cs-agent/internal/repositories" - - "github.com/mlogclub/simple/sqls" - "github.com/mlogclub/simple/web/params" -) - -var TicketMentionService = newTicketMentionService() - -func newTicketMentionService() *ticketMentionService { - return &ticketMentionService{} -} - -type ticketMentionService struct { -} - -func (s *ticketMentionService) Get(id int64) *models.TicketMention { - return repositories.TicketMentionRepository.Get(sqls.DB(), id) -} - -func (s *ticketMentionService) Take(where ...interface{}) *models.TicketMention { - return repositories.TicketMentionRepository.Take(sqls.DB(), where...) -} - -func (s *ticketMentionService) Find(cnd *sqls.Cnd) []models.TicketMention { - return repositories.TicketMentionRepository.Find(sqls.DB(), cnd) -} - -func (s *ticketMentionService) FindOne(cnd *sqls.Cnd) *models.TicketMention { - return repositories.TicketMentionRepository.FindOne(sqls.DB(), cnd) -} - -func (s *ticketMentionService) FindPageByParams(params *params.QueryParams) (list []models.TicketMention, paging *sqls.Paging) { - return repositories.TicketMentionRepository.FindPageByParams(sqls.DB(), params) -} - -func (s *ticketMentionService) FindPageByCnd(cnd *sqls.Cnd) (list []models.TicketMention, paging *sqls.Paging) { - return repositories.TicketMentionRepository.FindPageByCnd(sqls.DB(), cnd) -} - -func (s *ticketMentionService) Count(cnd *sqls.Cnd) int64 { - return repositories.TicketMentionRepository.Count(sqls.DB(), cnd) -} diff --git a/internal/services/ticket_no_sequence_service.go b/internal/services/ticket_no_sequence_service.go new file mode 100644 index 0000000..2af1382 --- /dev/null +++ b/internal/services/ticket_no_sequence_service.go @@ -0,0 +1,98 @@ +package services + +import ( + "fmt" + "time" + + "cs-agent/internal/models" + "cs-agent/internal/repositories" + + "github.com/mlogclub/simple/sqls" + "github.com/mlogclub/simple/web/params" + "gorm.io/gorm" +) + +var TicketNoSequenceService = newTicketNoSequenceService() + +func newTicketNoSequenceService() *ticketNoSequenceService { + return &ticketNoSequenceService{} +} + +type ticketNoSequenceService struct { +} + +func (s *ticketNoSequenceService) Get(id int64) *models.TicketNoSequence { + return repositories.TicketNoSequenceRepository.Get(sqls.DB(), id) +} + +func (s *ticketNoSequenceService) Take(where ...any) *models.TicketNoSequence { + return repositories.TicketNoSequenceRepository.Take(sqls.DB(), where...) +} + +func (s *ticketNoSequenceService) Find(cnd *sqls.Cnd) []models.TicketNoSequence { + return repositories.TicketNoSequenceRepository.Find(sqls.DB(), cnd) +} + +func (s *ticketNoSequenceService) FindOne(cnd *sqls.Cnd) *models.TicketNoSequence { + return repositories.TicketNoSequenceRepository.FindOne(sqls.DB(), cnd) +} + +func (s *ticketNoSequenceService) FindPageByParams(params *params.QueryParams) (list []models.TicketNoSequence, paging *sqls.Paging) { + return repositories.TicketNoSequenceRepository.FindPageByParams(sqls.DB(), params) +} + +func (s *ticketNoSequenceService) FindPageByCnd(cnd *sqls.Cnd) (list []models.TicketNoSequence, paging *sqls.Paging) { + return repositories.TicketNoSequenceRepository.FindPageByCnd(sqls.DB(), cnd) +} + +func (s *ticketNoSequenceService) Count(cnd *sqls.Cnd) int64 { + return repositories.TicketNoSequenceRepository.Count(sqls.DB(), cnd) +} + +func (s *ticketNoSequenceService) Create(t *models.TicketNoSequence) error { + return repositories.TicketNoSequenceRepository.Create(sqls.DB(), t) +} + +func (s *ticketNoSequenceService) Update(t *models.TicketNoSequence) error { + return repositories.TicketNoSequenceRepository.Update(sqls.DB(), t) +} + +func (s *ticketNoSequenceService) Updates(id int64, columns map[string]any) error { + return repositories.TicketNoSequenceRepository.Updates(sqls.DB(), id, columns) +} + +func (s *ticketNoSequenceService) UpdateColumn(id int64, name string, value any) error { + return repositories.TicketNoSequenceRepository.UpdateColumn(sqls.DB(), id, name, value) +} + +func (s *ticketNoSequenceService) Delete(id int64) { + repositories.TicketNoSequenceRepository.Delete(sqls.DB(), id) +} + +func (s *ticketNoSequenceService) Next(db *gorm.DB, now time.Time) (string, error) { + dateKey := now.Format("20060102") + for i := 0; i < 5; i++ { + current := repositories.TicketNoSequenceRepository.GetByDateKey(db, dateKey) + if current == nil { + item := &models.TicketNoSequence{ + DateKey: dateKey, + NextSeq: 2, + CreatedAt: now, + UpdatedAt: now, + } + if err := repositories.TicketNoSequenceRepository.Create(db, item); err != nil { + continue + } + return fmt.Sprintf("TK%s%04d", dateKey, int64(1)), nil + } + seq := current.NextSeq + ok, err := repositories.TicketNoSequenceRepository.UpdateNextSeq(db, current.ID, seq, seq+1, now) + if err != nil { + return "", err + } + if ok { + return fmt.Sprintf("TK%s%04d", dateKey, seq), nil + } + } + return "", fmt.Errorf("generate ticket number failed") +} diff --git a/internal/services/ticket_priority_config_service.go b/internal/services/ticket_priority_config_service.go deleted file mode 100644 index 2750af9..0000000 --- a/internal/services/ticket_priority_config_service.go +++ /dev/null @@ -1,162 +0,0 @@ -package services - -import ( - "strings" - "time" - - "cs-agent/internal/models" - "cs-agent/internal/pkg/dto" - "cs-agent/internal/pkg/dto/request" - "cs-agent/internal/pkg/enums" - "cs-agent/internal/pkg/errorsx" - "cs-agent/internal/pkg/utils" - "cs-agent/internal/repositories" - - "github.com/mlogclub/simple/sqls" - "github.com/mlogclub/simple/web/params" -) - -var TicketPriorityConfigService = newTicketPriorityConfigService() - -func newTicketPriorityConfigService() *ticketPriorityConfigService { - return &ticketPriorityConfigService{} -} - -type ticketPriorityConfigService struct{} - -func (s *ticketPriorityConfigService) Get(id int64) *models.TicketPriorityConfig { - return repositories.TicketPriorityConfigRepository.Get(sqls.DB(), id) -} - -func (s *ticketPriorityConfigService) Take(where ...interface{}) *models.TicketPriorityConfig { - return repositories.TicketPriorityConfigRepository.Take(sqls.DB(), where...) -} - -func (s *ticketPriorityConfigService) Find(cnd *sqls.Cnd) []models.TicketPriorityConfig { - return repositories.TicketPriorityConfigRepository.Find(sqls.DB(), cnd) -} - -func (s *ticketPriorityConfigService) FindOne(cnd *sqls.Cnd) *models.TicketPriorityConfig { - return repositories.TicketPriorityConfigRepository.FindOne(sqls.DB(), cnd) -} - -func (s *ticketPriorityConfigService) FindPageByParams(queryParams *params.QueryParams) (list []models.TicketPriorityConfig, paging *sqls.Paging) { - return repositories.TicketPriorityConfigRepository.FindPageByParams(sqls.DB(), queryParams) -} - -func (s *ticketPriorityConfigService) FindPageByCnd(cnd *sqls.Cnd) (list []models.TicketPriorityConfig, paging *sqls.Paging) { - return repositories.TicketPriorityConfigRepository.FindPageByCnd(sqls.DB(), cnd) -} - -func (s *ticketPriorityConfigService) Create(t *models.TicketPriorityConfig) error { - return repositories.TicketPriorityConfigRepository.Create(sqls.DB(), t) -} - -func (s *ticketPriorityConfigService) Updates(id int64, columns map[string]any) error { - return repositories.TicketPriorityConfigRepository.Updates(sqls.DB(), id, columns) -} - -func (s *ticketPriorityConfigService) GetDefaultActive() *models.TicketPriorityConfig { - return s.FindOne(sqls.NewCnd().Eq("status", enums.StatusOk).Asc("sort_no").Asc("id")) -} - -func (s *ticketPriorityConfigService) CreateTicketPriorityConfig(req request.CreateTicketPriorityConfigRequest, operator *dto.AuthPrincipal) (*models.TicketPriorityConfig, error) { - if operator == nil { - return nil, errorsx.Unauthorized("未登录或登录已过期") - } - item, err := s.buildPriorityConfigModel(0, req.Name, req.FirstResponseMinutes, req.ResolutionMinutes, int(req.Status), req.Remark) - if err != nil { - return nil, err - } - item.SortNo = s.nextSortNo() - item.AuditFields = utils.BuildAuditFields(operator) - if err := s.Create(item); err != nil { - return nil, err - } - return item, nil -} - -func (s *ticketPriorityConfigService) UpdateTicketPriorityConfig(req request.UpdateTicketPriorityConfigRequest, operator *dto.AuthPrincipal) error { - if operator == nil { - return errorsx.Unauthorized("未登录或登录已过期") - } - current := s.Get(req.ID) - if current == nil || current.Status == enums.StatusDeleted { - return errorsx.InvalidParam("工单优先级配置不存在") - } - item, err := s.buildPriorityConfigModel(req.ID, req.Name, req.FirstResponseMinutes, req.ResolutionMinutes, int(req.Status), req.Remark) - if err != nil { - return err - } - return s.Updates(req.ID, map[string]any{ - "name": item.Name, - "first_response_minutes": item.FirstResponseMinutes, - "resolution_minutes": item.ResolutionMinutes, - "status": item.Status, - "remark": item.Remark, - "update_user_id": operator.UserID, - "update_user_name": operator.Username, - "updated_at": time.Now(), - }) -} - -func (s *ticketPriorityConfigService) UpdateSort(ids []int64) error { - return sqls.WithTransaction(func(ctx *sqls.TxContext) error { - for i, id := range ids { - if err := repositories.TicketPriorityConfigRepository.UpdateColumn(ctx.Tx, id, "sort_no", i+1); err != nil { - return err - } - } - return nil - }) -} - -func (s *ticketPriorityConfigService) DeleteTicketPriorityConfig(id int64, operator *dto.AuthPrincipal) error { - if operator == nil { - return errorsx.Unauthorized("未登录或登录已过期") - } - current := s.Get(id) - if current == nil || current.Status == enums.StatusDeleted { - return errorsx.InvalidParam("工单优先级配置不存在") - } - if TicketService.Take("priority = ?", id) != nil { - return errorsx.Forbidden("该优先级仍有关联工单,无法删除") - } - return s.Updates(id, map[string]any{ - "status": enums.StatusDeleted, - "update_user_id": operator.UserID, - "update_user_name": operator.Username, - "updated_at": time.Now(), - }) -} - -func (s *ticketPriorityConfigService) buildPriorityConfigModel(id int64, name string, firstResponseMinutes, resolutionMinutes, status int, remark string) (*models.TicketPriorityConfig, error) { - name = strings.TrimSpace(name) - if name == "" { - return nil, errorsx.InvalidParam("工单优先级名称不能为空") - } - if firstResponseMinutes <= 0 || resolutionMinutes <= 0 { - return nil, errorsx.InvalidParam("SLA 时长必须大于 0") - } - if !enums.IsValidStatus(status) || status == int(enums.StatusDeleted) { - return nil, errorsx.InvalidParam("工单优先级状态不合法") - } - if exists := s.Take("name = ? AND status <> ? AND id <> ?", name, enums.StatusDeleted, id); exists != nil { - return nil, errorsx.InvalidParam("工单优先级名称已存在") - } - return &models.TicketPriorityConfig{ - Name: name, - FirstResponseMinutes: firstResponseMinutes, - ResolutionMinutes: resolutionMinutes, - Status: enums.Status(status), - Remark: strings.TrimSpace(remark), - }, nil -} - -func (s *ticketPriorityConfigService) nextSortNo() int { - list := s.Find(sqls.NewCnd().NotEq("status", enums.StatusDeleted).Desc("sort_no").Desc("id").Limit(1)) - if len(list) == 0 { - return 1 - } - return list[0].SortNo + 1 -} diff --git a/internal/services/ticket_progress_service.go b/internal/services/ticket_progress_service.go new file mode 100644 index 0000000..ec616fa --- /dev/null +++ b/internal/services/ticket_progress_service.go @@ -0,0 +1,66 @@ +package services + +import ( + "cs-agent/internal/models" + "cs-agent/internal/repositories" + + "github.com/mlogclub/simple/sqls" + "github.com/mlogclub/simple/web/params" +) + +var TicketProgressService = newTicketProgressService() + +func newTicketProgressService() *ticketProgressService { + return &ticketProgressService{} +} + +type ticketProgressService struct { +} + +func (s *ticketProgressService) Get(id int64) *models.TicketProgress { + return repositories.TicketProgressRepository.Get(sqls.DB(), id) +} + +func (s *ticketProgressService) Take(where ...any) *models.TicketProgress { + return repositories.TicketProgressRepository.Take(sqls.DB(), where...) +} + +func (s *ticketProgressService) Find(cnd *sqls.Cnd) []models.TicketProgress { + return repositories.TicketProgressRepository.Find(sqls.DB(), cnd) +} + +func (s *ticketProgressService) FindOne(cnd *sqls.Cnd) *models.TicketProgress { + return repositories.TicketProgressRepository.FindOne(sqls.DB(), cnd) +} + +func (s *ticketProgressService) FindPageByParams(params *params.QueryParams) (list []models.TicketProgress, paging *sqls.Paging) { + return repositories.TicketProgressRepository.FindPageByParams(sqls.DB(), params) +} + +func (s *ticketProgressService) FindPageByCnd(cnd *sqls.Cnd) (list []models.TicketProgress, paging *sqls.Paging) { + return repositories.TicketProgressRepository.FindPageByCnd(sqls.DB(), cnd) +} + +func (s *ticketProgressService) Count(cnd *sqls.Cnd) int64 { + return repositories.TicketProgressRepository.Count(sqls.DB(), cnd) +} + +func (s *ticketProgressService) Create(t *models.TicketProgress) error { + return repositories.TicketProgressRepository.Create(sqls.DB(), t) +} + +func (s *ticketProgressService) Update(t *models.TicketProgress) error { + return repositories.TicketProgressRepository.Update(sqls.DB(), t) +} + +func (s *ticketProgressService) Updates(id int64, columns map[string]any) error { + return repositories.TicketProgressRepository.Updates(sqls.DB(), id, columns) +} + +func (s *ticketProgressService) UpdateColumn(id int64, name string, value any) error { + return repositories.TicketProgressRepository.UpdateColumn(sqls.DB(), id, name, value) +} + +func (s *ticketProgressService) Delete(id int64) { + repositories.TicketProgressRepository.Delete(sqls.DB(), id) +} diff --git a/internal/services/ticket_relation_service.go b/internal/services/ticket_relation_service.go deleted file mode 100644 index 385834d..0000000 --- a/internal/services/ticket_relation_service.go +++ /dev/null @@ -1,212 +0,0 @@ -package services - -import ( - "cs-agent/internal/models" - "cs-agent/internal/pkg/dto" - "cs-agent/internal/pkg/enums" - "cs-agent/internal/pkg/errorsx" - "cs-agent/internal/repositories" - "strings" - "time" - - "github.com/mlogclub/simple/sqls" - "github.com/mlogclub/simple/web/params" -) - -var TicketRelationService = newTicketRelationService() - -func newTicketRelationService() *ticketRelationService { - return &ticketRelationService{} -} - -type ticketRelationService struct { -} - -func (s *ticketRelationService) Get(id int64) *models.TicketRelation { - return repositories.TicketRelationRepository.Get(sqls.DB(), id) -} - -func (s *ticketRelationService) Take(where ...interface{}) *models.TicketRelation { - return repositories.TicketRelationRepository.Take(sqls.DB(), where...) -} - -func (s *ticketRelationService) Find(cnd *sqls.Cnd) []models.TicketRelation { - return repositories.TicketRelationRepository.Find(sqls.DB(), cnd) -} - -func (s *ticketRelationService) FindOne(cnd *sqls.Cnd) *models.TicketRelation { - return repositories.TicketRelationRepository.FindOne(sqls.DB(), cnd) -} - -func (s *ticketRelationService) FindPageByParams(params *params.QueryParams) (list []models.TicketRelation, paging *sqls.Paging) { - return repositories.TicketRelationRepository.FindPageByParams(sqls.DB(), params) -} - -func (s *ticketRelationService) FindPageByCnd(cnd *sqls.Cnd) (list []models.TicketRelation, paging *sqls.Paging) { - return repositories.TicketRelationRepository.FindPageByCnd(sqls.DB(), cnd) -} - -func (s *ticketRelationService) Count(cnd *sqls.Cnd) int64 { - return repositories.TicketRelationRepository.Count(sqls.DB(), cnd) -} - -func (s *ticketRelationService) Create(t *models.TicketRelation) error { - return repositories.TicketRelationRepository.Create(sqls.DB(), t) -} - -func (s *ticketRelationService) Update(t *models.TicketRelation) error { - return repositories.TicketRelationRepository.Update(sqls.DB(), t) -} - -func (s *ticketRelationService) Updates(id int64, columns map[string]interface{}) error { - return repositories.TicketRelationRepository.Updates(sqls.DB(), id, columns) -} - -func (s *ticketRelationService) UpdateColumn(id int64, name string, value interface{}) error { - return repositories.TicketRelationRepository.UpdateColumn(sqls.DB(), id, name, value) -} - -func (s *ticketRelationService) Delete(id int64) { - repositories.TicketRelationRepository.Delete(sqls.DB(), id) -} - -func (s *ticketRelationService) AddRelation(ticketID, relatedTicketID int64, relationType enums.TicketRelationType, operator *dto.AuthPrincipal) error { - if operator == nil { - return errorsx.Unauthorized("未登录或登录已过期") - } - if ticketID <= 0 || relatedTicketID <= 0 { - return errorsx.InvalidParam("工单不存在") - } - if ticketID == relatedTicketID { - return errorsx.InvalidParam("不能关联自己") - } - if !isValidTicketRelationType(relationType) { - return errorsx.InvalidParam("关联类型不合法") - } - ticket := TicketService.Get(ticketID) - if ticket == nil { - return errorsx.InvalidParam("工单不存在") - } - relatedTicket := TicketService.Get(relatedTicketID) - if relatedTicket == nil { - return errorsx.InvalidParam("关联工单不存在") - } - if repositories.TicketRelationRepository.Take(sqls.DB(), "ticket_id = ? AND related_ticket_id = ? AND relation_type = ?", ticketID, relatedTicketID, relationType) != nil { - return errorsx.InvalidParam("该关联已存在") - } - now := time.Now() - inverseType := inverseTicketRelationType(relationType) - return sqls.WithTransaction(func(ctx *sqls.TxContext) error { - if err := repositories.TicketRelationRepository.Create(ctx.Tx, &models.TicketRelation{ - TicketID: ticketID, - RelatedTicketID: relatedTicketID, - RelationType: relationType, - CreatedAt: now, - }); err != nil { - return err - } - if repositories.TicketRelationRepository.Take(ctx.Tx, "ticket_id = ? AND related_ticket_id = ? AND relation_type = ?", relatedTicketID, ticketID, inverseType) == nil { - if err := repositories.TicketRelationRepository.Create(ctx.Tx, &models.TicketRelation{ - TicketID: relatedTicketID, - RelatedTicketID: ticketID, - RelationType: inverseType, - CreatedAt: now, - }); err != nil { - return err - } - } - if err := repositories.TicketEventLogRepository.Create(ctx.Tx, &models.TicketEventLog{ - TicketID: ticketID, - EventType: enums.TicketEventTypeUpdated, - OperatorType: enums.IMSenderTypeAgent, - OperatorID: operator.UserID, - Content: "新增关联工单", - Payload: strings.TrimSpace(string(relationType) + ":" + relatedTicket.TicketNo), - CreatedAt: now, - }); err != nil { - return err - } - return repositories.TicketEventLogRepository.Create(ctx.Tx, &models.TicketEventLog{ - TicketID: relatedTicketID, - EventType: enums.TicketEventTypeUpdated, - OperatorType: enums.IMSenderTypeAgent, - OperatorID: operator.UserID, - Content: "新增关联工单", - Payload: strings.TrimSpace(string(inverseType) + ":" + ticket.TicketNo), - CreatedAt: now, - }) - }) -} - -func (s *ticketRelationService) DeleteRelation(ticketID, relationID int64, operator *dto.AuthPrincipal) error { - if operator == nil { - return errorsx.Unauthorized("未登录或登录已过期") - } - relation := s.Get(relationID) - if relation == nil || relation.TicketID != ticketID { - return errorsx.InvalidParam("关联关系不存在") - } - ticket := TicketService.Get(relation.TicketID) - relatedTicket := TicketService.Get(relation.RelatedTicketID) - now := time.Now() - return sqls.WithTransaction(func(ctx *sqls.TxContext) error { - if err := repositories.TicketRelationRepository.DeleteByTicketRelation(ctx.Tx, relation.TicketID, relation.RelatedTicketID, string(relation.RelationType)); err != nil { - return err - } - if err := repositories.TicketRelationRepository.DeleteByTicketRelation(ctx.Tx, relation.RelatedTicketID, relation.TicketID, string(inverseTicketRelationType(relation.RelationType))); err != nil { - return err - } - if ticket != nil { - if err := repositories.TicketEventLogRepository.Create(ctx.Tx, &models.TicketEventLog{ - TicketID: ticket.ID, - EventType: enums.TicketEventTypeUpdated, - OperatorType: enums.IMSenderTypeAgent, - OperatorID: operator.UserID, - Content: "移除关联工单", - Payload: strings.TrimSpace(string(relation.RelationType) + ":" + relationTicketNo(relatedTicket)), - CreatedAt: now, - }); err != nil { - return err - } - } - if relatedTicket != nil { - return repositories.TicketEventLogRepository.Create(ctx.Tx, &models.TicketEventLog{ - TicketID: relatedTicket.ID, - EventType: enums.TicketEventTypeUpdated, - OperatorType: enums.IMSenderTypeAgent, - OperatorID: operator.UserID, - Content: "移除关联工单", - Payload: strings.TrimSpace(string(inverseTicketRelationType(relation.RelationType)) + ":" + relationTicketNo(ticket)), - CreatedAt: now, - }) - } - return nil - }) -} - -func isValidTicketRelationType(relationType enums.TicketRelationType) bool { - switch relationType { - case enums.TicketRelationTypeDuplicate, enums.TicketRelationTypeRelated, enums.TicketRelationTypeParent, enums.TicketRelationTypeChild: - return true - default: - return false - } -} - -func inverseTicketRelationType(relationType enums.TicketRelationType) enums.TicketRelationType { - switch relationType { - case enums.TicketRelationTypeParent: - return enums.TicketRelationTypeChild - case enums.TicketRelationTypeChild: - return enums.TicketRelationTypeParent - default: - return relationType - } -} - -func relationTicketNo(ticket *models.Ticket) string { - if ticket == nil { - return "" - } - return ticket.TicketNo -} diff --git a/internal/services/ticket_resolution_code_service.go b/internal/services/ticket_resolution_code_service.go deleted file mode 100644 index 62c79fe..0000000 --- a/internal/services/ticket_resolution_code_service.go +++ /dev/null @@ -1,134 +0,0 @@ -package services - -import ( - "cs-agent/internal/models" - "cs-agent/internal/pkg/dto" - "cs-agent/internal/pkg/dto/request" - "cs-agent/internal/pkg/enums" - "cs-agent/internal/pkg/errorsx" - "cs-agent/internal/pkg/utils" - "cs-agent/internal/repositories" - "strings" - "time" - - "github.com/mlogclub/simple/sqls" - "github.com/mlogclub/simple/web/params" -) - -var TicketResolutionCodeService = newTicketResolutionCodeService() - -func newTicketResolutionCodeService() *ticketResolutionCodeService { - return &ticketResolutionCodeService{} -} - -type ticketResolutionCodeService struct{} - -func (s *ticketResolutionCodeService) Get(id int64) *models.TicketResolutionCode { - return repositories.TicketResolutionCodeRepository.Get(sqls.DB(), id) -} -func (s *ticketResolutionCodeService) Take(where ...interface{}) *models.TicketResolutionCode { - return repositories.TicketResolutionCodeRepository.Take(sqls.DB(), where...) -} -func (s *ticketResolutionCodeService) Find(cnd *sqls.Cnd) []models.TicketResolutionCode { - return repositories.TicketResolutionCodeRepository.Find(sqls.DB(), cnd) -} -func (s *ticketResolutionCodeService) FindOne(cnd *sqls.Cnd) *models.TicketResolutionCode { - return repositories.TicketResolutionCodeRepository.FindOne(sqls.DB(), cnd) -} -func (s *ticketResolutionCodeService) FindPageByParams(params *params.QueryParams) (list []models.TicketResolutionCode, paging *sqls.Paging) { - return repositories.TicketResolutionCodeRepository.FindPageByParams(sqls.DB(), params) -} -func (s *ticketResolutionCodeService) FindPageByCnd(cnd *sqls.Cnd) (list []models.TicketResolutionCode, paging *sqls.Paging) { - return repositories.TicketResolutionCodeRepository.FindPageByCnd(sqls.DB(), cnd) -} -func (s *ticketResolutionCodeService) Count(cnd *sqls.Cnd) int64 { - return repositories.TicketResolutionCodeRepository.Count(sqls.DB(), cnd) -} -func (s *ticketResolutionCodeService) Create(t *models.TicketResolutionCode) error { - return repositories.TicketResolutionCodeRepository.Create(sqls.DB(), t) -} -func (s *ticketResolutionCodeService) Updates(id int64, columns map[string]interface{}) error { - return repositories.TicketResolutionCodeRepository.Updates(sqls.DB(), id, columns) -} - -func (s *ticketResolutionCodeService) CreateTicketResolutionCode(req request.CreateTicketResolutionCodeRequest, operator *dto.AuthPrincipal) (*models.TicketResolutionCode, error) { - if operator == nil { - return nil, errorsx.Unauthorized("未登录或登录已过期") - } - item, err := s.buildResolutionCodeModel(0, req.Name, req.Code, int(req.Status), req.SortNo, req.Remark) - if err != nil { - return nil, err - } - item.AuditFields = utils.BuildAuditFields(operator) - if err := s.Create(item); err != nil { - return nil, err - } - return item, nil -} - -func (s *ticketResolutionCodeService) UpdateTicketResolutionCode(req request.UpdateTicketResolutionCodeRequest, operator *dto.AuthPrincipal) error { - if operator == nil { - return errorsx.Unauthorized("未登录或登录已过期") - } - current := s.Get(req.ID) - if current == nil || current.Status == enums.StatusDeleted { - return errorsx.InvalidParam("工单解决码不存在") - } - item, err := s.buildResolutionCodeModel(req.ID, req.Name, req.Code, int(req.Status), req.SortNo, req.Remark) - if err != nil { - return err - } - return s.Updates(req.ID, map[string]any{ - "name": item.Name, - "code": item.Code, - "status": item.Status, - "sort_no": item.SortNo, - "remark": item.Remark, - "update_user_id": operator.UserID, - "update_user_name": operator.Username, - "updated_at": time.Now(), - }) -} - -func (s *ticketResolutionCodeService) DeleteTicketResolutionCode(id int64, operator *dto.AuthPrincipal) error { - if operator == nil { - return errorsx.Unauthorized("未登录或登录已过期") - } - current := s.Get(id) - if current == nil || current.Status == enums.StatusDeleted { - return errorsx.InvalidParam("工单解决码不存在") - } - return s.Updates(id, map[string]any{ - "status": enums.StatusDeleted, - "update_user_id": operator.UserID, - "update_user_name": operator.Username, - "updated_at": time.Now(), - }) -} - -func (s *ticketResolutionCodeService) buildResolutionCodeModel(id int64, name, code string, status, sortNo int, remark string) (*models.TicketResolutionCode, error) { - name = strings.TrimSpace(name) - code = strings.TrimSpace(code) - if name == "" { - return nil, errorsx.InvalidParam("工单解决码名称不能为空") - } - if code == "" { - return nil, errorsx.InvalidParam("工单解决码编码不能为空") - } - if !enums.IsValidStatus(status) || status == int(enums.StatusDeleted) { - return nil, errorsx.InvalidParam("工单解决码状态不合法") - } - if exists := s.Take("name = ? AND status <> ? AND id <> ?", name, enums.StatusDeleted, id); exists != nil { - return nil, errorsx.InvalidParam("工单解决码名称已存在") - } - if exists := s.Take("code = ? AND status <> ? AND id <> ?", code, enums.StatusDeleted, id); exists != nil { - return nil, errorsx.InvalidParam("工单解决码编码已存在") - } - return &models.TicketResolutionCode{ - Name: name, - Code: code, - SortNo: sortNo, - Status: enums.Status(status), - Remark: strings.TrimSpace(remark), - }, nil -} diff --git a/internal/services/ticket_service.go b/internal/services/ticket_service.go index 7e02f82..1b46c7a 100644 --- a/internal/services/ticket_service.go +++ b/internal/services/ticket_service.go @@ -2,8 +2,6 @@ package services import ( "context" - "encoding/json" - "fmt" "strings" "time" @@ -29,74 +27,34 @@ func newTicketService() *ticketService { } type TicketDetailAggregate struct { - Ticket *models.Ticket - Tags []models.Tag - Customer *models.Customer - SLAs []models.TicketSLARecord - Watchers []models.TicketWatcher - Collaborators []models.TicketCollaborator - Comments []models.TicketComment - Events []models.TicketEventLog - RelatedTickets []models.TicketRelation - Users map[int64]*models.User - Teams map[int64]*models.AgentTeam - AgentProfiles map[int64]*models.AgentProfile - RelatedMap map[int64]*models.Ticket - OperatorUsers map[int64]*models.User - OperatorCustomers map[int64]*models.Customer - OperatorAIAgents map[int64]*models.AIAgent + Ticket *models.Ticket + Tags []models.Tag + Customer *models.Customer + Progresses []models.TicketProgress + Users map[int64]*models.User } type TicketSummaryAggregate struct { - All int64 - Mine int64 - Watching int64 - Collaboration int64 - Participating int64 - Mentioned int64 - Unassigned int64 - PendingCustomer int64 - PendingInternal int64 - Overdue int64 -} - -type TicketRiskReasonAggregate struct { - Code string - Title string - Description string - Count int64 -} - -type TicketRiskOverviewAggregate struct { - Overdue int64 - HighRisk int64 - Unassigned int64 - PendingInternal int64 - PendingCustomer int64 - RiskWindowMins int - Reasons []TicketRiskReasonAggregate + All int64 + Pending int64 + InProgress int64 + Done int64 + Unassigned int64 + Mine int64 + Stale int64 } type TicketListAggregate struct { - List []models.Ticket - Paging *sqls.Paging - TagsByTicketID map[int64][]models.Tag - Priorities map[int64]*models.TicketPriorityConfig - ResolutionCodes map[string]*models.TicketResolutionCode - Users map[int64]*models.User - Teams map[int64]*models.AgentTeam - Customers map[int64]*models.Customer - SLAByTicketID map[int64][]models.TicketSLARecord - WatchedTicketIDs map[int64]struct{} + List []models.Ticket + Paging *sqls.Paging + TagsByTicketID map[int64][]models.Tag + Users map[int64]*models.User + Customers map[int64]*models.Customer } type ticketService struct { } -type ticketNotePayload struct { - MentionUserIDs []int64 `json:"mentionUserIds,omitempty"` -} - func (s *ticketService) Get(id int64) *models.Ticket { return repositories.TicketRepository.Get(sqls.DB(), id) } @@ -121,43 +79,9 @@ func (s *ticketService) FindPageByCnd(cnd *sqls.Cnd) (list []models.Ticket, pagi return repositories.TicketRepository.FindPageByCnd(sqls.DB(), cnd) } -func (s *ticketService) GetTags(ticketID int64) []models.Tag { - if ticketID <= 0 { - return nil - } - relations := TicketTagService.Find(sqls.NewCnd().Eq("ticket_id", ticketID).Asc("id")) - if len(relations) == 0 { - return nil - } - tagIDs := make([]int64, 0, len(relations)) - tagOrder := make(map[int64]int, len(relations)) - for i := range relations { - tagIDs = append(tagIDs, relations[i].TagID) - tagOrder[relations[i].TagID] = i - } - tags := repositories.TagRepository.Find(sqls.DB(), sqls.NewCnd().In("id", tagIDs)) - if len(tags) <= 1 { - return tags - } - ordered := make([]models.Tag, 0, len(tags)) - indexed := make([]*models.Tag, len(relations)) - for i := range tags { - tag := tags[i] - if order, ok := tagOrder[tag.ID]; ok { - indexed[order] = &tag - } - } - for _, tag := range indexed { - if tag != nil { - ordered = append(ordered, *tag) - } - } - return ordered -} - -func (s *ticketService) FindPageAggregateByCnd(cnd *sqls.Cnd, watcherUserID int64) (*TicketListAggregate, error) { +func (s *ticketService) FindPageAggregateByCnd(cnd *sqls.Cnd, _ int64) (*TicketListAggregate, error) { list, paging := repositories.TicketRepository.FindPageByCnd(sqls.DB(), cnd) - return s.buildTicketListAggregate(sqls.DB(), list, paging, watcherUserID), nil + return s.buildTicketListAggregate(sqls.DB(), list, paging), nil } func (s *ticketService) Count(cnd *sqls.Cnd) int64 { @@ -184,562 +108,47 @@ func (s *ticketService) Delete(id int64) { repositories.TicketRepository.Delete(sqls.DB(), id) } -func (s *ticketService) GetDetail(id int64) (*TicketDetailAggregate, error) { - ticket := s.Get(id) - if ticket == nil { - return nil, errorsx.InvalidParam("工单不存在") +func (s *ticketService) GetTags(ticketID int64) []models.Tag { + if ticketID <= 0 { + return nil } - aggregate := &TicketDetailAggregate{ - Ticket: ticket, - Tags: s.GetTags(id), - SLAs: TicketSLARecordService.Find( - sqls.NewCnd().Eq("ticket_id", id).Asc("id"), - ), - Watchers: TicketWatcherService.Find( - sqls.NewCnd().Eq("ticket_id", id).Asc("id"), - ), - Collaborators: TicketCollaboratorService.Find( - sqls.NewCnd().Eq("ticket_id", id).Asc("id"), - ), - Comments: TicketCommentService.Find( - sqls.NewCnd().Eq("ticket_id", id).Asc("id"), - ), - Events: TicketEventLogService.Find( - sqls.NewCnd().Eq("ticket_id", id).Desc("id"), - ), - RelatedTickets: TicketRelationService.Find( - sqls.NewCnd().Eq("ticket_id", id).Desc("id"), - ), - Users: make(map[int64]*models.User), - Teams: make(map[int64]*models.AgentTeam), - AgentProfiles: make(map[int64]*models.AgentProfile), - RelatedMap: make(map[int64]*models.Ticket), - OperatorUsers: make(map[int64]*models.User), - OperatorCustomers: make(map[int64]*models.Customer), - OperatorAIAgents: make(map[int64]*models.AIAgent), + relations := TicketTagService.Find(sqls.NewCnd().Eq("ticket_id", ticketID).Asc("id")) + if len(relations) == 0 { + return nil } - if ticket.CustomerID > 0 { - aggregate.Customer = CustomerService.Get(ticket.CustomerID) + tagIDs := make([]int64, 0, len(relations)) + for i := range relations { + tagIDs = append(tagIDs, relations[i].TagID) } - s.enrichTicketDetailAggregate(aggregate) - return aggregate, nil -} - -func (s *ticketService) enrichTicketDetailAggregate(aggregate *TicketDetailAggregate) { - if aggregate == nil { - return + tags := repositories.TagRepository.Find(sqls.DB(), sqls.NewCnd().In("id", tagIDs)) + if len(tags) <= 1 { + return tags } - userIDs := make([]int64, 0) - teamIDs := make([]int64, 0) - relatedTicketIDs := make([]int64, 0) - operatorCustomerIDs := make([]int64, 0) - operatorAIIDs := make([]int64, 0) - userSeen := make(map[int64]struct{}) - teamSeen := make(map[int64]struct{}) - relatedSeen := make(map[int64]struct{}) - customerSeen := make(map[int64]struct{}) - aiSeen := make(map[int64]struct{}) - addUserID := func(userID int64) { - if userID <= 0 { - return - } - if _, ok := userSeen[userID]; ok { - return - } - userSeen[userID] = struct{}{} - userIDs = append(userIDs, userID) + tagMap := make(map[int64]models.Tag, len(tags)) + for i := range tags { + tagMap[tags[i].ID] = tags[i] } - addTeamID := func(teamID int64) { - if teamID <= 0 { - return - } - if _, ok := teamSeen[teamID]; ok { - return - } - teamSeen[teamID] = struct{}{} - teamIDs = append(teamIDs, teamID) - } - addCustomerID := func(customerID int64) { - if customerID <= 0 { - return - } - if _, ok := customerSeen[customerID]; ok { - return - } - customerSeen[customerID] = struct{}{} - operatorCustomerIDs = append(operatorCustomerIDs, customerID) - } - addAIID := func(aiID int64) { - if aiID <= 0 { - return - } - if _, ok := aiSeen[aiID]; ok { - return - } - aiSeen[aiID] = struct{}{} - operatorAIIDs = append(operatorAIIDs, aiID) - } - for i := range aggregate.Watchers { - addUserID(aggregate.Watchers[i].UserID) - } - for i := range aggregate.Collaborators { - addUserID(aggregate.Collaborators[i].UserID) - } - for i := range aggregate.RelatedTickets { - relatedTicketID := aggregate.RelatedTickets[i].RelatedTicketID - if relatedTicketID <= 0 { - continue - } - if _, ok := relatedSeen[relatedTicketID]; ok { - continue - } - relatedSeen[relatedTicketID] = struct{}{} - relatedTicketIDs = append(relatedTicketIDs, relatedTicketID) - } - for i := range aggregate.Comments { - switch aggregate.Comments[i].AuthorType { - case enums.IMSenderTypeAgent: - addUserID(aggregate.Comments[i].AuthorID) - case enums.IMSenderTypeCustomer: - addCustomerID(aggregate.Comments[i].AuthorID) - case enums.IMSenderTypeAI: - addAIID(aggregate.Comments[i].AuthorID) + ordered := make([]models.Tag, 0, len(relations)) + for _, tagID := range tagIDs { + if tag, ok := tagMap[tagID]; ok { + ordered = append(ordered, tag) } } - for i := range aggregate.Events { - switch aggregate.Events[i].OperatorType { - case enums.IMSenderTypeAgent: - addUserID(aggregate.Events[i].OperatorID) - case enums.IMSenderTypeCustomer: - addCustomerID(aggregate.Events[i].OperatorID) - case enums.IMSenderTypeAI: - addAIID(aggregate.Events[i].OperatorID) - } - } - if len(userIDs) > 0 { - users := repositories.UserRepository.FindByIds(sqls.DB(), userIDs) - for i := range users { - item := users[i] - aggregate.Users[item.ID] = &item - aggregate.OperatorUsers[item.ID] = &item - } - profiles := repositories.AgentProfileRepository.Find(sqls.DB(), sqls.NewCnd().In("user_id", userIDs)) - for i := range profiles { - item := profiles[i] - aggregate.AgentProfiles[item.UserID] = &item - addTeamID(item.TeamID) - } - } - if len(relatedTicketIDs) > 0 { - relatedTickets := repositories.TicketRepository.Find(sqls.DB(), sqls.NewCnd().In("id", relatedTicketIDs)) - for i := range relatedTickets { - item := relatedTickets[i] - aggregate.RelatedMap[item.ID] = &item - addUserID(item.CurrentAssigneeID) - addTeamID(item.CurrentTeamID) - } - } - if len(teamIDs) > 0 { - teams := repositories.AgentTeamRepository.FindByIds(sqls.DB(), teamIDs) - for i := range teams { - item := teams[i] - aggregate.Teams[item.ID] = &item - } - } - if len(userIDs) > len(aggregate.Users) { - users := repositories.UserRepository.FindByIds(sqls.DB(), userIDs) - for i := range users { - item := users[i] - aggregate.Users[item.ID] = &item - aggregate.OperatorUsers[item.ID] = &item - } - } - if len(operatorCustomerIDs) > 0 { - customers := repositories.CustomerRepository.Find(sqls.DB(), sqls.NewCnd().In("id", operatorCustomerIDs)) - for i := range customers { - item := customers[i] - aggregate.OperatorCustomers[item.ID] = &item - } - } - if len(operatorAIIDs) > 0 { - aiAgents := repositories.AIAgentRepository.FindByIds(sqls.DB(), operatorAIIDs) - for i := range aiAgents { - item := aiAgents[i] - aggregate.OperatorAIAgents[item.ID] = &item - } - } -} - -func (s *ticketService) GetSummary(operator *dto.AuthPrincipal) *TicketSummaryAggregate { - if operator == nil { - return &TicketSummaryAggregate{} - } - now := time.Now() - return &TicketSummaryAggregate{ - All: s.Count(sqls.NewCnd()), - Mine: s.Count( - sqls.NewCnd().Eq("current_assignee_id", operator.UserID), - ), - Watching: s.Count( - sqls.NewCnd().Where("id IN (SELECT ticket_id FROM t_ticket_watcher WHERE user_id = ?)", operator.UserID), - ), - Collaboration: s.Count( - sqls.NewCnd().Where( - "id IN (SELECT ticket_id FROM t_ticket_collaborator WHERE user_id = ?) OR id IN (SELECT ticket_id FROM t_ticket_mention WHERE mentioned_user_id = ?)", - operator.UserID, - operator.UserID, - ), - ), - Participating: s.Count( - sqls.NewCnd().Where("id IN (SELECT ticket_id FROM t_ticket_collaborator WHERE user_id = ?)", operator.UserID), - ), - Mentioned: s.Count( - sqls.NewCnd().Where("id IN (SELECT ticket_id FROM t_ticket_mention WHERE mentioned_user_id = ?)", operator.UserID), - ), - Unassigned: s.Count( - sqls.NewCnd(). - In("status", []enums.TicketStatus{ - enums.TicketStatusNew, - enums.TicketStatusOpen, - enums.TicketStatusPendingCustomer, - enums.TicketStatusPendingInternal, - }). - Eq("current_assignee_id", 0), - ), - PendingCustomer: s.Count( - sqls.NewCnd().Eq("status", enums.TicketStatusPendingCustomer), - ), - PendingInternal: s.Count( - sqls.NewCnd().Eq("status", enums.TicketStatusPendingInternal), - ), - Overdue: s.Count( - sqls.NewCnd(). - In("status", []enums.TicketStatus{ - enums.TicketStatusNew, - enums.TicketStatusOpen, - enums.TicketStatusPendingCustomer, - enums.TicketStatusPendingInternal, - }). - Where("resolve_deadline_at IS NOT NULL"). - Where("resolve_deadline_at < ?", now), - ), - } -} - -func (s *ticketService) GetRiskPageAggregate(riskType string, teamID int64, riskWindowMins int, page, limit int, watcherUserID int64) (*TicketListAggregate, error) { - cnd := s.buildRiskListCnd(riskType, teamID, riskWindowMins, page, limit) - list, paging := repositories.TicketRepository.FindPageByCnd(sqls.DB(), cnd) - return s.buildTicketListAggregate(sqls.DB(), list, paging, watcherUserID), nil -} - -func (s *ticketService) GetRiskOverview(teamID int64, riskWindowMins int) *TicketRiskOverviewAggregate { - if riskWindowMins <= 0 { - riskWindowMins = 240 - } - now := time.Now() - staleAt := now.Add(-24 * time.Hour) - activeBaseCnd := func() *sqls.Cnd { - cnd := sqls.NewCnd().In("status", []enums.TicketStatus{ - enums.TicketStatusNew, - enums.TicketStatusOpen, - enums.TicketStatusPendingCustomer, - enums.TicketStatusPendingInternal, - }) - if teamID > 0 { - cnd.Eq("current_team_id", teamID) - } - return cnd - } - pendingCustomerBaseCnd := func() *sqls.Cnd { - cnd := sqls.NewCnd().Eq("status", enums.TicketStatusPendingCustomer) - if teamID > 0 { - cnd.Eq("current_team_id", teamID) - } - return cnd - } - pendingInternalBaseCnd := func() *sqls.Cnd { - cnd := sqls.NewCnd().Eq("status", enums.TicketStatusPendingInternal) - if teamID > 0 { - cnd.Eq("current_team_id", teamID) - } - return cnd - } - - overview := &TicketRiskOverviewAggregate{ - Overdue: s.Count( - activeBaseCnd(). - Where("resolve_deadline_at IS NOT NULL"). - Where("resolve_deadline_at < ?", now), - ), - HighRisk: s.Count( - activeBaseCnd(). - Where("resolve_deadline_at IS NOT NULL"). - Where("resolve_deadline_at >= ?", now). - Where("resolve_deadline_at <= ?", now.Add(time.Duration(riskWindowMins)*time.Minute)), - ), - Unassigned: s.Count( - activeBaseCnd().Eq("current_assignee_id", 0), - ), - PendingInternal: s.Count(pendingInternalBaseCnd()), - PendingCustomer: s.Count(pendingCustomerBaseCnd()), - RiskWindowMins: riskWindowMins, - Reasons: []TicketRiskReasonAggregate{ - { - Code: "unassigned_active", - Title: "工单未分配", - Description: "仍处于活跃状态,但没有明确负责人的工单", - Count: s.Count( - activeBaseCnd().Eq("current_assignee_id", 0), - ), - }, - { - Code: "pending_internal_stale", - Title: "待内部处理滞留超过 24 小时", - Description: "内部协作未及时推进,容易形成长期积压", - Count: s.Count( - pendingInternalBaseCnd().Where("updated_at < ?", staleAt), - ), - }, - { - Code: "pending_customer_stale", - Title: "待客户反馈滞留超过 24 小时", - Description: "客户迟迟未补充信息,建议催办或关单", - Count: s.Count( - pendingCustomerBaseCnd().Where("updated_at < ?", staleAt), - ), - }, - { - Code: "active_without_deadline", - Title: "活跃工单未设置解决时限", - Description: "缺少 SLA 截止时间,主管无法有效盯防风险", - Count: s.Count( - activeBaseCnd().Where("resolve_deadline_at IS NULL"), - ), - }, - }, - } - return overview -} - -func (s *ticketService) buildRiskListCnd(riskType string, teamID int64, riskWindowMins int, page, limit int) *sqls.Cnd { - if riskWindowMins <= 0 { - riskWindowMins = 240 - } - if page <= 0 { - page = 1 - } - if limit <= 0 { - limit = 10 - } - now := time.Now() - cnd := sqls.NewCnd().Page(page, limit) - if teamID > 0 { - cnd.Eq("current_team_id", teamID) - } - switch strings.TrimSpace(riskType) { - case "overdue": - cnd.In("status", activeTicketStatuses()). - Where("resolve_deadline_at IS NOT NULL"). - Where("resolve_deadline_at < ?", now). - Asc("resolve_deadline_at"). - Asc("id") - case "high_risk": - cnd.In("status", activeTicketStatuses()). - Where("resolve_deadline_at IS NOT NULL"). - Where("resolve_deadline_at >= ?", now). - Where("resolve_deadline_at <= ?", now.Add(time.Duration(riskWindowMins)*time.Minute)). - Asc("resolve_deadline_at"). - Desc("priority"). - Desc("id") - case "unassigned": - cnd.In("status", activeTicketStatuses()). - Eq("current_assignee_id", 0). - Desc("updated_at"). - Desc("id") - case "pending_internal": - cnd.Eq("status", enums.TicketStatusPendingInternal). - Desc("updated_at"). - Desc("id") - case "pending_customer": - cnd.Eq("status", enums.TicketStatusPendingCustomer). - Desc("updated_at"). - Desc("id") - default: - cnd.In("status", activeTicketStatuses()). - Desc("updated_at"). - Desc("id") - } - return cnd -} - -func (s *ticketService) buildTicketListAggregate(db *gorm.DB, list []models.Ticket, paging *sqls.Paging, watcherUserID int64) *TicketListAggregate { - aggregate := &TicketListAggregate{ - List: list, - Paging: paging, - TagsByTicketID: make(map[int64][]models.Tag), - Priorities: make(map[int64]*models.TicketPriorityConfig), - ResolutionCodes: make(map[string]*models.TicketResolutionCode), - Users: make(map[int64]*models.User), - Teams: make(map[int64]*models.AgentTeam), - Customers: make(map[int64]*models.Customer), - SLAByTicketID: make(map[int64][]models.TicketSLARecord), - WatchedTicketIDs: make(map[int64]struct{}), - } - if len(list) == 0 { - return aggregate - } - - tagIDs := make([]int64, 0) - priorityIDs := make([]int64, 0) - customerIDs := make([]int64, 0) - teamIDs := make([]int64, 0) - userIDs := make([]int64, 0) - ticketIDs := make([]int64, 0, len(list)) - resolutionCodes := make([]string, 0) - - tagSeen := map[int64]struct{}{} - prioritySeen := map[int64]struct{}{} - customerSeen := map[int64]struct{}{} - teamSeen := map[int64]struct{}{} - userSeen := map[int64]struct{}{} - ticketSeen := map[int64]struct{}{} - codeSeen := map[string]struct{}{} - - for i := range list { - item := &list[i] - if _, ok := ticketSeen[item.ID]; !ok { - ticketSeen[item.ID] = struct{}{} - ticketIDs = append(ticketIDs, item.ID) - } - if item.Priority > 0 { - if _, ok := prioritySeen[item.Priority]; !ok { - prioritySeen[item.Priority] = struct{}{} - priorityIDs = append(priorityIDs, item.Priority) - } - } - if item.CustomerID > 0 { - if _, ok := customerSeen[item.CustomerID]; !ok { - customerSeen[item.CustomerID] = struct{}{} - customerIDs = append(customerIDs, item.CustomerID) - } - } - if item.CurrentTeamID > 0 { - if _, ok := teamSeen[item.CurrentTeamID]; !ok { - teamSeen[item.CurrentTeamID] = struct{}{} - teamIDs = append(teamIDs, item.CurrentTeamID) - } - } - if item.CurrentAssigneeID > 0 { - if _, ok := userSeen[item.CurrentAssigneeID]; !ok { - userSeen[item.CurrentAssigneeID] = struct{}{} - userIDs = append(userIDs, item.CurrentAssigneeID) - } - } - code := strings.TrimSpace(item.ResolutionCode) - if code != "" { - if _, ok := codeSeen[code]; !ok { - codeSeen[code] = struct{}{} - resolutionCodes = append(resolutionCodes, code) - } - } - } - - if len(ticketIDs) > 0 { - ticketTags := repositories.TicketTagRepository.Find(db, sqls.NewCnd().In("ticket_id", ticketIDs).Asc("id")) - if len(ticketTags) > 0 { - ticketTagMap := make(map[int64][]int64, len(ticketIDs)) - for i := range ticketTags { - relation := ticketTags[i] - ticketTagMap[relation.TicketID] = append(ticketTagMap[relation.TicketID], relation.TagID) - if _, ok := tagSeen[relation.TagID]; !ok { - tagSeen[relation.TagID] = struct{}{} - tagIDs = append(tagIDs, relation.TagID) - } - } - tags := repositories.TagRepository.Find(db, sqls.NewCnd().In("id", tagIDs)) - tagMap := make(map[int64]models.Tag, len(tags)) - for i := range tags { - tagMap[tags[i].ID] = tags[i] - } - for ticketID, orderedTagIDs := range ticketTagMap { - orderedTags := make([]models.Tag, 0, len(orderedTagIDs)) - for _, tagID := range orderedTagIDs { - if tag, ok := tagMap[tagID]; ok { - orderedTags = append(orderedTags, tag) - } - } - aggregate.TagsByTicketID[ticketID] = orderedTags - } - } - } - if len(priorityIDs) > 0 { - priorities := repositories.TicketPriorityConfigRepository.Find(db, sqls.NewCnd().In("id", priorityIDs)) - for i := range priorities { - item := priorities[i] - aggregate.Priorities[item.ID] = &item - } - } - if len(resolutionCodes) > 0 { - codeItems := repositories.TicketResolutionCodeRepository.Find(db, sqls.NewCnd().In("code", resolutionCodes).NotEq("status", enums.StatusDeleted)) - for i := range codeItems { - item := codeItems[i] - aggregate.ResolutionCodes[item.Code] = &item - } - } - users := repositories.UserRepository.FindByIds(db, userIDs) - for i := range users { - item := users[i] - aggregate.Users[item.ID] = &item - } - teams := repositories.AgentTeamRepository.FindByIds(db, teamIDs) - for i := range teams { - item := teams[i] - aggregate.Teams[item.ID] = &item - } - if len(customerIDs) > 0 { - customers := repositories.CustomerRepository.Find(db, sqls.NewCnd().In("id", customerIDs)) - for i := range customers { - item := customers[i] - aggregate.Customers[item.ID] = &item - } - } - if len(ticketIDs) > 0 { - slaRecords := repositories.TicketSLARecordRepository.Find(db, sqls.NewCnd().In("ticket_id", ticketIDs).Asc("id")) - for i := range slaRecords { - item := slaRecords[i] - aggregate.SLAByTicketID[item.TicketID] = append(aggregate.SLAByTicketID[item.TicketID], item) - } - } - if watcherUserID > 0 { - watchers := repositories.TicketWatcherRepository.Find(db, sqls.NewCnd().Eq("user_id", watcherUserID).In("ticket_id", ticketIDs)) - for i := range watchers { - aggregate.WatchedTicketIDs[watchers[i].TicketID] = struct{}{} - } - } - return aggregate + return ordered } func (s *ticketService) CreateTicket(req request.CreateTicketRequest, operator *dto.AuthPrincipal) (*models.Ticket, error) { if operator == nil { return nil, errorsx.Unauthorized("未登录或登录已过期") } - title := strings.TrimSpace(req.Title) + description := strings.TrimSpace(req.Description) if title == "" { return nil, errorsx.InvalidParam("工单标题不能为空") } - - if req.CustomerID > 0 && CustomerService.Get(req.CustomerID) == nil { - return nil, errorsx.InvalidParam("客户不存在") + if description == "" { + return nil, errorsx.InvalidParam("工单描述不能为空") } - if req.ConversationID > 0 && ConversationService.Get(req.ConversationID) == nil { - return nil, errorsx.InvalidParam("会话不存在") - } - tagIDs, err := TicketTagService.ValidateTagIDs(req.TagIDs) - if err != nil { - return nil, err - } - source := enums.TicketSource(strings.TrimSpace(req.Source)) if source == "" { source = enums.TicketSourceManual @@ -747,62 +156,24 @@ func (s *ticketService) CreateTicket(req request.CreateTicketRequest, operator * if !enums.IsValidTicketSource(string(source)) { return nil, errorsx.InvalidParam("工单来源不合法") } - - priority := req.Priority - if priority == 0 { - defaultPriority := TicketPriorityConfigService.GetDefaultActive() - if defaultPriority == nil { - return nil, errorsx.InvalidParam("请先配置启用中的工单优先级") - } - priority = defaultPriority.ID + if err := s.validateTicketRefs(req.CustomerID, req.ConversationID, req.CurrentAssigneeID); err != nil { + return nil, err } - priorityConfig := TicketPriorityConfigService.Get(priority) - if priorityConfig == nil || priorityConfig.Status != enums.StatusOk { - return nil, errorsx.InvalidParam("工单优先级不存在") - } - - severity := enums.TicketSeverity(req.Severity) - if req.Severity == 0 { - severity = enums.TicketSeverityMinor - } - if !enums.IsValidTicketSeverity(int(severity)) { - return nil, errorsx.InvalidParam("工单严重度不合法") - } - - teamID, assigneeID, err := s.normalizeAssignment(req.CurrentTeamID, req.CurrentAssigneeID) + tagIDs, err := TicketTagService.ValidateTagIDs(req.TagIDs) if err != nil { return nil, err } - dueAt, err := parseOptionalDateTime(req.DueAt) - if err != nil { - return nil, errorsx.InvalidParam("截止时间格式不合法") - } - customFieldsJSON, err := marshalJSON(req.CustomFields) - if err != nil { - return nil, errorsx.InvalidParam("自定义字段格式不合法") - } - now := time.Now() - status := enums.TicketStatusNew - if assigneeID > 0 { - status = enums.TicketStatusOpen - } ticket := &models.Ticket{ Title: title, - Description: strings.TrimSpace(req.Description), + Description: description, Source: source, Channel: strings.TrimSpace(req.Channel), CustomerID: req.CustomerID, ConversationID: req.ConversationID, - Type: strings.TrimSpace(req.Type), - Priority: priority, - Severity: severity, - Status: status, - CurrentTeamID: teamID, - CurrentAssigneeID: assigneeID, - DueAt: dueAt, - CustomFieldsJSON: customFieldsJSON, + Status: enums.TicketStatusPending, + CurrentAssigneeID: req.CurrentAssigneeID, AuditFields: utils.BuildAuditFields(operator), } ticket.UpdatedAt = now @@ -819,27 +190,21 @@ func (s *ticketService) CreateTicket(req request.CreateTicketRequest, operator * if err := TicketTagService.ReplaceTicketTags(ctx.Tx, ticket.ID, tagIDs, operator); err != nil { return err } - if err := s.initSLAs(ctx.Tx, ticket, now); err != nil { - return err - } - if err := s.logEvent(ctx.Tx, ticket.ID, enums.TicketEventTypeCreated, operator, "", string(ticket.Status), "创建工单", ""); err != nil { - return err - } - if ticket.ConversationID > 0 { - if err := ConversationEventLogService.CreateEvent(ctx, ticket.ConversationID, enums.IMEventTypeMessageSend, enums.IMSenderTypeAgent, operator.UserID, fmt.Sprintf("已创建工单 %s", ticket.TicketNo), ""); err != nil { - return err - } - } - return nil + return repositories.TicketProgressRepository.Create(ctx.Tx, &models.TicketProgress{ + TicketID: ticket.ID, + Content: "创建工单", + AuthorID: operator.UserID, + CreatedAt: now, + }) }); err != nil { return nil, err } - current := s.Get(ticket.ID) + eventbus.PublishAsync(context.Background(), events.TicketCreatedEvent{ TicketID: ticket.ID, OperatorID: operator.UserID, }) - return current, nil + return s.Get(ticket.ID), nil } func (s *ticketService) CreateFromConversation(req request.CreateTicketFromConversationRequest, operator *dto.AuthPrincipal) (*models.Ticket, error) { @@ -852,13 +217,19 @@ func (s *ticketService) CreateFromConversation(req request.CreateTicketFromConve } title := strings.TrimSpace(req.Title) if title == "" { - title = ConversationService.BuildConversationSummary(conversation) + title = strings.TrimSpace(ConversationService.BuildConversationSummary(conversation)) + } + if title == "" { + title = "会话工单" } description := strings.TrimSpace(req.Description) if description == "" { description = strings.TrimSpace(conversation.LastMessageSummary) } - item, err := s.CreateTicket(request.CreateTicketRequest{ + if description == "" { + description = title + } + return s.CreateTicket(request.CreateTicketRequest{ Title: title, Description: description, Source: string(enums.TicketSourceConversation), @@ -866,129 +237,53 @@ func (s *ticketService) CreateFromConversation(req request.CreateTicketFromConve CustomerID: conversation.CustomerID, ConversationID: conversation.ID, TagIDs: req.TagIDs, - Priority: req.Priority, - Severity: req.Severity, - CurrentTeamID: req.CurrentTeamID, CurrentAssigneeID: req.CurrentAssigneeID, - CustomFields: req.CustomFields, }, operator) - if err != nil { - return nil, err - } - if req.SyncToConversation { - _ = sqls.WithTransaction(func(ctx *sqls.TxContext) error { - return ConversationEventLogService.CreateEvent(ctx, conversation.ID, enums.IMEventTypeMessageSend, enums.IMSenderTypeAgent, operator.UserID, fmt.Sprintf("会话已转为工单 %s", item.TicketNo), "") - }) - } - return item, nil } func (s *ticketService) UpdateTicket(req request.UpdateTicketRequest, operator *dto.AuthPrincipal) error { if operator == nil { return errorsx.Unauthorized("未登录或登录已过期") } + title := strings.TrimSpace(req.Title) + description := strings.TrimSpace(req.Description) + if title == "" { + return errorsx.InvalidParam("工单标题不能为空") + } + if description == "" { + return errorsx.InvalidParam("工单描述不能为空") + } ticket := s.Get(req.TicketID) if ticket == nil { return errorsx.InvalidParam("工单不存在") } - if !s.isEditableStatus(ticket.Status) { - return errorsx.InvalidParam("工单当前状态不允许编辑") - } - title := strings.TrimSpace(req.Title) - if title == "" { - return errorsx.InvalidParam("工单标题不能为空") - } - priorityConfig := TicketPriorityConfigService.Get(req.Priority) - if priorityConfig == nil || priorityConfig.Status != enums.StatusOk { - return errorsx.InvalidParam("工单优先级不存在") - } - if !enums.IsValidTicketSeverity(req.Severity) { - return errorsx.InvalidParam("工单严重度不合法") + if err := s.validateAssignee(req.CurrentAssigneeID); err != nil { + return err } tagIDs, err := TicketTagService.ValidateTagIDs(req.TagIDs) if err != nil { return err } - teamID, assigneeID, err := s.normalizeAssignment(req.CurrentTeamID, req.CurrentAssigneeID) - if err != nil { - return err - } - dueAt, err := parseOptionalDateTime(req.DueAt) - if err != nil { - return errorsx.InvalidParam("截止时间格式不合法") - } - customFieldsJSON, err := marshalJSON(req.CustomFields) - if err != nil { - return errorsx.InvalidParam("自定义字段格式不合法") - } + now := time.Now() return sqls.WithTransaction(func(ctx *sqls.TxContext) error { if err := repositories.TicketRepository.Updates(ctx.Tx, ticket.ID, map[string]any{ "title": title, - "description": strings.TrimSpace(req.Description), - "type": strings.TrimSpace(req.Type), - "priority": req.Priority, - "severity": req.Severity, - "current_team_id": teamID, - "current_assignee_id": assigneeID, - "due_at": dueAt, - "custom_fields_json": customFieldsJSON, + "description": description, + "current_assignee_id": req.CurrentAssigneeID, + "updated_at": now, "update_user_id": operator.UserID, "update_user_name": operator.Username, - "updated_at": time.Now(), }); err != nil { return err } - if err := TicketTagService.ReplaceTicketTags(ctx.Tx, ticket.ID, tagIDs, operator); err != nil { - return err - } - return s.logEvent(ctx.Tx, ticket.ID, enums.TicketEventTypeUpdated, operator, "", "", "更新工单信息", "") - }) -} - -func (s *ticketService) LinkTicketCustomer(ticketID, customerID int64, operator *dto.AuthPrincipal) error { - if operator == nil { - return errorsx.Unauthorized("未登录或登录已过期") - } - if ticketID <= 0 || customerID <= 0 { - return errorsx.InvalidParam("参数不合法") - } - ticket := repositories.TicketRepository.Get(sqls.DB(), ticketID) - if ticket == nil { - return errorsx.InvalidParam("工单不存在") - } - customer := CustomerService.Get(customerID) - if customer == nil || customer.Status == enums.StatusDeleted { - return errorsx.InvalidParam("客户不存在") - } - if ticket.CustomerID == customerID { - return nil - } - - oldCustomerID := ticket.CustomerID - return sqls.WithTransaction(func(ctx *sqls.TxContext) error { - now := time.Now() - if err := repositories.TicketRepository.Updates(ctx.Tx, ticketID, map[string]any{ - "customer_id": customerID, - "update_user_id": operator.UserID, - "update_user_name": operator.Username, - "updated_at": now, - }); err != nil { - return err - } - return s.logEvent( - ctx.Tx, - ticketID, - enums.TicketEventTypeUpdated, - operator, - fmt.Sprintf("%d", oldCustomerID), - fmt.Sprintf("%d", customerID), - fmt.Sprintf("关联客户:%s", strings.TrimSpace(customer.Name)), - "", - ) + return TicketTagService.ReplaceTicketTags(ctx.Tx, ticket.ID, tagIDs, operator) }) } func (s *ticketService) AssignTicket(req request.AssignTicketRequest, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.Unauthorized("未登录或登录已过期") + } var assignedEvent *events.TicketAssignedEvent if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { event, err := s.assignTicketTx(ctx.Tx, req, operator) @@ -1006,477 +301,11 @@ func (s *ticketService) AssignTicket(req request.AssignTicketRequest, operator * return nil } -func (s *ticketService) assignTicketTx(tx *gorm.DB, req request.AssignTicketRequest, operator *dto.AuthPrincipal) (*events.TicketAssignedEvent, error) { - if operator == nil { - return nil, errorsx.Unauthorized("未登录或登录已过期") - } - ticket := repositories.TicketRepository.Get(tx, req.TicketID) - if ticket == nil { - return nil, errorsx.InvalidParam("工单不存在") - } - teamID, assigneeID, err := s.normalizeAssignmentTx(tx, req.ToTeamID, req.ToUserID) - if err != nil { - return nil, err - } - if assigneeID <= 0 { - return nil, errorsx.InvalidParam("目标处理人不能为空") - } - now := time.Now() - eventType := enums.TicketEventTypeAssigned - content := "指派工单" - if ticket.CurrentAssigneeID > 0 && ticket.CurrentAssigneeID != assigneeID { - eventType = enums.TicketEventTypeTransferred - content = "转派工单" - } - status := ticket.Status - if status == enums.TicketStatusNew { - status = enums.TicketStatusOpen - } - if err := repositories.TicketRepository.Updates(tx, ticket.ID, map[string]any{ - "current_team_id": teamID, - "current_assignee_id": assigneeID, - "status": status, - "update_user_id": operator.UserID, - "update_user_name": operator.Username, - "updated_at": now, - }); err != nil { - return nil, err - } - if err := s.logEvent(tx, ticket.ID, eventType, operator, fmt.Sprintf("%d", ticket.CurrentAssigneeID), fmt.Sprintf("%d", assigneeID), strings.TrimSpace(content), strings.TrimSpace(req.Reason)); err != nil { - return nil, err - } - return &events.TicketAssignedEvent{ - TicketID: ticket.ID, - FromUserID: ticket.CurrentAssigneeID, - ToUserID: assigneeID, - OperatorID: operator.UserID, - Reason: strings.TrimSpace(req.Reason), - }, nil -} - -func (s *ticketService) ChangeStatus(req request.ChangeTicketStatusRequest, operator *dto.AuthPrincipal) error { - return sqls.WithTransaction(func(ctx *sqls.TxContext) error { - return s.changeStatusTx(ctx.Tx, req, operator) - }) -} - -func (s *ticketService) changeStatusTx(tx *gorm.DB, req request.ChangeTicketStatusRequest, operator *dto.AuthPrincipal) error { - if operator == nil { - return errorsx.Unauthorized("未登录或登录已过期") - } - ticket := repositories.TicketRepository.Get(tx, req.TicketID) - if ticket == nil { - return errorsx.InvalidParam("工单不存在") - } - targetStatus := enums.TicketStatus(strings.TrimSpace(req.Status)) - if !enums.IsValidTicketStatus(string(targetStatus)) { - return errorsx.InvalidParam("工单状态不合法") - } - if !s.canTransition(ticket.Status, targetStatus) { - return errorsx.InvalidParam("工单当前状态不允许该操作") - } - if targetStatus == enums.TicketStatusClosed { - if childNos := s.findOpenChildTicketNosTx(tx, ticket.ID); len(childNos) > 0 { - if len(childNos) > 3 { - return errorsx.InvalidParam(fmt.Sprintf("仍有未完成子工单,暂不可关闭:%s 等 %d 张", strings.Join(childNos[:3], "、"), len(childNos))) - } - return errorsx.InvalidParam(fmt.Sprintf("仍有未完成子工单,暂不可关闭:%s", strings.Join(childNos, "、"))) - } - } - now := time.Now() - values := map[string]any{ - "status": targetStatus, - "pending_reason": "", - "close_reason": "", - "resolution_code": "", - "resolution_summary": "", - "update_user_id": operator.UserID, - "update_user_name": operator.Username, - "updated_at": now, - } - switch targetStatus { - case enums.TicketStatusPendingCustomer, enums.TicketStatusPendingInternal: - values["pending_reason"] = strings.TrimSpace(req.PendingReason) - case enums.TicketStatusResolved: - resolutionCode := strings.TrimSpace(req.ResolutionCode) - if resolutionCode != "" { - code := repositories.TicketResolutionCodeRepository.Take(tx, "code = ? AND status = ?", resolutionCode, enums.StatusOk) - if code == nil { - return errorsx.InvalidParam("解决码不存在") - } - } - values["resolution_code"] = resolutionCode - values["resolution_summary"] = strings.TrimSpace(req.ResolutionSummary) - values["resolved_at"] = now - case enums.TicketStatusClosed: - closeReason := strings.TrimSpace(req.CloseReason) - if closeReason == "" { - return errorsx.InvalidParam("关闭原因不能为空") - } - values["close_reason"] = closeReason - values["closed_at"] = now - case enums.TicketStatusOpen: - values["resolved_at"] = nil - values["closed_at"] = nil - } - if err := repositories.TicketRepository.Updates(tx, ticket.ID, values); err != nil { - return err - } - if err := s.applySLAOnStatusChange(tx, ticket.ID, ticket.Status, targetStatus, now); err != nil { - return err - } - if err := s.refreshTicketSLAFields(tx, ticket.ID, now); err != nil { - return err - } - return s.logEvent(tx, ticket.ID, enums.TicketEventTypeStatusChanged, operator, string(ticket.Status), string(targetStatus), strings.TrimSpace(req.Reason), "") -} - -func (s *ticketService) findOpenChildTicketNos(ticketID int64) []string { - return s.findOpenChildTicketNosTx(sqls.DB(), ticketID) -} - -func (s *ticketService) findOpenChildTicketNosTx(db *gorm.DB, ticketID int64) []string { - relations := repositories.TicketRelationRepository.Find(db, - sqls.NewCnd(). - Eq("ticket_id", ticketID). - Eq("relation_type", enums.TicketRelationTypeChild), - ) - if len(relations) == 0 { - return nil - } - results := make([]string, 0, len(relations)) - for _, relation := range relations { - child := repositories.TicketRepository.Get(db, relation.RelatedTicketID) - if child == nil { - continue - } - switch child.Status { - case enums.TicketStatusResolved, enums.TicketStatusClosed, enums.TicketStatusCancelled: - continue - default: - if child.TicketNo != "" { - results = append(results, child.TicketNo) - } else { - results = append(results, fmt.Sprintf("#%d", child.ID)) - } - } - } - return results -} - -func (s *ticketService) ReplyTicket(req request.ReplyTicketRequest, operator *dto.AuthPrincipal) (*models.TicketComment, error) { - if operator == nil { - return nil, errorsx.Unauthorized("未登录或登录已过期") - } - ticket := s.Get(req.TicketID) - if ticket == nil { - return nil, errorsx.InvalidParam("工单不存在") - } - if ticket.Status == enums.TicketStatusClosed || ticket.Status == enums.TicketStatusCancelled { - return nil, errorsx.InvalidParam("当前工单状态不允许回复") - } - content := strings.TrimSpace(req.Content) - if content == "" { - return nil, errorsx.InvalidParam("回复内容不能为空") - } - now := time.Now() - comment := &models.TicketComment{ - TicketID: ticket.ID, - CommentType: enums.TicketCommentTypePublicReply, - AuthorType: enums.IMSenderTypeAgent, - AuthorID: operator.UserID, - ContentType: strings.TrimSpace(req.ContentType), - Content: content, - Payload: strings.TrimSpace(req.Payload), - CreatedAt: now, - } - if comment.ContentType == "" { - comment.ContentType = "text" - } - err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { - if err := repositories.TicketCommentRepository.Create(ctx.Tx, comment); err != nil { - return err - } - updateValues := map[string]any{ - "update_user_id": operator.UserID, - "update_user_name": operator.Username, - "updated_at": now, - } - if ticket.FirstResponseAt == nil { - updateValues["first_response_at"] = now - } - if ticket.Status == enums.TicketStatusPendingCustomer || ticket.Status == enums.TicketStatusNew { - updateValues["status"] = enums.TicketStatusOpen - updateValues["pending_reason"] = "" - } - if err := repositories.TicketRepository.Updates(ctx.Tx, ticket.ID, updateValues); err != nil { - return err - } - if err := s.completeFirstResponseSLA(ctx.Tx, ticket.ID, now); err != nil { - return err - } - if err := s.refreshTicketSLAFields(ctx.Tx, ticket.ID, now); err != nil { - return err - } - return s.logEvent(ctx.Tx, ticket.ID, enums.TicketEventTypeReplied, operator, "", "", "回复客户", "") - }) - if err != nil { - return nil, err - } - return comment, nil -} - -func (s *ticketService) AddInternalNote(req request.InternalNoteRequest, operator *dto.AuthPrincipal) (*models.TicketComment, error) { - if operator == nil { - return nil, errorsx.Unauthorized("未登录或登录已过期") - } - ticket := s.Get(req.TicketID) - if ticket == nil { - return nil, errorsx.InvalidParam("工单不存在") - } - content := strings.TrimSpace(req.Content) - if content == "" { - return nil, errorsx.InvalidParam("备注内容不能为空") - } - comment := &models.TicketComment{ - TicketID: ticket.ID, - CommentType: enums.TicketCommentTypeInternalNote, - AuthorType: enums.IMSenderTypeAgent, - AuthorID: operator.UserID, - ContentType: strings.TrimSpace(req.ContentType), - Content: content, - Payload: strings.TrimSpace(req.Payload), - CreatedAt: time.Now(), - } - if comment.ContentType == "" { - comment.ContentType = "text" - } - notePayload := parseTicketNotePayload(comment.Payload) - err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { - if err := repositories.TicketCommentRepository.Create(ctx.Tx, comment); err != nil { - return err - } - mentionedNames := make([]string, 0, len(notePayload.MentionUserIDs)) - for _, userID := range notePayload.MentionUserIDs { - if userID <= 0 || userID == operator.UserID { - continue - } - if user := repositories.UserRepository.Get(ctx.Tx, userID); user != nil { - name := user.Nickname - if name == "" { - name = user.Username - } - if name != "" { - mentionedNames = append(mentionedNames, name) - } - } - if repositories.TicketCollaboratorRepository.TakeByTicketIDAndUserID(ctx.Tx, ticket.ID, userID) == nil { - if err := repositories.TicketCollaboratorRepository.Create(ctx.Tx, &models.TicketCollaborator{ - TicketID: ticket.ID, - UserID: userID, - CreatedAt: time.Now(), - }); err != nil { - return err - } - } - if repositories.TicketWatcherRepository.TakeByTicketIDAndUserID(ctx.Tx, ticket.ID, userID) == nil { - if err := repositories.TicketWatcherRepository.Create(ctx.Tx, &models.TicketWatcher{ - TicketID: ticket.ID, - UserID: userID, - CreatedAt: time.Now(), - }); err != nil { - return err - } - } - if repositories.TicketMentionRepository.TakeByCommentAndUserID(ctx.Tx, ticket.ID, comment.ID, userID) == nil { - if err := repositories.TicketMentionRepository.Create(ctx.Tx, &models.TicketMention{ - TicketID: ticket.ID, - CommentID: comment.ID, - MentionedUserID: userID, - CreatedAt: time.Now(), - }); err != nil { - return err - } - } - } - if err := repositories.TicketRepository.Updates(ctx.Tx, ticket.ID, map[string]any{ - "update_user_id": operator.UserID, - "update_user_name": operator.Username, - "updated_at": time.Now(), - }); err != nil { - return err - } - if err := s.logEvent(ctx.Tx, ticket.ID, enums.TicketEventTypeInternalNoted, operator, "", "", "添加内部备注", ""); err != nil { - return err - } - if len(mentionedNames) > 0 { - return s.logEvent(ctx.Tx, ticket.ID, enums.TicketEventTypeMentioned, operator, "", "", fmt.Sprintf("提及协作人:%s", strings.Join(mentionedNames, "、")), comment.Payload) - } - return nil - }) - if err != nil { - return nil, err - } - return comment, nil -} - -func (s *ticketService) CloseTicket(req request.CloseTicketRequest, operator *dto.AuthPrincipal) error { - return s.ChangeStatus(request.ChangeTicketStatusRequest{ - TicketID: req.TicketID, - Status: string(enums.TicketStatusClosed), - CloseReason: req.CloseReason, - Reason: "关闭工单", - }, operator) -} - -func (s *ticketService) ReopenTicket(req request.ReopenTicketRequest, operator *dto.AuthPrincipal) error { - if operator == nil { - return errorsx.Unauthorized("未登录或登录已过期") - } - ticket := s.Get(req.TicketID) - if ticket == nil { - return errorsx.InvalidParam("工单不存在") - } - if ticket.Status != enums.TicketStatusClosed && ticket.Status != enums.TicketStatusResolved { - return errorsx.InvalidParam("当前工单状态不允许重开") - } - now := time.Now() - return sqls.WithTransaction(func(ctx *sqls.TxContext) error { - if err := repositories.TicketRepository.Updates(ctx.Tx, ticket.ID, map[string]any{ - "status": enums.TicketStatusOpen, - "close_reason": "", - "closed_at": nil, - "resolved_at": nil, - "reopened_count": ticket.ReopenedCount + 1, - "update_user_id": operator.UserID, - "update_user_name": operator.Username, - "updated_at": now, - }); err != nil { - return err - } - if err := s.resumeResolutionSLA(ctx.Tx, ticket.ID, now); err != nil { - return err - } - if err := s.refreshTicketSLAFields(ctx.Tx, ticket.ID, now); err != nil { - return err - } - return s.logEvent(ctx.Tx, ticket.ID, enums.TicketEventTypeReopened, operator, string(ticket.Status), string(enums.TicketStatusOpen), strings.TrimSpace(req.Reason), "") - }) -} - -func (s *ticketService) WatchTicket(ticketID int64, operator *dto.AuthPrincipal) error { - if operator == nil { - return errorsx.Unauthorized("未登录或登录已过期") - } - ticket := s.Get(ticketID) - if ticket == nil { - return errorsx.InvalidParam("工单不存在") - } - existing := TicketWatcherService.FindOne(sqls.NewCnd(). - Eq("ticket_id", ticketID). - Eq("user_id", operator.UserID)) - if existing != nil { - return nil - } - return repositories.TicketWatcherRepository.Create(sqls.DB(), &models.TicketWatcher{ - TicketID: ticketID, - UserID: operator.UserID, - CreatedAt: time.Now(), - }) -} - -func (s *ticketService) UnwatchTicket(ticketID int64, operator *dto.AuthPrincipal) error { - if operator == nil { - return errorsx.Unauthorized("未登录或登录已过期") - } - ticket := s.Get(ticketID) - if ticket == nil { - return errorsx.InvalidParam("工单不存在") - } - existing := TicketWatcherService.FindOne(sqls.NewCnd(). - Eq("ticket_id", ticketID). - Eq("user_id", operator.UserID)) - if existing == nil { - return nil - } - repositories.TicketWatcherRepository.Delete(sqls.DB(), existing.ID) - return nil -} - -func (s *ticketService) AddCollaborator(ticketID, userID int64, operator *dto.AuthPrincipal) error { - if operator == nil { - return errorsx.Unauthorized("未登录或登录已过期") - } - ticket := s.Get(ticketID) - if ticket == nil { - return errorsx.InvalidParam("工单不存在") - } - if userID <= 0 { - return errorsx.InvalidParam("协作人不能为空") - } - profile := AgentProfileService.GetByUserID(userID) - if profile == nil || profile.Status != enums.StatusOk { - return errorsx.InvalidParam("协作人不存在") - } - if TicketCollaboratorService.FindOne(sqls.NewCnd().Eq("ticket_id", ticketID).Eq("user_id", userID)) != nil { - return nil - } - now := time.Now() - userName := "" - if user := UserService.Get(userID); user != nil { - userName = user.Nickname - if userName == "" { - userName = user.Username - } - } - return sqls.WithTransaction(func(ctx *sqls.TxContext) error { - if err := repositories.TicketCollaboratorRepository.Create(ctx.Tx, &models.TicketCollaborator{ - TicketID: ticketID, - UserID: userID, - CreatedAt: now, - }); err != nil { - return err - } - if TicketWatcherService.FindOne(sqls.NewCnd().Eq("ticket_id", ticketID).Eq("user_id", userID)) == nil { - if err := repositories.TicketWatcherRepository.Create(ctx.Tx, &models.TicketWatcher{ - TicketID: ticketID, - UserID: userID, - CreatedAt: now, - }); err != nil { - return err - } - } - return s.logEvent(ctx.Tx, ticketID, enums.TicketEventTypeUpdated, operator, "", "", fmt.Sprintf("新增协作人:%s", userName), "") - }) -} - -func (s *ticketService) RemoveCollaborator(ticketID, collaboratorID int64, operator *dto.AuthPrincipal) error { - if operator == nil { - return errorsx.Unauthorized("未登录或登录已过期") - } - ticket := s.Get(ticketID) - if ticket == nil { - return errorsx.InvalidParam("工单不存在") - } - item := TicketCollaboratorService.Get(collaboratorID) - if item == nil || item.TicketID != ticketID { - return errorsx.InvalidParam("协作关系不存在") - } - userName := "" - if user := UserService.Get(item.UserID); user != nil { - userName = user.Nickname - if userName == "" { - userName = user.Username - } - } - return sqls.WithTransaction(func(ctx *sqls.TxContext) error { - repositories.TicketCollaboratorRepository.Delete(ctx.Tx, collaboratorID) - return s.logEvent(ctx.Tx, ticketID, enums.TicketEventTypeUpdated, operator, "", "", fmt.Sprintf("移除协作人:%s", userName), "") - }) -} - func (s *ticketService) BatchAssignTickets(req request.BatchAssignTicketRequest, operator *dto.AuthPrincipal) error { - ticketIDs := normalizeBatchTicketIDs(req.TicketIDs) + if operator == nil { + return errorsx.Unauthorized("未登录或登录已过期") + } + ticketIDs := normalizeInt64IDs(req.TicketIDs) if len(ticketIDs) == 0 { return errorsx.InvalidParam("请选择工单") } @@ -1486,7 +315,6 @@ func (s *ticketService) BatchAssignTickets(req request.BatchAssignTicketRequest, event, err := s.assignTicketTx(ctx.Tx, request.AssignTicketRequest{ TicketID: ticketID, ToUserID: req.ToUserID, - ToTeamID: req.ToTeamID, Reason: req.Reason, }, operator) if err != nil { @@ -1500,349 +328,269 @@ func (s *ticketService) BatchAssignTickets(req request.BatchAssignTicketRequest, }); err != nil { return err } - for _, event := range assignedEvents { - eventbus.PublishAsync(context.Background(), event) + for i := range assignedEvents { + eventbus.PublishAsync(context.Background(), assignedEvents[i]) } return nil } -func (s *ticketService) BatchChangeStatus(req request.BatchChangeTicketStatusRequest, operator *dto.AuthPrincipal) error { - ticketIDs := normalizeBatchTicketIDs(req.TicketIDs) - if len(ticketIDs) == 0 { - return errorsx.InvalidParam("请选择工单") +func (s *ticketService) ChangeStatus(req request.ChangeTicketStatusRequest, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.Unauthorized("未登录或登录已过期") } - return sqls.WithTransaction(func(ctx *sqls.TxContext) error { - for _, ticketID := range ticketIDs { - if err := s.changeStatusTx(ctx.Tx, request.ChangeTicketStatusRequest{ - TicketID: ticketID, - Status: req.Status, - PendingReason: req.PendingReason, - CloseReason: req.CloseReason, - ResolutionCode: req.ResolutionCode, - ResolutionSummary: req.ResolutionSummary, - Reason: req.Reason, - }, operator); err != nil { - return err - } - } - return nil - }) -} - -func (s *ticketService) BatchWatchTickets(req request.BatchWatchTicketRequest, operator *dto.AuthPrincipal) error { - ticketIDs := normalizeBatchTicketIDs(req.TicketIDs) - if len(ticketIDs) == 0 { - return errorsx.InvalidParam("请选择工单") + status := strings.TrimSpace(req.Status) + if !enums.IsValidTicketStatus(status) { + return errorsx.InvalidParam("工单状态不合法") } - for _, ticketID := range ticketIDs { - var err error - if req.Watched { - err = s.WatchTicket(ticketID, operator) - } else { - err = s.UnwatchTicket(ticketID, operator) - } - if err != nil { - return err - } - } - return nil -} - -func normalizeBatchTicketIDs(ticketIDs []int64) []int64 { - if len(ticketIDs) == 0 { - return nil - } - results := make([]int64, 0, len(ticketIDs)) - exists := make(map[int64]struct{}, len(ticketIDs)) - for _, ticketID := range ticketIDs { - if ticketID <= 0 { - continue - } - if _, ok := exists[ticketID]; ok { - continue - } - exists[ticketID] = struct{}{} - results = append(results, ticketID) - } - return results -} - -func (s *ticketService) normalizeAssignment(teamID, assigneeID int64) (int64, int64, error) { - return s.normalizeAssignmentTx(sqls.DB(), teamID, assigneeID) -} - -func (s *ticketService) normalizeAssignmentTx(db *gorm.DB, teamID, assigneeID int64) (int64, int64, error) { - if assigneeID > 0 { - profile := repositories.AgentProfileRepository.FindOne(db, sqls.NewCnd().Eq("user_id", assigneeID)) - if profile == nil || profile.Status != enums.StatusOk { - return 0, 0, errorsx.InvalidParam("目标处理人不存在") - } - if teamID <= 0 { - teamID = profile.TeamID - } - } - if teamID > 0 { - team := repositories.AgentTeamRepository.Get(db, teamID) - if team == nil || team.Status != enums.StatusOk { - return 0, 0, errorsx.InvalidParam("处理团队不存在") - } - } - return teamID, assigneeID, nil -} - -func (s *ticketService) initSLAs(tx *gorm.DB, ticket *models.Ticket, now time.Time) error { - priorityConfig := repositories.TicketPriorityConfigRepository.Get(tx, ticket.Priority) - if priorityConfig == nil || priorityConfig.Status != enums.StatusOk { - return errorsx.InvalidParam("工单优先级不存在") - } - records := []*models.TicketSLARecord{ - { - TicketID: ticket.ID, - SLAType: enums.TicketSLATypeFirstResponse, - TargetMinutes: priorityConfig.FirstResponseMinutes, - Status: enums.TicketSLAStatusRunning, - StartedAt: &now, - CreatedAt: now, - UpdatedAt: now, - }, - { - TicketID: ticket.ID, - SLAType: enums.TicketSLATypeResolution, - TargetMinutes: priorityConfig.ResolutionMinutes, - Status: enums.TicketSLAStatusRunning, - StartedAt: &now, - CreatedAt: now, - UpdatedAt: now, - }, - } - for _, item := range records { - if err := repositories.TicketSLARecordRepository.Create(tx, item); err != nil { - return err - } - } - return s.refreshTicketSLAFields(tx, ticket.ID, now) -} - -func (s *ticketService) logEvent(tx *gorm.DB, ticketID int64, eventType enums.TicketEventType, operator *dto.AuthPrincipal, oldValue, newValue, content, payload string) error { - operatorID := int64(0) - if operator != nil { - operatorID = operator.UserID - } - return repositories.TicketEventLogRepository.Create(tx, &models.TicketEventLog{ - TicketID: ticketID, - EventType: eventType, - OperatorType: enums.IMSenderTypeAgent, - OperatorID: operatorID, - OldValue: strings.TrimSpace(oldValue), - NewValue: strings.TrimSpace(newValue), - Content: strings.TrimSpace(content), - Payload: strings.TrimSpace(payload), - CreatedAt: time.Now(), - }) -} - -func (s *ticketService) completeFirstResponseSLA(tx *gorm.DB, ticketID int64, now time.Time) error { - record := repositories.TicketSLARecordRepository.TakeByTicketIDAndType(tx, ticketID, string(enums.TicketSLATypeFirstResponse)) - if record == nil || record.Status == enums.TicketSLAStatusCompleted { - return nil - } - elapsed := diffMinutes(record.StartedAt, now) - return repositories.TicketSLARecordRepository.Updates(tx, record.ID, map[string]any{ - "status": enums.TicketSLAStatusCompleted, - "stopped_at": now, - "elapsed_min": elapsed, - "updated_at": now, - }) -} - -func (s *ticketService) applySLAOnStatusChange(tx *gorm.DB, ticketID int64, fromStatus, toStatus enums.TicketStatus, now time.Time) error { - switch toStatus { - case enums.TicketStatusPendingCustomer: - return s.pauseResolutionSLA(tx, ticketID, now) - case enums.TicketStatusOpen: - return s.resumeResolutionSLA(tx, ticketID, now) - case enums.TicketStatusResolved, enums.TicketStatusClosed, enums.TicketStatusCancelled: - return s.completeResolutionSLA(tx, ticketID, now) - default: - if fromStatus == enums.TicketStatusPendingCustomer && toStatus != enums.TicketStatusPendingCustomer { - return s.resumeResolutionSLA(tx, ticketID, now) - } - } - return nil -} - -func (s *ticketService) pauseResolutionSLA(tx *gorm.DB, ticketID int64, now time.Time) error { - record := repositories.TicketSLARecordRepository.TakeByTicketIDAndType(tx, ticketID, string(enums.TicketSLATypeResolution)) - if record == nil || record.Status != enums.TicketSLAStatusRunning { - return nil - } - elapsed := record.ElapsedMin + diffMinutes(record.StartedAt, now) - return repositories.TicketSLARecordRepository.Updates(tx, record.ID, map[string]any{ - "status": enums.TicketSLAStatusPaused, - "paused_at": now, - "elapsed_min": elapsed, - "updated_at": now, - }) -} - -func (s *ticketService) resumeResolutionSLA(tx *gorm.DB, ticketID int64, now time.Time) error { - record := repositories.TicketSLARecordRepository.TakeByTicketIDAndType(tx, ticketID, string(enums.TicketSLATypeResolution)) - if record == nil { - return nil - } - values := map[string]any{ - "status": enums.TicketSLAStatusRunning, - "started_at": now, - "paused_at": nil, - "updated_at": now, - } - if record.Status == enums.TicketSLAStatusCompleted || record.Status == enums.TicketSLAStatusBreached { - values["elapsed_min"] = 0 - values["stopped_at"] = nil - values["breached_at"] = nil - } - return repositories.TicketSLARecordRepository.Updates(tx, record.ID, values) -} - -func (s *ticketService) completeResolutionSLA(tx *gorm.DB, ticketID int64, now time.Time) error { - record := repositories.TicketSLARecordRepository.TakeByTicketIDAndType(tx, ticketID, string(enums.TicketSLATypeResolution)) - if record == nil || record.Status == enums.TicketSLAStatusCompleted { - return nil - } - elapsed := record.ElapsedMin - if record.Status == enums.TicketSLAStatusRunning { - elapsed += diffMinutes(record.StartedAt, now) - } - return repositories.TicketSLARecordRepository.Updates(tx, record.ID, map[string]any{ - "status": enums.TicketSLAStatusCompleted, - "stopped_at": now, - "paused_at": nil, - "elapsed_min": elapsed, - "updated_at": now, - }) -} - -func (s *ticketService) refreshTicketSLAFields(tx *gorm.DB, ticketID int64, now time.Time) error { - firstResponse := repositories.TicketSLARecordRepository.TakeByTicketIDAndType(tx, ticketID, string(enums.TicketSLATypeFirstResponse)) - resolution := repositories.TicketSLARecordRepository.TakeByTicketIDAndType(tx, ticketID, string(enums.TicketSLATypeResolution)) - return repositories.TicketRepository.Updates(tx, ticketID, map[string]any{ - "next_reply_deadline_at": calcTicketSLADeadline(firstResponse, now), - "resolve_deadline_at": calcTicketSLADeadline(resolution, now), - }) -} - -func (s *ticketService) ScanAndMarkBreachedSLAs(limit int) (int, error) { - if limit <= 0 { - limit = 200 + ticket := s.Get(req.TicketID) + if ticket == nil { + return errorsx.InvalidParam("工单不存在") } now := time.Now() - cnd := sqls.NewCnd(). - Eq("status", enums.TicketSLAStatusRunning). - Where("started_at IS NOT NULL"). - Asc("id") - records, _ := TicketSLARecordService.FindPageByCnd(cnd.Limit(limit)) - if len(records) == 0 { - return 0, nil + var handledAt *time.Time + if enums.TicketStatus(status) == enums.TicketStatusDone { + handledAt = &now } + return repositories.TicketRepository.Updates(sqls.DB(), ticket.ID, map[string]any{ + "status": enums.TicketStatus(status), + "handled_at": handledAt, + "updated_at": now, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + }) +} - breachedCount := 0 - for i := range records { - record := &records[i] - elapsed := record.ElapsedMin + diffMinutes(record.StartedAt, now) - if elapsed < record.TargetMinutes { - continue +func (s *ticketService) AddProgress(req request.CreateTicketProgressRequest, operator *dto.AuthPrincipal) (*models.TicketProgress, error) { + if operator == nil { + return nil, errorsx.Unauthorized("未登录或登录已过期") + } + content := strings.TrimSpace(req.Content) + if content == "" { + return nil, errorsx.InvalidParam("处理进展不能为空") + } + ticket := s.Get(req.TicketID) + if ticket == nil { + return nil, errorsx.InvalidParam("工单不存在") + } + now := time.Now() + progress := &models.TicketProgress{ + TicketID: ticket.ID, + Content: content, + AuthorID: operator.UserID, + CreatedAt: now, + } + if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + if err := repositories.TicketProgressRepository.Create(ctx.Tx, progress); err != nil { + return err } - if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { - if err := repositories.TicketSLARecordRepository.Updates(ctx.Tx, record.ID, map[string]any{ - "status": enums.TicketSLAStatusBreached, - "breached_at": now, - "elapsed_min": elapsed, - "updated_at": now, - }); err != nil { - return err - } - if err := s.refreshTicketSLAFields(ctx.Tx, record.TicketID, now); err != nil { - return err - } - return s.logEvent(ctx.Tx, record.TicketID, enums.TicketEventTypeSLABreached, nil, string(enums.TicketSLAStatusRunning), string(enums.TicketSLAStatusBreached), fmt.Sprintf("%s SLA 已超时", record.SLAType), "") - }); err != nil { - return breachedCount, err + return repositories.TicketRepository.Updates(ctx.Tx, ticket.ID, map[string]any{ + "updated_at": now, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + }) + }); err != nil { + return nil, err + } + return progress, nil +} + +func (s *ticketService) GetDetail(id int64) (*TicketDetailAggregate, error) { + ticket := s.Get(id) + if ticket == nil { + return nil, errorsx.InvalidParam("工单不存在") + } + aggregate := &TicketDetailAggregate{ + Ticket: ticket, + Tags: s.GetTags(id), + Progresses: repositories.TicketProgressRepository.Find(sqls.DB(), sqls.NewCnd().Eq("ticket_id", id).Asc("id")), + Users: make(map[int64]*models.User), + } + if ticket.CustomerID > 0 { + aggregate.Customer = CustomerService.Get(ticket.CustomerID) + } + userIDs := make([]int64, 0) + seen := make(map[int64]struct{}) + addUserID := func(userID int64) { + if userID <= 0 { + return } - breachedCount++ + if _, ok := seen[userID]; ok { + return + } + seen[userID] = struct{}{} + userIDs = append(userIDs, userID) } - return breachedCount, nil + addUserID(ticket.CurrentAssigneeID) + for i := range aggregate.Progresses { + addUserID(aggregate.Progresses[i].AuthorID) + } + if len(userIDs) > 0 { + users := repositories.UserRepository.FindByIds(sqls.DB(), userIDs) + for i := range users { + item := users[i] + aggregate.Users[item.ID] = &item + } + } + return aggregate, nil } -func (s *ticketService) canTransition(from, to enums.TicketStatus) bool { - if from == to { - return true +func (s *ticketService) GetSummary(operator *dto.AuthPrincipal, staleHours ...int) *TicketSummaryAggregate { + staleHour := 24 + if len(staleHours) > 0 && staleHours[0] > 0 { + staleHour = staleHours[0] } - switch from { - case enums.TicketStatusNew: - return to == enums.TicketStatusOpen || to == enums.TicketStatusCancelled || to == enums.TicketStatusPendingInternal - case enums.TicketStatusOpen: - return to == enums.TicketStatusPendingCustomer || to == enums.TicketStatusPendingInternal || to == enums.TicketStatusResolved || to == enums.TicketStatusClosed || to == enums.TicketStatusCancelled - case enums.TicketStatusPendingCustomer: - return to == enums.TicketStatusOpen || to == enums.TicketStatusResolved || to == enums.TicketStatusClosed || to == enums.TicketStatusCancelled - case enums.TicketStatusPendingInternal: - return to == enums.TicketStatusOpen || to == enums.TicketStatusResolved || to == enums.TicketStatusClosed || to == enums.TicketStatusCancelled - case enums.TicketStatusResolved: - return to == enums.TicketStatusOpen || to == enums.TicketStatusClosed - case enums.TicketStatusClosed: - return to == enums.TicketStatusOpen - default: - return false + summary := &TicketSummaryAggregate{ + All: s.Count(sqls.NewCnd()), + Pending: s.Count(sqls.NewCnd().Eq("status", enums.TicketStatusPending)), + InProgress: s.Count(sqls.NewCnd().Eq("status", enums.TicketStatusInProgress)), + Done: s.Count(sqls.NewCnd().Eq("status", enums.TicketStatusDone)), + Unassigned: s.Count(sqls.NewCnd().Eq("current_assignee_id", 0)), + Stale: s.Count( + sqls.NewCnd(). + NotEq("status", enums.TicketStatusDone). + Where("updated_at < ?", time.Now().Add(-time.Duration(staleHour)*time.Hour)), + ), + } + if operator != nil { + summary.Mine = s.Count(sqls.NewCnd().Eq("current_assignee_id", operator.UserID)) + } + return summary +} + +func (s *ticketService) assignTicketTx(tx *gorm.DB, req request.AssignTicketRequest, operator *dto.AuthPrincipal) (*events.TicketAssignedEvent, error) { + ticket := repositories.TicketRepository.Get(tx, req.TicketID) + if ticket == nil { + return nil, errorsx.InvalidParam("工单不存在") + } + if err := s.validateAssignee(req.ToUserID); err != nil { + return nil, err + } + now := time.Now() + if err := repositories.TicketRepository.Updates(tx, ticket.ID, map[string]any{ + "current_assignee_id": req.ToUserID, + "updated_at": now, + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + }); err != nil { + return nil, err + } + return &events.TicketAssignedEvent{ + TicketID: ticket.ID, + FromUserID: ticket.CurrentAssigneeID, + ToUserID: req.ToUserID, + OperatorID: operator.UserID, + Reason: strings.TrimSpace(req.Reason), + }, nil +} + +func (s *ticketService) buildTicketListAggregate(db *gorm.DB, list []models.Ticket, paging *sqls.Paging) *TicketListAggregate { + aggregate := &TicketListAggregate{ + List: list, + Paging: paging, + TagsByTicketID: make(map[int64][]models.Tag), + Users: make(map[int64]*models.User), + Customers: make(map[int64]*models.Customer), + } + if len(list) == 0 { + return aggregate + } + ticketIDs := make([]int64, 0, len(list)) + customerIDs := make([]int64, 0) + userIDs := make([]int64, 0) + ticketSeen := make(map[int64]struct{}) + customerSeen := make(map[int64]struct{}) + userSeen := make(map[int64]struct{}) + for i := range list { + item := &list[i] + if _, ok := ticketSeen[item.ID]; !ok { + ticketSeen[item.ID] = struct{}{} + ticketIDs = append(ticketIDs, item.ID) + } + if item.CustomerID > 0 { + if _, ok := customerSeen[item.CustomerID]; !ok { + customerSeen[item.CustomerID] = struct{}{} + customerIDs = append(customerIDs, item.CustomerID) + } + } + if item.CurrentAssigneeID > 0 { + if _, ok := userSeen[item.CurrentAssigneeID]; !ok { + userSeen[item.CurrentAssigneeID] = struct{}{} + userIDs = append(userIDs, item.CurrentAssigneeID) + } + } + } + s.enrichTicketTags(db, aggregate, ticketIDs) + if len(userIDs) > 0 { + users := repositories.UserRepository.FindByIds(db, userIDs) + for i := range users { + item := users[i] + aggregate.Users[item.ID] = &item + } + } + if len(customerIDs) > 0 { + customers := repositories.CustomerRepository.Find(db, sqls.NewCnd().In("id", customerIDs)) + for i := range customers { + item := customers[i] + aggregate.Customers[item.ID] = &item + } + } + return aggregate +} + +func (s *ticketService) enrichTicketTags(db *gorm.DB, aggregate *TicketListAggregate, ticketIDs []int64) { + if len(ticketIDs) == 0 { + return + } + ticketTags := repositories.TicketTagRepository.Find(db, sqls.NewCnd().In("ticket_id", ticketIDs).Asc("id")) + if len(ticketTags) == 0 { + return + } + tagIDs := make([]int64, 0) + tagSeen := make(map[int64]struct{}) + ticketTagMap := make(map[int64][]int64, len(ticketIDs)) + for i := range ticketTags { + relation := ticketTags[i] + ticketTagMap[relation.TicketID] = append(ticketTagMap[relation.TicketID], relation.TagID) + if _, ok := tagSeen[relation.TagID]; !ok { + tagSeen[relation.TagID] = struct{}{} + tagIDs = append(tagIDs, relation.TagID) + } + } + tags := repositories.TagRepository.Find(db, sqls.NewCnd().In("id", tagIDs)) + tagMap := make(map[int64]models.Tag, len(tags)) + for i := range tags { + tagMap[tags[i].ID] = tags[i] + } + for ticketID, orderedTagIDs := range ticketTagMap { + orderedTags := make([]models.Tag, 0, len(orderedTagIDs)) + for _, tagID := range orderedTagIDs { + if tag, ok := tagMap[tagID]; ok { + orderedTags = append(orderedTags, tag) + } + } + aggregate.TagsByTicketID[ticketID] = orderedTags } } -func (s *ticketService) isEditableStatus(status enums.TicketStatus) bool { - return status != enums.TicketStatusClosed && status != enums.TicketStatusCancelled +func (s *ticketService) validateTicketRefs(customerID, conversationID, assigneeID int64) error { + if customerID > 0 && CustomerService.Get(customerID) == nil { + return errorsx.InvalidParam("客户不存在") + } + if conversationID > 0 && ConversationService.Get(conversationID) == nil { + return errorsx.InvalidParam("会话不存在") + } + return s.validateAssignee(assigneeID) } -func activeTicketStatuses() []enums.TicketStatus { - return []enums.TicketStatus{ - enums.TicketStatusNew, - enums.TicketStatusOpen, - enums.TicketStatusPendingCustomer, - enums.TicketStatusPendingInternal, +func (s *ticketService) validateAssignee(userID int64) error { + if userID <= 0 { + return nil } -} - -func marshalJSON(value any) (string, error) { - if value == nil { - return "", nil + user := UserService.Get(userID) + if user == nil || user.Status == enums.StatusDeleted { + return errorsx.InvalidParam("负责人不存在") } - buf, err := json.Marshal(value) - if err != nil { - return "", err - } - return string(buf), nil -} - -func parseTicketNotePayload(value string) ticketNotePayload { - value = strings.TrimSpace(value) - if value == "" { - return ticketNotePayload{} - } - var payload ticketNotePayload - if err := json.Unmarshal([]byte(value), &payload); err != nil { - return ticketNotePayload{} - } - return payload -} - -func parseOptionalDateTime(value string) (*time.Time, error) { - raw := strings.TrimSpace(value) - if raw == "" { - return nil, nil - } - if t, err := time.ParseInLocation(time.DateTime, raw, time.Local); err == nil { - return &t, nil - } - if t, err := time.ParseInLocation("2006-01-02T15:04:05", raw, time.Local); err == nil { - return &t, nil - } - return nil, fmt.Errorf("invalid datetime") + return nil } func (s *ticketService) resolveConversationChannel(conversation *models.Conversation) string { @@ -1855,41 +603,21 @@ func (s *ticketService) resolveConversationChannel(conversation *models.Conversa return "" } -func diffMinutes(start *time.Time, end time.Time) int { - if start == nil || start.IsZero() { - return 0 +func normalizeInt64IDs(ids []int64) []int64 { + if len(ids) == 0 { + return nil } - return int(end.Sub(*start).Minutes()) -} - -func calcTicketSLADeadline(record *models.TicketSLARecord, now time.Time) *time.Time { - if record == nil { - return nil - } - switch record.Status { - case enums.TicketSLAStatusCompleted: - return nil - case enums.TicketSLAStatusBreached: - if record.BreachedAt != nil && !record.BreachedAt.IsZero() { - value := *record.BreachedAt - return &value - } - case enums.TicketSLAStatusPaused: - base := now - if record.PausedAt != nil && !record.PausedAt.IsZero() { - base = *record.PausedAt - } - remaining := record.TargetMinutes - record.ElapsedMin - if remaining < 0 { - remaining = 0 - } - deadline := base.Add(time.Duration(remaining) * time.Minute) - return &deadline - } - if record.StartedAt == nil || record.StartedAt.IsZero() { - return nil - } - remaining := record.TargetMinutes - record.ElapsedMin - deadline := record.StartedAt.Add(time.Duration(remaining) * time.Minute) - return &deadline + seen := make(map[int64]struct{}, len(ids)) + result := make([]int64, 0, len(ids)) + for _, id := range ids { + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + result = append(result, id) + } + return result } diff --git a/internal/services/ticket_service_test.go b/internal/services/ticket_service_test.go index a6a44d7..fdd81ae 100644 --- a/internal/services/ticket_service_test.go +++ b/internal/services/ticket_service_test.go @@ -10,7 +10,6 @@ import ( "time" "cs-agent/internal/bootstrap" - "cs-agent/internal/builders" "cs-agent/internal/events" "cs-agent/internal/models" "cs-agent/internal/pkg/config" @@ -24,49 +23,77 @@ import ( "github.com/mlogclub/simple/sqls" ) -func TestCreateTicketSetsTicketNoAndDeadlines(t *testing.T) { - setupTicketTestDB(t) - operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"} - - first, err := services.TicketService.CreateTicket(createTestTicketRequest("ticket-1"), operator) - if err != nil { - t.Fatalf("CreateTicket() first error = %v", err) +func TestTicketLightweightStatuses(t *testing.T) { + if !enums.IsValidTicketStatus(string(enums.TicketStatusPending)) { + t.Fatalf("pending should be valid") } - second, err := services.TicketService.CreateTicket(createTestTicketRequest("ticket-2"), operator) - if err != nil { - t.Fatalf("CreateTicket() second error = %v", err) + if !enums.IsValidTicketStatus(string(enums.TicketStatusInProgress)) { + t.Fatalf("in_progress should be valid") } - - if first.TicketNo == "" || second.TicketNo == "" { - t.Fatalf("expected ticket numbers to be generated, got %q and %q", first.TicketNo, second.TicketNo) + if !enums.IsValidTicketStatus(string(enums.TicketStatusDone)) { + t.Fatalf("done should be valid") } - if first.TicketNo == second.TicketNo { - t.Fatalf("expected distinct ticket numbers, got %q", first.TicketNo) - } - if !strings.HasPrefix(first.TicketNo, "TK") { - t.Fatalf("expected ticket number prefix TK, got %q", first.TicketNo) - } - - detail := services.TicketService.Get(first.ID) - if detail == nil { - t.Fatalf("expected ticket to exist") - } - if detail.NextReplyDeadlineAt == nil { - t.Fatalf("expected next reply deadline to be populated") - } - if detail.ResolveDeadlineAt == nil { - t.Fatalf("expected resolve deadline to be populated") - } - - slaList := services.TicketSLARecordService.Find(sqls.NewCnd().Eq("ticket_id", first.ID)) - if len(slaList) != 2 { - t.Fatalf("expected 2 SLA records, got %d", len(slaList)) + for _, status := range []string{"new", "open", "pending_customer", "pending_internal", "resolved", "closed", "cancelled"} { + if enums.IsValidTicketStatus(status) { + t.Fatalf("legacy status %s should be invalid", status) + } } } -func TestCreateTicketPublishesTicketCreatedEvent(t *testing.T) { +func TestTicketProgressModelExists(t *testing.T) { + item := models.TicketProgress{ + TicketID: 12, + Content: "已电话联系客户确认问题仍存在", + AuthorID: 7, + } + if item.TicketID != 12 || item.AuthorID != 7 || item.Content == "" { + t.Fatalf("unexpected progress model: %+v", item) + } +} + +func TestTicketServiceCreateTicketSetsPendingStatusAndTicketNo(t *testing.T) { setupTicketTestDB(t) - operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"} + operator := createTestOperator(t, "creator") + customerID := createTestCustomer(t, "create-customer") + tagID := createTestTag(t, "create-tag") + + created, err := services.TicketService.CreateTicket(request.CreateTicketRequest{ + Title: "create ticket", + Description: "create ticket description", + CustomerID: customerID, + TagIDs: []int64{tagID}, + CurrentAssigneeID: operator.UserID, + }, operator) + if err != nil { + t.Fatalf("CreateTicket() error = %v", err) + } + if created.TicketNo == "" || !strings.HasPrefix(created.TicketNo, "TK") { + t.Fatalf("expected generated ticket number, got %q", created.TicketNo) + } + if created.Status != enums.TicketStatusPending { + t.Fatalf("expected pending status, got %s", created.Status) + } + if created.Source != enums.TicketSourceManual { + t.Fatalf("expected manual source, got %s", created.Source) + } + + progresses := services.TicketProgressService.Find(sqls.NewCnd().Eq("ticket_id", created.ID)) + if len(progresses) != 1 { + t.Fatalf("expected initial progress, got %d", len(progresses)) + } + if progresses[0].Content != "创建工单" || progresses[0].AuthorID != operator.UserID { + t.Fatalf("unexpected initial progress: %+v", progresses[0]) + } + + tags := services.TicketService.GetTags(created.ID) + if len(tags) != 1 || tags[0].ID != tagID { + t.Fatalf("expected ticket tag %d, got %+v", tagID, tags) + } +} + +func TestTicketServiceCreateTicketPublishesTicketCreatedEvent(t *testing.T) { + setupTicketTestDB(t) + operator := createTestOperator(t, "event-creator") eventsCh := make(chan events.TicketCreatedEvent, 1) _, unsubscribe := eventbus.Subscribe(func(ctx context.Context, event events.TicketCreatedEvent) error { eventsCh <- event @@ -92,240 +119,153 @@ func TestCreateTicketPublishesTicketCreatedEvent(t *testing.T) { } } -func TestAddInternalNoteAllowsMentionSameUserAcrossTickets(t *testing.T) { +func TestTicketServiceChangeStatusSetsHandledAt(t *testing.T) { setupTicketTestDB(t) - operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"} - mentionedUserID := createTestUser(t, "mentioned") - - first, err := services.TicketService.CreateTicket(createTestTicketRequest("note-ticket-1"), operator) - if err != nil { - t.Fatalf("CreateTicket() first error = %v", err) - } - second, err := services.TicketService.CreateTicket(createTestTicketRequest("note-ticket-2"), operator) - if err != nil { - t.Fatalf("CreateTicket() second error = %v", err) - } - - payload := fmt.Sprintf(`{"mentionUserIds":[%d]}`, mentionedUserID) - if _, err := services.TicketService.AddInternalNote(requestInternalNote(first.ID, payload), operator); err != nil { - t.Fatalf("AddInternalNote() first error = %v", err) - } - if _, err := services.TicketService.AddInternalNote(requestInternalNote(second.ID, payload), operator); err != nil { - t.Fatalf("AddInternalNote() second error = %v", err) - } - - mentions := services.TicketMentionService.Find(sqls.NewCnd().Eq("mentioned_user_id", mentionedUserID).Asc("id")) - if len(mentions) != 2 { - t.Fatalf("expected 2 mention records, got %d", len(mentions)) - } -} - -func TestBatchAssignTicketsPublishesTicketAssignedEvents(t *testing.T) { - setupTicketTestDB(t) - operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"} - teamID, assigneeID := createTestAgentProfile(t, "batch-event-assignee") - first, err := services.TicketService.CreateTicket(createTestTicketRequest("batch-event-1"), operator) - if err != nil { - t.Fatalf("CreateTicket() first error = %v", err) - } - second, err := services.TicketService.CreateTicket(createTestTicketRequest("batch-event-2"), operator) - if err != nil { - t.Fatalf("CreateTicket() second error = %v", err) - } - - eventsCh := make(chan events.TicketAssignedEvent, 2) - _, unsubscribe := eventbus.Subscribe(func(ctx context.Context, event events.TicketAssignedEvent) error { - eventsCh <- event - return nil - }) - defer unsubscribe() - - if err := services.TicketService.BatchAssignTickets(request.BatchAssignTicketRequest{ - TicketIDs: []int64{first.ID, second.ID}, - ToUserID: assigneeID, - ToTeamID: teamID, - Reason: "batch assign event", - }, operator); err != nil { - t.Fatalf("BatchAssignTickets() error = %v", err) - } - - got := map[int64]events.TicketAssignedEvent{} - for len(got) < 2 { - select { - case event := <-eventsCh: - got[event.TicketID] = event - case <-time.After(time.Second): - t.Fatalf("expected 2 ticket assigned events, got %d", len(got)) - } - } - for _, ticketID := range []int64{first.ID, second.ID} { - event, ok := got[ticketID] - if !ok { - t.Fatalf("missing event for ticket %d", ticketID) - } - if event.ToUserID != assigneeID { - t.Fatalf("expected assignee id %d, got %d", assigneeID, event.ToUserID) - } - } -} - -func TestBatchChangeStatusRollsBackOnFailure(t *testing.T) { - setupTicketTestDB(t) - operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"} - - first, err := services.TicketService.CreateTicket(createTestTicketRequest("batch-ticket"), operator) + operator := createTestOperator(t, "status-operator") + ticket, err := services.TicketService.CreateTicket(createTestTicketRequest("status-ticket"), operator) if err != nil { t.Fatalf("CreateTicket() error = %v", err) } - err = services.TicketService.BatchChangeStatus(request.BatchChangeTicketStatusRequest{ - TicketIDs: []int64{first.ID, 999999}, - Status: string(enums.TicketStatusOpen), - Reason: "batch open", - }, operator) - if err == nil { - t.Fatalf("expected batch change status to fail") - } - - current := services.TicketService.Get(first.ID) - if current == nil { - t.Fatalf("expected ticket to exist") - } - if current.Status != enums.TicketStatusNew { - t.Fatalf("expected ticket status rollback to new, got %s", current.Status) - } -} - -func TestAssignTicketPromotesNewTicketToOpenAndSetsTeamAssignee(t *testing.T) { - setupTicketTestDB(t) - operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"} - - ticket, err := services.TicketService.CreateTicket(createTestTicketRequest("assign-ticket"), operator) - if err != nil { - t.Fatalf("CreateTicket() error = %v", err) - } - teamID, assigneeID := createTestAgentProfile(t, "assignee") - - if err := services.TicketService.AssignTicket(request.AssignTicketRequest{ + if err := services.TicketService.ChangeStatus(request.ChangeTicketStatusRequest{ TicketID: ticket.ID, - ToTeamID: teamID, - ToUserID: assigneeID, - Reason: "manual assign", + Status: string(enums.TicketStatusInProgress), }, operator); err != nil { - t.Fatalf("AssignTicket() error = %v", err) + t.Fatalf("ChangeStatus() in_progress error = %v", err) } - - current := services.TicketService.Get(ticket.ID) - if current == nil { + inProgress := services.TicketService.Get(ticket.ID) + if inProgress == nil { t.Fatalf("expected ticket to exist") } - if current.Status != enums.TicketStatusOpen { - t.Fatalf("expected assigned ticket status to be open, got %s", current.Status) + if inProgress.Status != enums.TicketStatusInProgress { + t.Fatalf("expected in_progress status, got %s", inProgress.Status) } - if current.CurrentTeamID != teamID { - t.Fatalf("expected current team id %d, got %d", teamID, current.CurrentTeamID) + if inProgress.HandledAt != nil { + t.Fatalf("expected handled_at to remain nil before done") } - if current.CurrentAssigneeID != assigneeID { - t.Fatalf("expected current assignee id %d, got %d", assigneeID, current.CurrentAssigneeID) + + if err := services.TicketService.ChangeStatus(request.ChangeTicketStatusRequest{ + TicketID: ticket.ID, + Status: string(enums.TicketStatusDone), + }, operator); err != nil { + t.Fatalf("ChangeStatus() done error = %v", err) + } + done := services.TicketService.Get(ticket.ID) + if done == nil || done.HandledAt == nil { + t.Fatalf("expected handled_at to be set after done, got %+v", done) + } + + if err := services.TicketService.ChangeStatus(request.ChangeTicketStatusRequest{ + TicketID: ticket.ID, + Status: string(enums.TicketStatusPending), + }, operator); err != nil { + t.Fatalf("ChangeStatus() pending error = %v", err) + } + pending := services.TicketService.Get(ticket.ID) + if pending == nil || pending.HandledAt != nil { + t.Fatalf("expected handled_at to be cleared away from done, got %+v", pending) } } -func TestFindPageAggregateByCndBuildsWatcherAndLookupMaps(t *testing.T) { +func TestTicketServiceAddProgressStoresContentAndAuthor(t *testing.T) { setupTicketTestDB(t) - operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"} - teamID, assigneeID := createTestAgentProfile(t, "aggregate-agent") + operator := createTestOperator(t, "progress-operator") + ticket, err := services.TicketService.CreateTicket(createTestTicketRequest("progress-ticket"), operator) + if err != nil { + t.Fatalf("CreateTicket() error = %v", err) + } + + progress, err := services.TicketService.AddProgress(request.CreateTicketProgressRequest{ + TicketID: ticket.ID, + Content: "客户已确认问题复现路径", + }, operator) + if err != nil { + t.Fatalf("AddProgress() error = %v", err) + } + if progress.ID <= 0 { + t.Fatalf("expected progress id") + } + if progress.Content != "客户已确认问题复现路径" || progress.AuthorID != operator.UserID { + t.Fatalf("unexpected progress: %+v", progress) + } +} + +func TestTicketServiceSummaryCountsStaleTickets(t *testing.T) { + setupTicketTestDB(t) + operator := createTestOperator(t, "summary-operator") + mine, err := services.TicketService.CreateTicket(request.CreateTicketRequest{ + Title: "mine stale ticket", + Description: "mine stale description", + CurrentAssigneeID: operator.UserID, + }, operator) + if err != nil { + t.Fatalf("CreateTicket() mine error = %v", err) + } + if _, err := services.TicketService.CreateTicket(createTestTicketRequest("unassigned ticket"), operator); err != nil { + t.Fatalf("CreateTicket() unassigned error = %v", err) + } + staleUpdatedAt := time.Now().Add(-48 * time.Hour) + if err := repositories.TicketRepository.Updates(sqls.DB(), mine.ID, map[string]any{ + "updated_at": staleUpdatedAt, + }); err != nil { + t.Fatalf("update stale ticket error = %v", err) + } + + summary := services.TicketService.GetSummary(operator, 24) + if summary.All != 2 { + t.Fatalf("expected all count 2, got %d", summary.All) + } + if summary.Pending != 2 { + t.Fatalf("expected pending count 2, got %d", summary.Pending) + } + if summary.Mine != 1 { + t.Fatalf("expected mine count 1, got %d", summary.Mine) + } + if summary.Unassigned != 1 { + t.Fatalf("expected unassigned count 1, got %d", summary.Unassigned) + } + if summary.Stale != 1 { + t.Fatalf("expected stale count 1, got %d", summary.Stale) + } +} + +func TestTicketServiceFindPageAggregateEnrichesLookups(t *testing.T) { + setupTicketTestDB(t) + operator := createTestOperator(t, "aggregate-operator") + assignee := createTestOperator(t, "aggregate-assignee") customerID := createTestCustomer(t, "aggregate-customer") tagID := createTestTag(t, "aggregate-tag") ticket, err := services.TicketService.CreateTicket(request.CreateTicketRequest{ - Title: "aggregate-ticket", + Title: "aggregate ticket", + Description: "aggregate description", CustomerID: customerID, TagIDs: []int64{tagID}, - Priority: 3, - Severity: int(enums.TicketSeverityMajor), - CurrentTeamID: teamID, - CurrentAssigneeID: assigneeID, + CurrentAssigneeID: assignee.UserID, }, operator) if err != nil { t.Fatalf("CreateTicket() error = %v", err) } - if err := repositories.TicketWatcherRepository.Create(sqls.DB(), &models.TicketWatcher{ - TicketID: ticket.ID, - UserID: operator.UserID, - CreatedAt: time.Now(), - }); err != nil { - t.Fatalf("create ticket watcher error = %v", err) - } - aggregate, err := services.TicketService.FindPageAggregateByCnd( - sqls.NewCnd().Eq("id", ticket.ID).Page(1, 10), - operator.UserID, - ) + aggregate, err := services.TicketService.FindPageAggregateByCnd(sqls.NewCnd().Eq("id", ticket.ID).Page(1, 10), operator.UserID) if err != nil { t.Fatalf("FindPageAggregateByCnd() error = %v", err) } if len(aggregate.List) != 1 { t.Fatalf("expected 1 ticket, got %d", len(aggregate.List)) } - if _, ok := aggregate.WatchedTicketIDs[ticket.ID]; !ok { - t.Fatalf("expected watched ticket id to be populated") - } if len(aggregate.TagsByTicketID[ticket.ID]) != 1 || aggregate.TagsByTicketID[ticket.ID][0].ID != tagID { t.Fatalf("expected tag lookup to be populated") } if aggregate.Customers[customerID] == nil { t.Fatalf("expected customer lookup to be populated") } - if aggregate.Users[assigneeID] == nil { + if aggregate.Users[assignee.UserID] == nil { t.Fatalf("expected assignee lookup to be populated") } - if aggregate.Teams[teamID] == nil { - t.Fatalf("expected team lookup to be populated") - } - if len(aggregate.SLAByTicketID[ticket.ID]) != 2 { - t.Fatalf("expected 2 sla records for ticket, got %d", len(aggregate.SLAByTicketID[ticket.ID])) - } } -func TestWatchTicketAffectsSummaryAndListFilter(t *testing.T) { - setupTicketTestDB(t) - operator := &dto.AuthPrincipal{UserID: createTestUser(t, "watch-operator"), Username: "watch-operator"} - - ticket, err := services.TicketService.CreateTicket(createTestTicketRequest("watch-ticket"), operator) - if err != nil { - t.Fatalf("CreateTicket() error = %v", err) - } - - if err := services.TicketService.WatchTicket(ticket.ID, operator); err != nil { - t.Fatalf("WatchTicket() error = %v", err) - } - - summary := services.TicketService.GetSummary(operator) - if summary.Watching != 1 { - t.Fatalf("expected watching summary to be 1, got %d", summary.Watching) - } - - aggregate, err := services.TicketService.FindPageAggregateByCnd( - sqls.NewCnd(). - Where("id IN (SELECT ticket_id FROM t_ticket_watcher WHERE user_id = ?)", operator.UserID). - Page(1, 10), - operator.UserID, - ) - if err != nil { - t.Fatalf("FindPageAggregateByCnd() error = %v", err) - } - if len(aggregate.List) != 1 { - t.Fatalf("expected 1 watched ticket, got %d", len(aggregate.List)) - } - if aggregate.List[0].ID != ticket.ID { - t.Fatalf("expected watched ticket id %d, got %d", ticket.ID, aggregate.List[0].ID) - } - if _, ok := aggregate.WatchedTicketIDs[ticket.ID]; !ok { - t.Fatalf("expected watched ticket id to be marked in aggregate") - } -} - -func TestTicketNoServiceNextConcurrent(t *testing.T) { +func TestTicketServiceTicketNoNextConcurrent(t *testing.T) { setupTicketTestDB(t) const count = 20 @@ -373,402 +313,6 @@ func TestTicketNoServiceNextConcurrent(t *testing.T) { } } -func TestGetRiskPageAggregateReturnsAccurateHighRiskTickets(t *testing.T) { - setupTicketTestDB(t) - operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"} - - highRisk, err := services.TicketService.CreateTicket(createTestTicketRequest("high-risk-ticket"), operator) - if err != nil { - t.Fatalf("CreateTicket() highRisk error = %v", err) - } - safe, err := services.TicketService.CreateTicket(createTestTicketRequest("safe-ticket"), operator) - if err != nil { - t.Fatalf("CreateTicket() safe error = %v", err) - } - - if err := services.TicketService.ChangeStatus(request.ChangeTicketStatusRequest{ - TicketID: highRisk.ID, - Status: string(enums.TicketStatusOpen), - Reason: "pick up high risk", - }, operator); err != nil { - t.Fatalf("ChangeStatus() highRisk open error = %v", err) - } - if err := services.TicketService.ChangeStatus(request.ChangeTicketStatusRequest{ - TicketID: safe.ID, - Status: string(enums.TicketStatusOpen), - Reason: "pick up safe", - }, operator); err != nil { - t.Fatalf("ChangeStatus() safe open error = %v", err) - } - - now := time.Now() - if err := repositories.TicketRepository.Updates(sqls.DB(), highRisk.ID, map[string]any{ - "resolve_deadline_at": now.Add(30 * time.Minute), - "updated_at": now, - }); err != nil { - t.Fatalf("update highRisk deadline error = %v", err) - } - if err := repositories.TicketRepository.Updates(sqls.DB(), safe.ID, map[string]any{ - "resolve_deadline_at": now.Add(6 * time.Hour), - "updated_at": now, - }); err != nil { - t.Fatalf("update safe deadline error = %v", err) - } - - aggregate, err := services.TicketService.GetRiskPageAggregate("high_risk", 0, 60, 1, 10, operator.UserID) - if err != nil { - t.Fatalf("GetRiskPageAggregate() error = %v", err) - } - if len(aggregate.List) != 1 { - t.Fatalf("expected 1 high risk ticket, got %d", len(aggregate.List)) - } - if aggregate.List[0].ID != highRisk.ID { - t.Fatalf("expected high risk ticket id %d, got %d", highRisk.ID, aggregate.List[0].ID) - } -} - -func TestBuildTicketDetailUsesAggregatedWatcherCollaboratorAndRelationLookups(t *testing.T) { - setupTicketTestDB(t) - operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"} - teamID, assigneeID := createTestAgentProfile(t, "detail-agent") - - parent, err := services.TicketService.CreateTicket(createTestTicketRequest("detail-parent"), operator) - if err != nil { - t.Fatalf("CreateTicket() parent error = %v", err) - } - child, err := services.TicketService.CreateTicket(request.CreateTicketRequest{ - Title: "detail-child", - Priority: 1, - Severity: int(enums.TicketSeverityMinor), - CurrentTeamID: teamID, - CurrentAssigneeID: assigneeID, - }, operator) - if err != nil { - t.Fatalf("CreateTicket() child error = %v", err) - } - if err := repositories.TicketWatcherRepository.Create(sqls.DB(), &models.TicketWatcher{ - TicketID: parent.ID, - UserID: assigneeID, - CreatedAt: time.Now(), - }); err != nil { - t.Fatalf("create watcher error = %v", err) - } - if err := repositories.TicketCollaboratorRepository.Create(sqls.DB(), &models.TicketCollaborator{ - TicketID: parent.ID, - UserID: assigneeID, - CreatedAt: time.Now(), - }); err != nil { - t.Fatalf("create collaborator error = %v", err) - } - if err := repositories.TicketRelationRepository.Create(sqls.DB(), &models.TicketRelation{ - TicketID: parent.ID, - RelatedTicketID: child.ID, - RelationType: enums.TicketRelationTypeChild, - CreatedAt: time.Now(), - }); err != nil { - t.Fatalf("create relation error = %v", err) - } - if err := repositories.TicketCommentRepository.Create(sqls.DB(), &models.TicketComment{ - TicketID: parent.ID, - CommentType: enums.TicketCommentTypePublicReply, - AuthorType: enums.IMSenderTypeAgent, - AuthorID: assigneeID, - ContentType: "text", - Content: "reply", - CreatedAt: time.Now(), - }); err != nil { - t.Fatalf("create comment error = %v", err) - } - if err := repositories.TicketEventLogRepository.Create(sqls.DB(), &models.TicketEventLog{ - TicketID: parent.ID, - EventType: enums.TicketEventTypeAssigned, - OperatorType: enums.IMSenderTypeAgent, - OperatorID: assigneeID, - Content: "assigned", - CreatedAt: time.Now(), - }); err != nil { - t.Fatalf("create event error = %v", err) - } - - aggregate, err := services.TicketService.GetDetail(parent.ID) - if err != nil { - t.Fatalf("GetDetail() error = %v", err) - } - detail := builders.BuildTicketDetail(aggregate) - if detail == nil { - t.Fatalf("expected ticket detail to be built") - } - if len(detail.Watchers) != 1 || detail.Watchers[0].UserName == "" { - t.Fatalf("expected watcher user name to be populated") - } - if len(detail.Collaborators) != 1 || detail.Collaborators[0].UserName == "" || detail.Collaborators[0].TeamName == "" { - t.Fatalf("expected collaborator user and team names to be populated") - } - if len(detail.RelatedTickets) != 1 { - t.Fatalf("expected 1 related ticket, got %d", len(detail.RelatedTickets)) - } - if detail.RelatedTickets[0].RelatedTicketNo == "" || detail.RelatedTickets[0].CurrentAssigneeName == "" || detail.RelatedTickets[0].CurrentTeamName == "" { - t.Fatalf("expected related ticket display fields to be populated") - } - if len(detail.Comments) != 1 || detail.Comments[0].AuthorName == "" { - t.Fatalf("expected comment author name to be populated") - } - if len(detail.Events) == 0 { - t.Fatalf("expected events to be populated") - } - hasNamedEvent := false - for i := range detail.Events { - if detail.Events[i].OperatorName != "" { - hasNamedEvent = true - break - } - } - if !hasNamedEvent { - t.Fatalf("expected at least one event operator name to be populated") - } -} - -func TestCloseAndReopenTicketRefreshResolutionDeadline(t *testing.T) { - setupTicketTestDB(t) - operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"} - - ticket, err := services.TicketService.CreateTicket(createTestTicketRequest("deadline-ticket"), operator) - if err != nil { - t.Fatalf("CreateTicket() error = %v", err) - } - - original := services.TicketService.Get(ticket.ID) - if original == nil || original.ResolveDeadlineAt == nil { - t.Fatalf("expected initial resolve deadline to exist") - } - - if err := services.TicketService.ChangeStatus(request.ChangeTicketStatusRequest{ - TicketID: ticket.ID, - Status: string(enums.TicketStatusOpen), - Reason: "pick up", - }, operator); err != nil { - t.Fatalf("ChangeStatus() open error = %v", err) - } - - if err := services.TicketService.CloseTicket(request.CloseTicketRequest{ - TicketID: ticket.ID, - CloseReason: "done", - }, operator); err != nil { - t.Fatalf("CloseTicket() error = %v", err) - } - - closed := services.TicketService.Get(ticket.ID) - if closed == nil { - t.Fatalf("expected closed ticket to exist") - } - if closed.ResolveDeadlineAt != nil { - t.Fatalf("expected resolve deadline to be cleared after close") - } - - if err := services.TicketService.ReopenTicket(request.ReopenTicketRequest{ - TicketID: ticket.ID, - Reason: "need follow-up", - }, operator); err != nil { - t.Fatalf("ReopenTicket() error = %v", err) - } - - reopened := services.TicketService.Get(ticket.ID) - if reopened == nil { - t.Fatalf("expected reopened ticket to exist") - } - if reopened.ResolveDeadlineAt == nil { - t.Fatalf("expected resolve deadline to be restored after reopen") - } -} - -func TestChangeStatusPendingCustomerThenOpenRefreshesSLAFields(t *testing.T) { - setupTicketTestDB(t) - operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"} - - ticket, err := services.TicketService.CreateTicket(createTestTicketRequest("pending-ticket"), operator) - if err != nil { - t.Fatalf("CreateTicket() error = %v", err) - } - if err := services.TicketService.ChangeStatus(request.ChangeTicketStatusRequest{ - TicketID: ticket.ID, - Status: string(enums.TicketStatusOpen), - Reason: "pick up", - }, operator); err != nil { - t.Fatalf("ChangeStatus() open error = %v", err) - } - - beforePending := services.TicketService.Get(ticket.ID) - if beforePending == nil || beforePending.ResolveDeadlineAt == nil { - t.Fatalf("expected resolve deadline before pending") - } - - if err := services.TicketService.ChangeStatus(request.ChangeTicketStatusRequest{ - TicketID: ticket.ID, - Status: string(enums.TicketStatusPendingCustomer), - PendingReason: "waiting customer", - Reason: "pause for customer", - }, operator); err != nil { - t.Fatalf("ChangeStatus() pending error = %v", err) - } - - pending := services.TicketService.Get(ticket.ID) - if pending == nil { - t.Fatalf("expected pending ticket to exist") - } - if pending.Status != enums.TicketStatusPendingCustomer { - t.Fatalf("expected pending status, got %s", pending.Status) - } - if pending.PendingReason != "waiting customer" { - t.Fatalf("expected pending reason to be persisted, got %q", pending.PendingReason) - } - if pending.ResolveDeadlineAt == nil { - t.Fatalf("expected resolve deadline to remain calculable while pending") - } - - if err := services.TicketService.ChangeStatus(request.ChangeTicketStatusRequest{ - TicketID: ticket.ID, - Status: string(enums.TicketStatusOpen), - Reason: "customer replied", - }, operator); err != nil { - t.Fatalf("ChangeStatus() reopen error = %v", err) - } - - reopened := services.TicketService.Get(ticket.ID) - if reopened == nil { - t.Fatalf("expected reopened ticket to exist") - } - if reopened.Status != enums.TicketStatusOpen { - t.Fatalf("expected reopened status open, got %s", reopened.Status) - } - if reopened.PendingReason != "" { - t.Fatalf("expected pending reason to be cleared, got %q", reopened.PendingReason) - } - if reopened.ResolveDeadlineAt == nil { - t.Fatalf("expected resolve deadline after reopening") - } -} - -func TestCloseTicketBlockedByOpenChild(t *testing.T) { - setupTicketTestDB(t) - operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"} - - parent, err := services.TicketService.CreateTicket(createTestTicketRequest("parent-ticket"), operator) - if err != nil { - t.Fatalf("CreateTicket() parent error = %v", err) - } - child, err := services.TicketService.CreateTicket(createTestTicketRequest("child-ticket"), operator) - if err != nil { - t.Fatalf("CreateTicket() child error = %v", err) - } - - if err := repositories.TicketRelationRepository.Create(sqls.DB(), &models.TicketRelation{ - TicketID: parent.ID, - RelatedTicketID: child.ID, - RelationType: enums.TicketRelationTypeChild, - CreatedAt: time.Now(), - }); err != nil { - t.Fatalf("create relation error = %v", err) - } - - err = services.TicketService.CloseTicket(request.CloseTicketRequest{ - TicketID: parent.ID, - CloseReason: "done", - }, operator) - if err == nil { - t.Fatalf("expected close ticket to be blocked by open child") - } - - current := services.TicketService.Get(parent.ID) - if current == nil { - t.Fatalf("expected parent ticket to exist") - } - if current.Status != enums.TicketStatusNew { - t.Fatalf("expected parent ticket status to remain new, got %s", current.Status) - } -} - -func TestTicketViewServiceSaveListAndDeleteOwnViews(t *testing.T) { - setupTicketTestDB(t) - operator := &dto.AuthPrincipal{UserID: createTestUser(t, "viewer"), Username: "viewer"} - - created, err := services.TicketViewService.Save(request.SaveTicketViewRequest{ - Name: "我的待处理", - Filters: map[string]any{ - "quickView": "mine", - "statusFilter": "open", - }, - }, operator) - if err != nil { - t.Fatalf("TicketViewService.Save() create error = %v", err) - } - if created.ID <= 0 { - t.Fatalf("expected created ticket view id") - } - - updated, err := services.TicketViewService.Save(request.SaveTicketViewRequest{ - ID: created.ID, - Name: "我的处理中", - Filters: map[string]any{ - "quickView": "mine", - "statusFilter": "pending_internal", - }, - }, operator) - if err != nil { - t.Fatalf("TicketViewService.Save() update error = %v", err) - } - if !strings.Contains(updated.FiltersJSON, "pending_internal") { - t.Fatalf("expected updated filters json, got %s", updated.FiltersJSON) - } - - list := services.TicketViewService.ListByUser(operator.UserID) - if len(list) != 1 { - t.Fatalf("expected 1 ticket view, got %d", len(list)) - } - if list[0].Name != "我的处理中" { - t.Fatalf("expected updated name, got %s", list[0].Name) - } - - if err := services.TicketViewService.Delete(created.ID, operator); err != nil { - t.Fatalf("TicketViewService.Delete() error = %v", err) - } - if got := services.TicketViewService.ListByUser(operator.UserID); len(got) != 0 { - t.Fatalf("expected ticket views to be deleted, got %d", len(got)) - } -} - -func TestTicketViewServiceRejectsCrossUserUpdateAndDelete(t *testing.T) { - setupTicketTestDB(t) - owner := &dto.AuthPrincipal{UserID: createTestUser(t, "owner"), Username: "owner"} - other := &dto.AuthPrincipal{UserID: createTestUser(t, "other"), Username: "other"} - - created, err := services.TicketViewService.Save(request.SaveTicketViewRequest{ - Name: "owner-view", - Filters: map[string]any{ - "quickView": "watching", - }, - }, owner) - if err != nil { - t.Fatalf("TicketViewService.Save() create error = %v", err) - } - - if _, err := services.TicketViewService.Save(request.SaveTicketViewRequest{ - ID: created.ID, - Name: "hijack", - Filters: map[string]any{ - "quickView": "all", - }, - }, other); err == nil { - t.Fatalf("expected cross-user update to fail") - } - - if err := services.TicketViewService.Delete(created.ID, other); err == nil { - t.Fatalf("expected cross-user delete to fail") - } - if got := services.TicketViewService.ListByUser(owner.UserID); len(got) != 1 { - t.Fatalf("expected owner view to remain, got %d", len(got)) - } -} - func setupTicketTestDB(t *testing.T) { t.Helper() @@ -795,32 +339,22 @@ func setupTicketTestDB(t *testing.T) { func createTestTicketRequest(title string) request.CreateTicketRequest { return request.CreateTicketRequest{ - Title: title, - Priority: 1, - Severity: int(enums.TicketSeverityMinor), + Title: title, + Description: title + " description", } } -func requestInternalNote(ticketID int64, payload string) request.InternalNoteRequest { - return request.InternalNoteRequest{ - TicketID: ticketID, - ContentType: "text", - Content: "note", - Payload: payload, - } +func createTestOperator(t *testing.T, prefix string) *dto.AuthPrincipal { + t.Helper() + userID := createTestUser(t, prefix) + return &dto.AuthPrincipal{UserID: userID, Username: prefix} } func createTestUser(t *testing.T, prefix string) int64 { - t.Helper() - return createTestUserWithID(t, 0, prefix) -} - -func createTestUserWithID(t *testing.T, id int64, prefix string) int64 { t.Helper() now := time.Now() username := fmt.Sprintf("%s_%d", prefix, now.UnixNano()) user := &models.User{ - ID: id, Username: username, Nickname: prefix, Status: enums.StatusOk, @@ -839,52 +373,6 @@ func createTestUserWithID(t *testing.T, id int64, prefix string) int64 { return user.ID } -func createTestAgentProfile(t *testing.T, prefix string) (int64, int64) { - t.Helper() - - now := time.Now() - userID := createTestUser(t, prefix) - team := &models.AgentTeam{ - Name: fmt.Sprintf("%s-team-%d", prefix, now.UnixNano()), - Status: enums.StatusOk, - Description: "test team", - AuditFields: models.AuditFields{ - CreatedAt: now, - CreateUserID: 1, - CreateUserName: "admin", - UpdatedAt: now, - UpdateUserID: 1, - UpdateUserName: "admin", - }, - } - if err := repositories.AgentTeamRepository.Create(sqls.DB(), team); err != nil { - t.Fatalf("create agent team error = %v", err) - } - - profile := &models.AgentProfile{ - UserID: userID, - TeamID: team.ID, - AgentCode: fmt.Sprintf("%s-code-%d", prefix, now.UnixNano()), - DisplayName: prefix, - ServiceStatus: enums.ServiceStatusIdle, - MaxConcurrentCount: 5, - AutoAssignEnabled: true, - Status: enums.StatusOk, - AuditFields: models.AuditFields{ - CreatedAt: now, - CreateUserID: 1, - CreateUserName: "admin", - UpdatedAt: now, - UpdateUserID: 1, - UpdateUserName: "admin", - }, - } - if err := repositories.AgentProfileRepository.Create(sqls.DB(), profile); err != nil { - t.Fatalf("create agent profile error = %v", err) - } - return team.ID, userID -} - func createTestCustomer(t *testing.T, prefix string) int64 { t.Helper() diff --git a/internal/services/ticket_sla_record_service.go b/internal/services/ticket_sla_record_service.go deleted file mode 100644 index d6d6111..0000000 --- a/internal/services/ticket_sla_record_service.go +++ /dev/null @@ -1,67 +0,0 @@ -package services - -import ( - "cs-agent/internal/models" - "cs-agent/internal/repositories" - - "github.com/mlogclub/simple/sqls" - "github.com/mlogclub/simple/web/params" -) - -var TicketSLARecordService = newTicketSLARecordService() - -func newTicketSLARecordService() *ticketSLARecordService { - return &ticketSLARecordService{} -} - -type ticketSLARecordService struct { -} - -func (s *ticketSLARecordService) Get(id int64) *models.TicketSLARecord { - return repositories.TicketSLARecordRepository.Get(sqls.DB(), id) -} - -func (s *ticketSLARecordService) Take(where ...interface{}) *models.TicketSLARecord { - return repositories.TicketSLARecordRepository.Take(sqls.DB(), where...) -} - -func (s *ticketSLARecordService) Find(cnd *sqls.Cnd) []models.TicketSLARecord { - return repositories.TicketSLARecordRepository.Find(sqls.DB(), cnd) -} - -func (s *ticketSLARecordService) FindOne(cnd *sqls.Cnd) *models.TicketSLARecord { - return repositories.TicketSLARecordRepository.FindOne(sqls.DB(), cnd) -} - -func (s *ticketSLARecordService) FindPageByParams(params *params.QueryParams) (list []models.TicketSLARecord, paging *sqls.Paging) { - return repositories.TicketSLARecordRepository.FindPageByParams(sqls.DB(), params) -} - -func (s *ticketSLARecordService) FindPageByCnd(cnd *sqls.Cnd) (list []models.TicketSLARecord, paging *sqls.Paging) { - return repositories.TicketSLARecordRepository.FindPageByCnd(sqls.DB(), cnd) -} - -func (s *ticketSLARecordService) Count(cnd *sqls.Cnd) int64 { - return repositories.TicketSLARecordRepository.Count(sqls.DB(), cnd) -} - -func (s *ticketSLARecordService) Create(t *models.TicketSLARecord) error { - return repositories.TicketSLARecordRepository.Create(sqls.DB(), t) -} - -func (s *ticketSLARecordService) Update(t *models.TicketSLARecord) error { - return repositories.TicketSLARecordRepository.Update(sqls.DB(), t) -} - -func (s *ticketSLARecordService) Updates(id int64, columns map[string]interface{}) error { - return repositories.TicketSLARecordRepository.Updates(sqls.DB(), id, columns) -} - -func (s *ticketSLARecordService) UpdateColumn(id int64, name string, value interface{}) error { - return repositories.TicketSLARecordRepository.UpdateColumn(sqls.DB(), id, name, value) -} - -func (s *ticketSLARecordService) Delete(id int64) { - repositories.TicketSLARecordRepository.Delete(sqls.DB(), id) -} - diff --git a/internal/services/ticket_watcher_service.go b/internal/services/ticket_watcher_service.go deleted file mode 100644 index e066aef..0000000 --- a/internal/services/ticket_watcher_service.go +++ /dev/null @@ -1,67 +0,0 @@ -package services - -import ( - "cs-agent/internal/models" - "cs-agent/internal/repositories" - - "github.com/mlogclub/simple/sqls" - "github.com/mlogclub/simple/web/params" -) - -var TicketWatcherService = newTicketWatcherService() - -func newTicketWatcherService() *ticketWatcherService { - return &ticketWatcherService{} -} - -type ticketWatcherService struct { -} - -func (s *ticketWatcherService) Get(id int64) *models.TicketWatcher { - return repositories.TicketWatcherRepository.Get(sqls.DB(), id) -} - -func (s *ticketWatcherService) Take(where ...interface{}) *models.TicketWatcher { - return repositories.TicketWatcherRepository.Take(sqls.DB(), where...) -} - -func (s *ticketWatcherService) Find(cnd *sqls.Cnd) []models.TicketWatcher { - return repositories.TicketWatcherRepository.Find(sqls.DB(), cnd) -} - -func (s *ticketWatcherService) FindOne(cnd *sqls.Cnd) *models.TicketWatcher { - return repositories.TicketWatcherRepository.FindOne(sqls.DB(), cnd) -} - -func (s *ticketWatcherService) FindPageByParams(params *params.QueryParams) (list []models.TicketWatcher, paging *sqls.Paging) { - return repositories.TicketWatcherRepository.FindPageByParams(sqls.DB(), params) -} - -func (s *ticketWatcherService) FindPageByCnd(cnd *sqls.Cnd) (list []models.TicketWatcher, paging *sqls.Paging) { - return repositories.TicketWatcherRepository.FindPageByCnd(sqls.DB(), cnd) -} - -func (s *ticketWatcherService) Count(cnd *sqls.Cnd) int64 { - return repositories.TicketWatcherRepository.Count(sqls.DB(), cnd) -} - -func (s *ticketWatcherService) Create(t *models.TicketWatcher) error { - return repositories.TicketWatcherRepository.Create(sqls.DB(), t) -} - -func (s *ticketWatcherService) Update(t *models.TicketWatcher) error { - return repositories.TicketWatcherRepository.Update(sqls.DB(), t) -} - -func (s *ticketWatcherService) Updates(id int64, columns map[string]interface{}) error { - return repositories.TicketWatcherRepository.Updates(sqls.DB(), id, columns) -} - -func (s *ticketWatcherService) UpdateColumn(id int64, name string, value interface{}) error { - return repositories.TicketWatcherRepository.UpdateColumn(sqls.DB(), id, name, value) -} - -func (s *ticketWatcherService) Delete(id int64) { - repositories.TicketWatcherRepository.Delete(sqls.DB(), id) -} -