Init
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
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 AgentProfileService = newAgentProfileService()
|
||||
|
||||
func newAgentProfileService() *agentProfileService {
|
||||
return &agentProfileService{}
|
||||
}
|
||||
|
||||
type agentProfileService struct {
|
||||
}
|
||||
|
||||
func (s *agentProfileService) Get(id int64) *models.AgentProfile {
|
||||
return repositories.AgentProfileRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *agentProfileService) Take(where ...interface{}) *models.AgentProfile {
|
||||
return repositories.AgentProfileRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *agentProfileService) Find(cnd *sqls.Cnd) []models.AgentProfile {
|
||||
return repositories.AgentProfileRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *agentProfileService) FindOne(cnd *sqls.Cnd) *models.AgentProfile {
|
||||
return repositories.AgentProfileRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *agentProfileService) FindPageByParams(params *params.QueryParams) (list []models.AgentProfile, paging *sqls.Paging) {
|
||||
return repositories.AgentProfileRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *agentProfileService) FindPageByCnd(cnd *sqls.Cnd) (list []models.AgentProfile, paging *sqls.Paging) {
|
||||
return repositories.AgentProfileRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *agentProfileService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.AgentProfileRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *agentProfileService) GetByUserID(userID int64) *models.AgentProfile {
|
||||
if userID <= 0 {
|
||||
return nil
|
||||
}
|
||||
return repositories.AgentProfileRepository.FindOne(sqls.DB(), sqls.NewCnd().Eq("user_id", userID))
|
||||
}
|
||||
|
||||
func (s *agentProfileService) GetUserIDsByTeamID(teamID int64) []int64 {
|
||||
if teamID <= 0 {
|
||||
return nil
|
||||
}
|
||||
list := s.Find(sqls.NewCnd().Eq("team_id", teamID))
|
||||
if len(list) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]int64, 0, len(list))
|
||||
for _, item := range list {
|
||||
if item.UserID > 0 {
|
||||
result = append(result, item.UserID)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetDispatchAgents 获取可用于分配会话的客服
|
||||
func (s *agentProfileService) GetDispatchAgents(teamIds []int64) []models.AgentProfile {
|
||||
return AgentProfileService.Find(sqls.NewCnd().
|
||||
In("team_id", teamIds).
|
||||
Eq("status", enums.StatusOk).
|
||||
Eq("auto_assign_enabled", true).
|
||||
Eq("service_status", enums.ServiceStatusIdle))
|
||||
}
|
||||
|
||||
func (s *agentProfileService) CreateAgentProfile(req request.CreateAgentProfileRequest, operator *dto.AuthPrincipal) (*models.AgentProfile, error) {
|
||||
if operator == nil {
|
||||
return nil, errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
item, err := s.buildProfileModel(0, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.AuditFields = utils.BuildAuditFields(operator)
|
||||
if err := repositories.AgentProfileRepository.Create(sqls.DB(), item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.dispatchPendingConversationsIfEligible(item)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *agentProfileService) UpdateAgentProfile(req request.UpdateAgentProfileRequest, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
current := s.Get(req.ID)
|
||||
if current == nil {
|
||||
return errorsx.InvalidParam("客服档案不存在")
|
||||
}
|
||||
item, err := s.buildProfileModel(req.ID, req.CreateAgentProfileRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := repositories.AgentProfileRepository.Updates(sqls.DB(), req.ID, map[string]any{
|
||||
"user_id": item.UserID,
|
||||
"team_id": item.TeamID,
|
||||
"agent_code": item.AgentCode,
|
||||
"display_name": item.DisplayName,
|
||||
"avatar": item.Avatar,
|
||||
"service_status": item.ServiceStatus,
|
||||
"max_concurrent_count": item.MaxConcurrentCount,
|
||||
"priority_level": item.PriorityLevel,
|
||||
"auto_assign_enabled": item.AutoAssignEnabled,
|
||||
"receive_offline_message": item.ReceiveOfflineMessage,
|
||||
"remark": item.Remark,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
s.dispatchPendingConversationsIfEligible(item)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *agentProfileService) DeleteAgentProfile(id int64) error {
|
||||
current := s.Get(id)
|
||||
if current == nil {
|
||||
return errorsx.InvalidParam("客服档案不存在")
|
||||
}
|
||||
repositories.AgentProfileRepository.Delete(sqls.DB(), id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *agentProfileService) buildProfileModel(id int64, req request.CreateAgentProfileRequest) (*models.AgentProfile, error) {
|
||||
if req.UserID <= 0 {
|
||||
return nil, errorsx.InvalidParam("请选择关联用户")
|
||||
}
|
||||
if UserService.Get(req.UserID) == nil {
|
||||
return nil, errorsx.InvalidParam("关联用户不存在")
|
||||
}
|
||||
if req.TeamID <= 0 {
|
||||
return nil, errorsx.InvalidParam("请选择所属客服组")
|
||||
}
|
||||
if AgentTeamService.Get(req.TeamID) == nil {
|
||||
return nil, errorsx.InvalidParam("所属客服组不存在")
|
||||
}
|
||||
req.AgentCode = strings.TrimSpace(req.AgentCode)
|
||||
req.DisplayName = strings.TrimSpace(req.DisplayName)
|
||||
if req.AgentCode == "" || req.DisplayName == "" {
|
||||
return nil, errorsx.InvalidParam("客服工号和展示名不能为空")
|
||||
}
|
||||
if exists := s.Take("user_id = ? AND id <> ?", req.UserID, id); exists != nil {
|
||||
return nil, errorsx.InvalidParam("该用户已存在客服档案")
|
||||
}
|
||||
if exists := s.Take("agent_code = ? AND id <> ?", req.AgentCode, id); exists != nil {
|
||||
return nil, errorsx.InvalidParam("客服工号已存在")
|
||||
}
|
||||
if !enums.IsValidServiceStatus(req.ServiceStatus) {
|
||||
return nil, errorsx.InvalidParam("客服状态不合法")
|
||||
}
|
||||
if req.MaxConcurrentCount < 0 {
|
||||
return nil, errorsx.InvalidParam("最大并发接待数不能小于 0")
|
||||
}
|
||||
return &models.AgentProfile{
|
||||
UserID: req.UserID,
|
||||
TeamID: req.TeamID,
|
||||
AgentCode: req.AgentCode,
|
||||
DisplayName: req.DisplayName,
|
||||
Avatar: strings.TrimSpace(req.Avatar),
|
||||
ServiceStatus: req.ServiceStatus,
|
||||
MaxConcurrentCount: req.MaxConcurrentCount,
|
||||
PriorityLevel: req.PriorityLevel,
|
||||
AutoAssignEnabled: req.AutoAssignEnabled,
|
||||
ReceiveOfflineMessage: req.ReceiveOfflineMessage,
|
||||
Remark: strings.TrimSpace(req.Remark),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *agentProfileService) dispatchPendingConversationsIfEligible(item *models.AgentProfile) {
|
||||
if item == nil {
|
||||
return
|
||||
}
|
||||
if item.Status != enums.StatusOk {
|
||||
return
|
||||
}
|
||||
if !item.AutoAssignEnabled || item.MaxConcurrentCount <= 0 {
|
||||
return
|
||||
}
|
||||
if item.ServiceStatus != enums.ServiceStatusIdle {
|
||||
return
|
||||
}
|
||||
_, _ = ConversationDispatchService.DispatchPendingConversations(0)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/repositories"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"github.com/mlogclub/simple/web/params"
|
||||
)
|
||||
|
||||
var AgentRunLogService = newAgentRunLogService()
|
||||
|
||||
func newAgentRunLogService() *agentRunLogService {
|
||||
return &agentRunLogService{}
|
||||
}
|
||||
|
||||
type agentRunLogService struct{}
|
||||
|
||||
func (s *agentRunLogService) Get(id int64) *models.AgentRunLog {
|
||||
return repositories.AgentRunLogRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *agentRunLogService) Take(where ...interface{}) *models.AgentRunLog {
|
||||
return repositories.AgentRunLogRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *agentRunLogService) Find(cnd *sqls.Cnd) []models.AgentRunLog {
|
||||
return repositories.AgentRunLogRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *agentRunLogService) FindOne(cnd *sqls.Cnd) *models.AgentRunLog {
|
||||
return repositories.AgentRunLogRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *agentRunLogService) FindPageByParams(params *params.QueryParams) (list []models.AgentRunLog, paging *sqls.Paging) {
|
||||
return repositories.AgentRunLogRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *agentRunLogService) FindPageByCnd(cnd *sqls.Cnd) (list []models.AgentRunLog, paging *sqls.Paging) {
|
||||
return repositories.AgentRunLogRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *agentRunLogService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.AgentRunLogRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *agentRunLogService) Create(t *models.AgentRunLog) error {
|
||||
return repositories.AgentRunLogRepository.Create(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *agentRunLogService) Update(t *models.AgentRunLog) error {
|
||||
return repositories.AgentRunLogRepository.Update(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *agentRunLogService) Updates(id int64, columns map[string]interface{}) error {
|
||||
return repositories.AgentRunLogRepository.Updates(sqls.DB(), id, columns)
|
||||
}
|
||||
|
||||
func (s *agentRunLogService) UpdateColumn(id int64, name string, value interface{}) error {
|
||||
return repositories.AgentRunLogRepository.UpdateColumn(sqls.DB(), id, name, value)
|
||||
}
|
||||
|
||||
func (s *agentRunLogService) Delete(id int64) {
|
||||
repositories.AgentRunLogRepository.Delete(sqls.DB(), id)
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
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"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"github.com/mlogclub/simple/web/params"
|
||||
)
|
||||
|
||||
var AgentTeamScheduleService = newAgentTeamScheduleService()
|
||||
|
||||
func newAgentTeamScheduleService() *agentTeamScheduleService {
|
||||
return &agentTeamScheduleService{}
|
||||
}
|
||||
|
||||
type agentTeamScheduleService struct {
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) Get(id int64) *models.AgentTeamSchedule {
|
||||
return repositories.AgentTeamScheduleRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) Take(where ...interface{}) *models.AgentTeamSchedule {
|
||||
return repositories.AgentTeamScheduleRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) Find(cnd *sqls.Cnd) []models.AgentTeamSchedule {
|
||||
return repositories.AgentTeamScheduleRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) FindOne(cnd *sqls.Cnd) *models.AgentTeamSchedule {
|
||||
return repositories.AgentTeamScheduleRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) FindPageByParams(params *params.QueryParams) (list []models.AgentTeamSchedule, paging *sqls.Paging) {
|
||||
return repositories.AgentTeamScheduleRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) FindPageByCnd(cnd *sqls.Cnd) (list []models.AgentTeamSchedule, paging *sqls.Paging) {
|
||||
return repositories.AgentTeamScheduleRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.AgentTeamScheduleRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) Create(t *models.AgentTeamSchedule) error {
|
||||
return repositories.AgentTeamScheduleRepository.Create(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) Update(t *models.AgentTeamSchedule) error {
|
||||
return repositories.AgentTeamScheduleRepository.Update(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) Updates(id int64, columns map[string]interface{}) error {
|
||||
return repositories.AgentTeamScheduleRepository.Updates(sqls.DB(), id, columns)
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) UpdateColumn(id int64, name string, value interface{}) error {
|
||||
return repositories.AgentTeamScheduleRepository.UpdateColumn(sqls.DB(), id, name, value)
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) Delete(id int64) {
|
||||
repositories.AgentTeamScheduleRepository.Delete(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) CreateAgentTeamSchedule(req request.CreateAgentTeamScheduleRequest, operator *dto.AuthPrincipal) (*models.AgentTeamSchedule, error) {
|
||||
if operator == nil {
|
||||
return nil, errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
item, err := s.buildScheduleModel(0, req.TeamID, req.StartAt, req.EndAt, req.SourceType, req.Remark)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.AuditFields = utils.BuildAuditFields(operator)
|
||||
if err := repositories.AgentTeamScheduleRepository.Create(sqls.DB(), item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.dispatchPendingConversationsIfActive(item)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) UpdateAgentTeamSchedule(req request.UpdateAgentTeamScheduleRequest, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
if s.Get(req.ID) == nil {
|
||||
return errorsx.InvalidParam("客服组排班不存在")
|
||||
}
|
||||
item, err := s.buildScheduleModel(req.ID, req.TeamID, req.StartAt, req.EndAt, req.SourceType, req.Remark)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := repositories.AgentTeamScheduleRepository.Updates(sqls.DB(), req.ID, map[string]any{
|
||||
"team_id": item.TeamID,
|
||||
"start_at": item.StartAt,
|
||||
"end_at": item.EndAt,
|
||||
"source_type": item.SourceType,
|
||||
"remark": item.Remark,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
s.dispatchPendingConversationsIfActive(item)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) DeleteAgentTeamSchedule(id int64) error {
|
||||
if s.Get(id) == nil {
|
||||
return errorsx.InvalidParam("客服组排班不存在")
|
||||
}
|
||||
repositories.AgentTeamScheduleRepository.Delete(sqls.DB(), id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) buildScheduleModel(id, teamID int64, startAt, endAt, sourceType, remark string) (*models.AgentTeamSchedule, error) {
|
||||
if teamID <= 0 {
|
||||
return nil, errorsx.InvalidParam("请选择客服组")
|
||||
}
|
||||
team := AgentTeamService.Get(teamID)
|
||||
if team == nil {
|
||||
return nil, errorsx.InvalidParam("客服组不存在")
|
||||
}
|
||||
if !slices.Contains(enums.StatusValues, team.Status) {
|
||||
return nil, errorsx.InvalidParam("客服组状态不合法")
|
||||
}
|
||||
sourceType = strings.TrimSpace(sourceType)
|
||||
if sourceType == "" {
|
||||
return nil, errorsx.InvalidParam("排班来源不能为空")
|
||||
}
|
||||
startAtValue, err := parseRequiredDateTime(startAt, "开始时间格式错误")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
endAtValue, err := parseRequiredDateTime(endAt, "结束时间格式错误")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !endAtValue.After(startAtValue) {
|
||||
return nil, errorsx.InvalidParam("结束时间必须晚于开始时间")
|
||||
}
|
||||
var count int64
|
||||
sqls.DB().Model(&models.AgentTeamSchedule{}).
|
||||
Where("team_id = ? AND id <> ? AND start_at < ? AND end_at > ?", teamID, id, endAtValue, startAtValue).
|
||||
Count(&count)
|
||||
if count > 0 {
|
||||
return nil, errorsx.InvalidParam("该客服组在所选时间段已存在排班")
|
||||
}
|
||||
return &models.AgentTeamSchedule{
|
||||
TeamID: teamID,
|
||||
StartAt: startAtValue,
|
||||
EndAt: endAtValue,
|
||||
SourceType: sourceType,
|
||||
Remark: strings.TrimSpace(remark),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseRequiredDateTime(value, message string) (time.Time, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return time.Time{}, errorsx.InvalidParam(message)
|
||||
}
|
||||
ret, err := parseDateTimeValue(value)
|
||||
if err != nil {
|
||||
return time.Time{}, errorsx.InvalidParam(message + ",请使用 yyyy-MM-dd HH:mm:ss 或 RFC3339")
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func parseDateTimeValue(value string) (time.Time, error) {
|
||||
layouts := []string{
|
||||
time.DateTime,
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04",
|
||||
"2006-01-02T15:04:05",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if ret, err := time.ParseInLocation(layout, value, time.Local); err == nil {
|
||||
return ret, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, errorsx.InvalidParam("时间格式错误")
|
||||
}
|
||||
|
||||
func (s *agentTeamScheduleService) dispatchPendingConversationsIfActive(item *models.AgentTeamSchedule) {
|
||||
if item == nil {
|
||||
return
|
||||
}
|
||||
if item.Status != enums.StatusOk {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
if item.StartAt.After(now) || !item.EndAt.After(now) {
|
||||
return
|
||||
}
|
||||
_, _ = ConversationDispatchService.DispatchPendingConversations(0)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
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 AgentTeamService = newAgentTeamService()
|
||||
|
||||
func newAgentTeamService() *agentTeamService {
|
||||
return &agentTeamService{}
|
||||
}
|
||||
|
||||
type agentTeamService struct {
|
||||
}
|
||||
|
||||
func (s *agentTeamService) Get(id int64) *models.AgentTeam {
|
||||
return repositories.AgentTeamRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *agentTeamService) Take(where ...interface{}) *models.AgentTeam {
|
||||
return repositories.AgentTeamRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *agentTeamService) Find(cnd *sqls.Cnd) []models.AgentTeam {
|
||||
return repositories.AgentTeamRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *agentTeamService) FindOne(cnd *sqls.Cnd) *models.AgentTeam {
|
||||
return repositories.AgentTeamRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *agentTeamService) FindPageByParams(params *params.QueryParams) (list []models.AgentTeam, paging *sqls.Paging) {
|
||||
return repositories.AgentTeamRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *agentTeamService) FindPageByCnd(cnd *sqls.Cnd) (list []models.AgentTeam, paging *sqls.Paging) {
|
||||
return repositories.AgentTeamRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *agentTeamService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.AgentTeamRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *agentTeamService) FindByIds(ids []int64) []models.AgentTeam {
|
||||
return repositories.AgentTeamRepository.FindByIds(sqls.DB(), ids)
|
||||
}
|
||||
|
||||
func (s *agentTeamService) Create(t *models.AgentTeam) error {
|
||||
return repositories.AgentTeamRepository.Create(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *agentTeamService) Update(t *models.AgentTeam) error {
|
||||
return repositories.AgentTeamRepository.Update(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *agentTeamService) Updates(id int64, columns map[string]interface{}) error {
|
||||
return repositories.AgentTeamRepository.Updates(sqls.DB(), id, columns)
|
||||
}
|
||||
|
||||
func (s *agentTeamService) UpdateColumn(id int64, name string, value interface{}) error {
|
||||
return repositories.AgentTeamRepository.UpdateColumn(sqls.DB(), id, name, value)
|
||||
}
|
||||
|
||||
func (s *agentTeamService) Delete(id int64) {
|
||||
repositories.AgentTeamRepository.Delete(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *agentTeamService) CreateAgentTeam(req request.CreateAgentTeamRequest, operator *dto.AuthPrincipal) (*models.AgentTeam, error) {
|
||||
if operator == nil {
|
||||
return nil, errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
item, err := s.buildTeamModel(0, req.Name, req.LeaderUserID, req.Status, req.Description, req.Remark)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.AuditFields = utils.BuildAuditFields(operator)
|
||||
if err := repositories.AgentTeamRepository.Create(sqls.DB(), item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *agentTeamService) UpdateAgentTeam(req request.UpdateAgentTeamRequest, 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.buildTeamModel(req.ID, req.Name, req.LeaderUserID, req.Status, req.Description, req.Remark)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
return repositories.AgentTeamRepository.Updates(sqls.DB(), req.ID, map[string]any{
|
||||
"name": item.Name,
|
||||
"leader_user_id": item.LeaderUserID,
|
||||
"status": item.Status,
|
||||
"description": item.Description,
|
||||
"remark": item.Remark,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": now,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *agentTeamService) DeleteAgentTeam(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 AgentProfileService.Take("team_id = ?", id) != nil {
|
||||
return errorsx.Forbidden("客服组下仍有关联客服档案,无法删除")
|
||||
}
|
||||
if AgentTeamScheduleService.Take("team_id = ?", id) != nil {
|
||||
return errorsx.Forbidden("客服组下仍有关联组排班,无法删除")
|
||||
}
|
||||
if AIAgentService.Take(
|
||||
"(team_ids = ? OR team_ids LIKE ? OR team_ids LIKE ? OR team_ids LIKE ?) AND status <> ?",
|
||||
utils.JoinInt64s([]int64{id}),
|
||||
utils.JoinInt64s([]int64{id})+",%",
|
||||
"%,"+utils.JoinInt64s([]int64{id}),
|
||||
"%,"+utils.JoinInt64s([]int64{id})+",%",
|
||||
enums.StatusDeleted,
|
||||
) != nil {
|
||||
return errorsx.Forbidden("客服组下仍有关联 AI Agent,无法删除")
|
||||
}
|
||||
return repositories.AgentTeamRepository.Updates(sqls.DB(), id, map[string]any{
|
||||
"status": enums.StatusDeleted,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *agentTeamService) buildTeamModel(id int64, name string, leaderUserID int64, status int, description, remark string) (*models.AgentTeam, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, errorsx.InvalidParam("客服组名称不能为空")
|
||||
}
|
||||
if exists := s.Take("name = ? AND status <> ? AND id <> ?", name, enums.StatusDeleted, id); exists != nil {
|
||||
return nil, errorsx.InvalidParam("客服组名称已存在")
|
||||
}
|
||||
if leaderUserID > 0 && UserService.Get(leaderUserID) == nil {
|
||||
return nil, errorsx.InvalidParam("组长用户不存在")
|
||||
}
|
||||
if status != 0 && status != 1 {
|
||||
return nil, errorsx.InvalidParam("客服组状态不合法")
|
||||
}
|
||||
return &models.AgentTeam{
|
||||
Name: name,
|
||||
LeaderUserID: leaderUserID,
|
||||
Status: enums.Status(status),
|
||||
Description: strings.TrimSpace(description),
|
||||
Remark: strings.TrimSpace(remark),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/config"
|
||||
"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 AIAgentService = newAIAgentService()
|
||||
|
||||
func newAIAgentService() *aIAgentService {
|
||||
return &aIAgentService{}
|
||||
}
|
||||
|
||||
type aIAgentService struct {
|
||||
}
|
||||
|
||||
func (s *aIAgentService) Get(id int64) *models.AIAgent {
|
||||
if id <= 0 {
|
||||
return nil
|
||||
}
|
||||
return repositories.AIAgentRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *aIAgentService) Take(where ...interface{}) *models.AIAgent {
|
||||
return repositories.AIAgentRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *aIAgentService) Find(cnd *sqls.Cnd) []models.AIAgent {
|
||||
return repositories.AIAgentRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *aIAgentService) FindOne(cnd *sqls.Cnd) *models.AIAgent {
|
||||
return repositories.AIAgentRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *aIAgentService) FindPageByParams(params *params.QueryParams) (list []models.AIAgent, paging *sqls.Paging) {
|
||||
return repositories.AIAgentRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *aIAgentService) FindPageByCnd(cnd *sqls.Cnd) (list []models.AIAgent, paging *sqls.Paging) {
|
||||
return repositories.AIAgentRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *aIAgentService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.AIAgentRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *aIAgentService) FindByIds(ids []int64) []models.AIAgent {
|
||||
return repositories.AIAgentRepository.FindByIds(sqls.DB(), ids)
|
||||
}
|
||||
|
||||
func (s *aIAgentService) CreateAIAgent(req request.CreateAIAgentRequest, operator *dto.AuthPrincipal) (*models.AIAgent, error) {
|
||||
if operator == nil {
|
||||
return nil, errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
item, err := s.buildAIAgentModel(0, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Status = enums.StatusOk
|
||||
item.SortNo = 0
|
||||
item.AuditFields = utils.BuildAuditFields(operator)
|
||||
if err := repositories.AIAgentRepository.Create(sqls.DB(), item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *aIAgentService) UpdateAIAgent(req request.UpdateAIAgentRequest, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
if s.Get(req.ID) == nil {
|
||||
return errorsx.InvalidParam("AI Agent 不存在")
|
||||
}
|
||||
item, err := s.buildAIAgentModel(req.ID, req.CreateAIAgentRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return repositories.AIAgentRepository.Updates(sqls.DB(), req.ID, map[string]any{
|
||||
"name": item.Name,
|
||||
"description": item.Description,
|
||||
"ai_config_id": item.AIConfigID,
|
||||
"service_mode": item.ServiceMode,
|
||||
"system_prompt": item.SystemPrompt,
|
||||
"welcome_message": item.WelcomeMessage,
|
||||
"reply_timeout_seconds": item.ReplyTimeoutSeconds,
|
||||
"team_ids": item.TeamIDs,
|
||||
"handoff_mode": item.HandoffMode,
|
||||
"max_ai_reply_rounds": item.MaxAIReplyRounds,
|
||||
"fallback_mode": item.FallbackMode,
|
||||
"fallback_message": item.FallbackMessage,
|
||||
"knowledge_ids": item.KnowledgeIDs,
|
||||
"skill_ids": item.SkillIDs,
|
||||
"allowed_mcp_tools": item.AllowedMCPTools,
|
||||
"remark": item.Remark,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *aIAgentService) DeleteAIAgent(id int64, operator *dto.AuthPrincipal) error {
|
||||
current := s.Get(id)
|
||||
if current == nil {
|
||||
return errorsx.InvalidParam("AI Agent 不存在")
|
||||
}
|
||||
if ChannelService.Take("ai_agent_id = ?", id) != nil {
|
||||
return errorsx.Forbidden("已有接入渠道绑定该 AI Agent,无法删除")
|
||||
}
|
||||
return repositories.AIAgentRepository.Updates(sqls.DB(), id, map[string]any{
|
||||
"status": enums.StatusDeleted,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *aIAgentService) buildAIAgentModel(id int64, req request.CreateAIAgentRequest) (*models.AIAgent, error) {
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
return nil, errorsx.InvalidParam("AI Agent 名称不能为空")
|
||||
}
|
||||
if exists := s.Take("name = ? AND id <> ?", name, id); exists != nil {
|
||||
return nil, errorsx.InvalidParam("AI Agent 名称已存在")
|
||||
}
|
||||
if req.AIConfigID <= 0 {
|
||||
return nil, errorsx.InvalidParam("AI 配置不能为空")
|
||||
}
|
||||
aiConfig := AIConfigService.Get(req.AIConfigID)
|
||||
if aiConfig == nil {
|
||||
return nil, errorsx.InvalidParam("AI 配置不存在")
|
||||
}
|
||||
if aiConfig.Status != enums.StatusOk {
|
||||
return nil, errorsx.InvalidParam("AI 配置未启用")
|
||||
}
|
||||
if !slices.Contains(enums.IMConversationServiceModeValues, req.ServiceMode) {
|
||||
return nil, errorsx.InvalidParam("服务模式不合法")
|
||||
}
|
||||
teamIDs, err := s.normalizeTeamIDs(req.TeamIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !slices.Contains(enums.AIAgentHandoffModeValues, enums.AIAgentHandoffMode(req.HandoffMode)) {
|
||||
return nil, errorsx.InvalidParam("转人工模式不合法")
|
||||
}
|
||||
if enums.AIAgentHandoffMode(req.HandoffMode) == enums.AIAgentHandoffModeDefaultTeamPool && len(teamIDs) == 0 {
|
||||
return nil, errorsx.InvalidParam("默认客服组待接入池模式必须至少选择一个客服组")
|
||||
}
|
||||
|
||||
if !slices.Contains(enums.AIAgentFallbackModeValues, enums.AIAgentFallbackMode(req.FallbackMode)) {
|
||||
return nil, errorsx.InvalidParam("兜底模式不合法")
|
||||
}
|
||||
if req.ReplyTimeoutSeconds < 0 {
|
||||
return nil, errorsx.InvalidParam("回复超时秒数不能小于 0")
|
||||
}
|
||||
|
||||
knowledgeIDs, err := s.normalizeKnowledgeIDs(req.KnowledgeIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(knowledgeIDs) == 0 {
|
||||
return nil, errorsx.InvalidParam("请至少选择一个知识库")
|
||||
}
|
||||
skillIDs, err := s.normalizeSkillIDs(req.SkillIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
directTools, err := s.normalizeDirectTools(req.DirectTools)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
directToolsJSON := ""
|
||||
if len(directTools) > 0 {
|
||||
buf, marshalErr := json.Marshal(directTools)
|
||||
if marshalErr != nil {
|
||||
return nil, errorsx.InvalidParam("Direct Tools 配置格式不合法")
|
||||
}
|
||||
directToolsJSON = string(buf)
|
||||
}
|
||||
return &models.AIAgent{
|
||||
Name: name,
|
||||
Description: strings.TrimSpace(req.Description),
|
||||
AIConfigID: req.AIConfigID,
|
||||
ServiceMode: req.ServiceMode,
|
||||
SystemPrompt: strings.TrimSpace(req.SystemPrompt),
|
||||
WelcomeMessage: strings.TrimSpace(req.WelcomeMessage),
|
||||
ReplyTimeoutSeconds: req.ReplyTimeoutSeconds,
|
||||
TeamIDs: utils.JoinInt64s(teamIDs),
|
||||
HandoffMode: req.HandoffMode,
|
||||
MaxAIReplyRounds: req.MaxAIReplyRounds,
|
||||
FallbackMode: req.FallbackMode,
|
||||
FallbackMessage: strings.TrimSpace(req.FallbackMessage),
|
||||
KnowledgeIDs: utils.JoinInt64s(knowledgeIDs),
|
||||
SkillIDs: utils.JoinInt64s(skillIDs),
|
||||
AllowedMCPTools: directToolsJSON,
|
||||
Remark: strings.TrimSpace(req.Remark),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *aIAgentService) normalizeTeamIDs(input []int64) ([]int64, error) {
|
||||
ret := make([]int64, 0, len(input))
|
||||
seen := make(map[int64]struct{})
|
||||
for _, id := range input {
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
continue
|
||||
}
|
||||
team := AgentTeamService.Get(id)
|
||||
if team == nil {
|
||||
return nil, errorsx.InvalidParam("客服组不存在")
|
||||
}
|
||||
if team.Status != enums.StatusOk {
|
||||
return nil, errorsx.InvalidParam("客服组未启用")
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ret = append(ret, id)
|
||||
}
|
||||
slices.Sort(ret)
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (s *aIAgentService) normalizeKnowledgeIDs(input []int64) ([]int64, error) {
|
||||
ret := make([]int64, 0, len(input))
|
||||
seen := make(map[int64]struct{})
|
||||
for _, id := range input {
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
continue
|
||||
}
|
||||
kb := KnowledgeBaseService.Get(id)
|
||||
if kb == nil {
|
||||
return nil, errorsx.InvalidParam("知识库不存在")
|
||||
}
|
||||
if kb.Status != enums.StatusOk {
|
||||
return nil, errorsx.InvalidParam("知识库未启用")
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ret = append(ret, id)
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (s *aIAgentService) normalizeSkillIDs(input []int64) ([]int64, error) {
|
||||
ret := make([]int64, 0, len(input))
|
||||
seen := make(map[int64]struct{})
|
||||
for _, id := range input {
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
continue
|
||||
}
|
||||
skill := SkillDefinitionService.Get(id)
|
||||
if skill == nil {
|
||||
return nil, errorsx.InvalidParam("Skill 不存在")
|
||||
}
|
||||
if skill.Status != enums.StatusOk {
|
||||
return nil, errorsx.InvalidParam("Skill 未启用")
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ret = append(ret, id)
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (s *aIAgentService) normalizeDirectTools(input []request.AIAgentMCPToolRequest) ([]request.AIAgentMCPToolRequest, error) {
|
||||
if len(input) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
cfg := config.Current()
|
||||
if !cfg.MCP.Enabled {
|
||||
return nil, errorsx.InvalidParam("系统未启用 MCP,不能配置 Direct Tool")
|
||||
}
|
||||
ret := make([]request.AIAgentMCPToolRequest, 0, len(input))
|
||||
seen := make(map[string]struct{})
|
||||
for _, item := range input {
|
||||
serverCode := strings.TrimSpace(item.ServerCode)
|
||||
toolName := strings.TrimSpace(item.ToolName)
|
||||
if serverCode == "" || toolName == "" {
|
||||
return nil, errorsx.InvalidParam("Direct Tool 的 serverCode 和 toolName 不能为空")
|
||||
}
|
||||
server, ok := cfg.MCP.Servers[serverCode]
|
||||
if !ok || !server.Enabled {
|
||||
return nil, errorsx.InvalidParam("Direct Tool 绑定的 MCP 服务不存在或未启用")
|
||||
}
|
||||
key := serverCode + "/" + toolName
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
normalized := request.AIAgentMCPToolRequest{
|
||||
ServerCode: serverCode,
|
||||
ToolName: toolName,
|
||||
Title: strings.TrimSpace(item.Title),
|
||||
Description: strings.TrimSpace(item.Description),
|
||||
}
|
||||
if len(item.Arguments) > 0 {
|
||||
normalized.Arguments = make(map[string]string, len(item.Arguments))
|
||||
for key, value := range item.Arguments {
|
||||
key = strings.TrimSpace(key)
|
||||
value = strings.TrimSpace(value)
|
||||
if key == "" || value == "" {
|
||||
continue
|
||||
}
|
||||
normalized.Arguments[key] = value
|
||||
}
|
||||
}
|
||||
ret = append(ret, normalized)
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (s *aIAgentService) UpdateSort(ids []int64) error {
|
||||
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
for i, id := range ids {
|
||||
if err := repositories.AIAgentRepository.UpdateColumn(ctx.Tx, id, "sort_no", i+1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *aIAgentService) UpdateStatus(id int64, status int, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
current := s.Get(id)
|
||||
if current == nil {
|
||||
return errorsx.InvalidParam("AI Agent 不存在")
|
||||
}
|
||||
if status != int(enums.StatusOk) && status != int(enums.StatusDisabled) {
|
||||
return errorsx.InvalidParam("状态值不合法")
|
||||
}
|
||||
|
||||
return repositories.AIAgentRepository.Updates(sqls.DB(), id, map[string]any{
|
||||
"status": status,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
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/common/strs"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"github.com/mlogclub/simple/web/params"
|
||||
)
|
||||
|
||||
var AIConfigService = newAIConfigService()
|
||||
|
||||
func newAIConfigService() *aIConfigService {
|
||||
return &aIConfigService{}
|
||||
}
|
||||
|
||||
type aIConfigService struct {
|
||||
}
|
||||
|
||||
func (s *aIConfigService) Get(id int64) *models.AIConfig {
|
||||
return repositories.AIConfigRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *aIConfigService) Take(where ...interface{}) *models.AIConfig {
|
||||
return repositories.AIConfigRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *aIConfigService) Find(cnd *sqls.Cnd) []models.AIConfig {
|
||||
return repositories.AIConfigRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *aIConfigService) FindOne(cnd *sqls.Cnd) *models.AIConfig {
|
||||
return repositories.AIConfigRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *aIConfigService) FindPageByParams(params *params.QueryParams) (list []models.AIConfig, paging *sqls.Paging) {
|
||||
return repositories.AIConfigRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *aIConfigService) FindPageByCnd(cnd *sqls.Cnd) (list []models.AIConfig, paging *sqls.Paging) {
|
||||
return repositories.AIConfigRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *aIConfigService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.AIConfigRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *aIConfigService) Create(t *models.AIConfig) error {
|
||||
return repositories.AIConfigRepository.Create(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *aIConfigService) Update(t *models.AIConfig) error {
|
||||
return repositories.AIConfigRepository.Update(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *aIConfigService) Updates(id int64, columns map[string]interface{}) error {
|
||||
return repositories.AIConfigRepository.Updates(sqls.DB(), id, columns)
|
||||
}
|
||||
|
||||
func (s *aIConfigService) UpdateColumn(id int64, name string, value interface{}) error {
|
||||
return repositories.AIConfigRepository.UpdateColumn(sqls.DB(), id, name, value)
|
||||
}
|
||||
|
||||
func (s *aIConfigService) Delete(id int64) {
|
||||
repositories.AIConfigRepository.Delete(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *aIConfigService) CreateAIConfig(req request.CreateAIConfigRequest, operator *dto.AuthPrincipal) (*models.AIConfig, error) {
|
||||
if operator == nil {
|
||||
return nil, errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
item, err := s.buildAIConfigModel(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
item.Status = enums.StatusDisabled
|
||||
item.SortNo = s.nextSortNo()
|
||||
item.AuditFields = utils.BuildAuditFields(operator)
|
||||
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
if err := ctx.Tx.Create(item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *aIConfigService) UpdateAIConfig(req request.UpdateAIConfigRequest, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
current := s.Get(req.ID)
|
||||
if current == nil {
|
||||
return errorsx.InvalidParam("AI配置不存在")
|
||||
}
|
||||
item, err := s.buildAIConfigModel(req.CreateAIConfigRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return repositories.AIConfigRepository.Updates(sqls.DB(), req.ID, map[string]any{
|
||||
"name": item.Name,
|
||||
"provider": item.Provider,
|
||||
"base_url": item.BaseURL,
|
||||
"api_key": item.APIKey,
|
||||
"model_type": item.ModelType,
|
||||
"model_name": item.ModelName,
|
||||
"dimension": item.Dimension,
|
||||
"max_context_tokens": item.MaxContextTokens,
|
||||
"max_output_tokens": item.MaxOutputTokens,
|
||||
"timeout_ms": item.TimeoutMS,
|
||||
"max_retry_count": item.MaxRetryCount,
|
||||
"rpm_limit": item.RPMLimit,
|
||||
"tpm_limit": item.TPMLimit,
|
||||
"remark": item.Remark,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *aIConfigService) DeleteAIConfig(id int64, operator *dto.AuthPrincipal) error {
|
||||
current := s.Get(id)
|
||||
if current == nil {
|
||||
return nil
|
||||
}
|
||||
if current.Status == enums.StatusOk {
|
||||
return errorsx.Forbidden("启用中的AI配置不允许删除")
|
||||
}
|
||||
return repositories.AIConfigRepository.Updates(sqls.DB(), id, map[string]any{
|
||||
"status": enums.StatusDeleted,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *aIConfigService) UpdateStatus(id int64, status enums.Status, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
current := s.Get(id)
|
||||
if current == nil {
|
||||
return errorsx.InvalidParam("AI配置不存在")
|
||||
}
|
||||
if status != enums.StatusOk && status != enums.StatusDisabled {
|
||||
return errorsx.InvalidParam("状态值不合法")
|
||||
}
|
||||
|
||||
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
if status == enums.StatusOk {
|
||||
if err := s.disableOthersByModelType(ctx, current.ModelType, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return repositories.AIConfigRepository.Updates(ctx.Tx, id, map[string]any{
|
||||
"status": status,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *aIConfigService) disableOthersByModelType(ctx *sqls.TxContext, modelType enums.AIModelType, excludeID int64) error {
|
||||
query := ctx.Tx.Model(&models.AIConfig{}).Where("model_type = ?", modelType)
|
||||
if excludeID > 0 {
|
||||
query = query.Where("id <> ?", excludeID)
|
||||
}
|
||||
return query.Updates(map[string]any{
|
||||
"status": int(enums.StatusDisabled),
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (s *aIConfigService) UpdateSort(ids []int64) error {
|
||||
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
for i, id := range ids {
|
||||
if err := repositories.AIConfigRepository.UpdateColumn(ctx.Tx, id, "sort_no", i); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *aIConfigService) buildAIConfigModel(req request.CreateAIConfigRequest) (*models.AIConfig, error) {
|
||||
name := strings.TrimSpace(req.Name)
|
||||
baseURL := strings.TrimSpace(req.BaseURL)
|
||||
modelName := strings.TrimSpace(req.ModelName)
|
||||
|
||||
if name == "" {
|
||||
return nil, errorsx.InvalidParam("配置名称不能为空")
|
||||
}
|
||||
if strs.IsBlank(string(req.Provider)) {
|
||||
return nil, errorsx.InvalidParam("供应商不能为空")
|
||||
}
|
||||
if baseURL == "" {
|
||||
return nil, errorsx.InvalidParam("基础地址不能为空")
|
||||
}
|
||||
if strs.IsBlank(string(req.ModelType)) {
|
||||
return nil, errorsx.InvalidParam("模型类型不能为空")
|
||||
}
|
||||
if modelName == "" {
|
||||
return nil, errorsx.InvalidParam("模型名称不能为空")
|
||||
}
|
||||
if req.Dimension < 0 {
|
||||
req.Dimension = 0
|
||||
}
|
||||
if req.MaxContextTokens < 0 {
|
||||
req.MaxContextTokens = 0
|
||||
}
|
||||
if req.MaxOutputTokens < 0 {
|
||||
req.MaxOutputTokens = 0
|
||||
}
|
||||
if req.TimeoutMS <= 0 {
|
||||
req.TimeoutMS = 30000
|
||||
}
|
||||
if req.MaxRetryCount < 0 {
|
||||
req.MaxRetryCount = 0
|
||||
}
|
||||
if req.RPMLimit < 0 {
|
||||
req.RPMLimit = 0
|
||||
}
|
||||
if req.TPMLimit < 0 {
|
||||
req.TPMLimit = 0
|
||||
}
|
||||
|
||||
return &models.AIConfig{
|
||||
Name: name,
|
||||
Provider: req.Provider,
|
||||
BaseURL: baseURL,
|
||||
APIKey: strings.TrimSpace(req.APIKey),
|
||||
ModelType: req.ModelType,
|
||||
ModelName: modelName,
|
||||
Dimension: req.Dimension,
|
||||
MaxContextTokens: req.MaxContextTokens,
|
||||
MaxOutputTokens: req.MaxOutputTokens,
|
||||
TimeoutMS: req.TimeoutMS,
|
||||
MaxRetryCount: req.MaxRetryCount,
|
||||
RPMLimit: req.RPMLimit,
|
||||
TPMLimit: req.TPMLimit,
|
||||
Remark: strings.TrimSpace(req.Remark),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *aIConfigService) nextSortNo() int {
|
||||
if latest := s.FindOne(sqls.NewCnd().Desc("sort_no").Desc("id")); latest != nil {
|
||||
return latest.SortNo + 1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package services
|
||||
|
||||
import "cs-agent/internal/models"
|
||||
|
||||
var TriggerAIReplyAsyncHook func(conversation models.Conversation, message models.Message)
|
||||
@@ -0,0 +1,201 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/config"
|
||||
"cs-agent/internal/pkg/dto"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"cs-agent/internal/pkg/errorsx"
|
||||
"cs-agent/internal/pkg/utils"
|
||||
"cs-agent/internal/repositories"
|
||||
"cs-agent/internal/services/storage"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
var AssetService = newAssetService()
|
||||
|
||||
func newAssetService() *assetService {
|
||||
return &assetService{}
|
||||
}
|
||||
|
||||
type assetService struct {
|
||||
}
|
||||
|
||||
func (s *assetService) Get(id int64) *models.Asset {
|
||||
return repositories.AssetRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *assetService) GetByAssetID(assetID string) *models.Asset {
|
||||
return repositories.AssetRepository.GetByAssetID(sqls.DB(), strings.TrimSpace(assetID))
|
||||
}
|
||||
|
||||
func (s *assetService) GetByStorageKey(storageKey string) *models.Asset {
|
||||
return repositories.AssetRepository.GetByStorageKey(sqls.DB(), strings.TrimSpace(storageKey))
|
||||
}
|
||||
|
||||
func (s *assetService) FindPageByCnd(cnd *sqls.Cnd) (list []models.Asset, paging *sqls.Paging) {
|
||||
return repositories.AssetRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *assetService) OpenReader(asset *models.Asset) (io.ReadCloser, error) {
|
||||
cfg := config.Current()
|
||||
if asset == nil {
|
||||
return nil, errorsx.InvalidParam("图片资源不存在")
|
||||
}
|
||||
switch asset.Provider {
|
||||
case "", enums.AssetProviderLocal:
|
||||
return storage.NewLocalStorage(cfg.Storage.Local).Read(asset.StorageKey)
|
||||
case enums.AssetProviderOSS:
|
||||
return storage.NewOSSStorage(cfg.Storage.OSS).Read(asset.StorageKey)
|
||||
default:
|
||||
return nil, errorsx.InvalidParam("当前暂不支持该存储类型的文件读取")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *assetService) UploadBytes(data []byte, prefix, filename string, principal *dto.AuthPrincipal) (*models.Asset, error) {
|
||||
src := bytes.NewReader(data)
|
||||
return s.Upload(src, storage.UploadInfo{
|
||||
Prefix: prefix,
|
||||
Filename: filename,
|
||||
FileSize: int64(len(data)),
|
||||
MimeType: http.DetectContentType(data),
|
||||
Principal: principal,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *assetService) UploadFile(file *multipart.FileHeader, prefix string, principal *dto.AuthPrincipal) (*models.Asset, error) {
|
||||
if file == nil {
|
||||
return nil, errorsx.InvalidParam("请选择上传文件")
|
||||
}
|
||||
|
||||
cfg := config.Current()
|
||||
if file.Size > cfg.Storage.MaxUploadSizeBytes() {
|
||||
return nil, errorsx.InvalidParam("上传文件超过大小限制")
|
||||
}
|
||||
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = src.Close() }()
|
||||
|
||||
return s.Upload(src, storage.UploadInfo{
|
||||
Prefix: prefix,
|
||||
Filename: file.Filename,
|
||||
FileSize: file.Size,
|
||||
MimeType: file.Header.Get("Content-Type"),
|
||||
Principal: principal,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *assetService) Upload(reader io.Reader, info storage.UploadInfo) (*models.Asset, error) {
|
||||
provider, err := storage.GetDefault()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
assetID, key := storage.GenerateStorageKey(info)
|
||||
item := &models.Asset{
|
||||
AssetID: assetID,
|
||||
Provider: provider.ProviderType(),
|
||||
StorageKey: key,
|
||||
URL: provider.GetURL(key),
|
||||
Filename: info.Filename,
|
||||
FileSize: info.FileSize,
|
||||
MimeType: info.MimeType,
|
||||
Status: enums.AssetStatusPending,
|
||||
AuditFields: utils.BuildAuditFields(info.Principal),
|
||||
}
|
||||
if err := repositories.AssetRepository.Create(sqls.DB(), item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := provider.Upload(reader, key, storage.UploadInfo{
|
||||
Prefix: info.Prefix,
|
||||
Filename: info.Filename,
|
||||
FileSize: info.FileSize,
|
||||
MimeType: info.MimeType,
|
||||
Principal: info.Principal,
|
||||
}); err != nil {
|
||||
_ = s.markAssetStatus(item.ID, enums.AssetStatusFailed, info.Principal)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
item.Status = enums.AssetStatusSuccess
|
||||
_ = repositories.AssetRepository.UpdateColumn(sqls.DB(), item.ID, "status", enums.AssetStatusSuccess)
|
||||
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *assetService) GetSignedURL(id int64) (string, error) {
|
||||
item := s.Get(id)
|
||||
if item == nil {
|
||||
return "", errorsx.InvalidParam("文件不存在")
|
||||
}
|
||||
if item.Status != enums.AssetStatusSuccess {
|
||||
return "", errorsx.InvalidParam("文件不可访问")
|
||||
}
|
||||
|
||||
provider, err := storage.NewProvider(item.Provider)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
accessURL := provider.GetSignedURL(item.StorageKey)
|
||||
return accessURL, nil
|
||||
}
|
||||
|
||||
func (s *assetService) DeleteAsset(id int64, principal *dto.AuthPrincipal) error {
|
||||
if principal == nil {
|
||||
return errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
item := s.Get(id)
|
||||
if item == nil {
|
||||
return errorsx.InvalidParam("文件不存在")
|
||||
}
|
||||
return repositories.AssetRepository.Updates(sqls.DB(), id, map[string]any{
|
||||
"status": enums.AssetStatusDeleted,
|
||||
"update_user_id": principal.UserID,
|
||||
"update_user_name": principal.Username,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *assetService) markAssetStatus(id int64, status enums.AssetStatus, principal *dto.AuthPrincipal) error {
|
||||
updates := map[string]any{
|
||||
"status": status,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
if principal != nil {
|
||||
updates["update_user_id"] = principal.UserID
|
||||
updates["update_user_name"] = principal.Username
|
||||
}
|
||||
return repositories.AssetRepository.Updates(sqls.DB(), id, updates)
|
||||
}
|
||||
|
||||
func (s *assetService) buildFilenameFromMime(mimeType string) string {
|
||||
mimeType = strings.TrimSpace(strings.Split(mimeType, ";")[0])
|
||||
ext := ".bin"
|
||||
switch mimeType {
|
||||
case "image/jpeg":
|
||||
ext = ".jpg"
|
||||
case "image/png":
|
||||
ext = ".png"
|
||||
case "image/gif":
|
||||
ext = ".gif"
|
||||
case "image/webp":
|
||||
ext = ".webp"
|
||||
case "application/pdf":
|
||||
ext = ".pdf"
|
||||
case "text/plain":
|
||||
ext = ".txt"
|
||||
}
|
||||
return "wxwork_" + strings.ReplaceAll(uuid.NewString(), "-", "") + ext
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/config"
|
||||
"cs-agent/internal/pkg/constants"
|
||||
"cs-agent/internal/pkg/dto"
|
||||
"cs-agent/internal/pkg/dto/request"
|
||||
"cs-agent/internal/pkg/dto/response"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"cs-agent/internal/pkg/errorsx"
|
||||
"cs-agent/internal/repositories"
|
||||
"encoding/hex"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kataras/iris/v12"
|
||||
"github.com/mlogclub/simple/common/strs"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
authPrincipalContextKey = "authPrincipal"
|
||||
)
|
||||
|
||||
var AuthService = newAuthService()
|
||||
|
||||
func newAuthService() *authService {
|
||||
return &authService{}
|
||||
}
|
||||
|
||||
type authService struct {
|
||||
}
|
||||
|
||||
func (s *authService) GetAuthPrincipal(ctx iris.Context) *dto.AuthPrincipal {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
v := ctx.Values().Get(authPrincipalContextKey)
|
||||
if principal, ok := v.(*dto.AuthPrincipal); ok {
|
||||
return principal
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *authService) setAuthPrincipal(ctx iris.Context, user *models.User, roles, permissions []string) *dto.AuthPrincipal {
|
||||
principal := &dto.AuthPrincipal{
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Nickname: user.Nickname,
|
||||
Avatar: user.Avatar,
|
||||
Status: user.Status,
|
||||
Roles: roles,
|
||||
Permissions: permissions,
|
||||
}
|
||||
ctx.Values().Set(authPrincipalContextKey, principal)
|
||||
return principal
|
||||
}
|
||||
|
||||
func (s *authService) RequirePermission(ctx iris.Context, permission constants.Permission) (principal *dto.AuthPrincipal, err error) {
|
||||
if principal = s.GetAuthPrincipal(ctx); principal == nil {
|
||||
if principal, err = s.Authenticate(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if principal == nil {
|
||||
return nil, errorsx.Forbidden("无权限执行该操作")
|
||||
}
|
||||
|
||||
if !s.HasPermission(ctx, permission.Code) {
|
||||
return principal, errorsx.Forbidden("无权限执行该操作")
|
||||
}
|
||||
return principal, nil
|
||||
}
|
||||
|
||||
func (s *authService) Login(req request.LoginRequest, authCfg config.AuthConfig, clientIP, userAgent string) (*response.LoginResponse, error) {
|
||||
username := strings.TrimSpace(req.Username)
|
||||
password := req.Password
|
||||
if username == "" || strings.TrimSpace(password) == "" {
|
||||
return nil, errorsx.InvalidParam("用户名和密码不能为空")
|
||||
}
|
||||
|
||||
user := UserService.GetByUsername(username)
|
||||
if user == nil || user.Status != enums.StatusOk {
|
||||
_ = s.createLoginCredentialLog(username, 0, false, clientIP, userAgent, "user not found")
|
||||
return nil, errorsx.InvalidAccount("用户名或密码错误")
|
||||
}
|
||||
if strs.IsBlank(user.Password) || bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) != nil {
|
||||
_ = s.createLoginCredentialLog(username, user.ID, false, clientIP, userAgent, "password mismatch")
|
||||
return nil, errorsx.InvalidAccount("用户名或密码错误")
|
||||
}
|
||||
|
||||
var ret *response.LoginResponse
|
||||
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
var dbErr error
|
||||
ret, dbErr = s.issueTokens(ctx, user, clientIP, userAgent, authCfg)
|
||||
if dbErr != nil {
|
||||
return dbErr
|
||||
}
|
||||
if dbErr = repositories.UserRepository.Updates(ctx.Tx, user.ID, map[string]any{
|
||||
"last_login_at": time.Now(),
|
||||
"last_login_ip": clientIP,
|
||||
"update_user_id": user.ID,
|
||||
"update_user_name": user.Username,
|
||||
"updated_at": time.Now(),
|
||||
}); dbErr != nil {
|
||||
return dbErr
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_ = s.createLoginCredentialLog(username, user.ID, true, clientIP, userAgent, "")
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (s *authService) RefreshToken(refreshToken string, authCfg config.AuthConfig, clientIP, userAgent string) (*response.LoginResponse, error) {
|
||||
session, err := s.validateSessionToken(refreshToken, constants.TokenTypeRefresh)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if session.RevokedAt != nil {
|
||||
return nil, errorsx.InvalidToken("refresh token 已失效")
|
||||
}
|
||||
|
||||
user := UserService.Get(session.UserID)
|
||||
if user == nil || user.Status != enums.StatusOk {
|
||||
return nil, errorsx.Unauthorized("用户不存在或已被禁用")
|
||||
}
|
||||
|
||||
var ret *response.LoginResponse
|
||||
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
var dbErr error
|
||||
if dbErr = repositories.LoginSessionRepository.Updates(ctx.Tx, session.ID, map[string]any{
|
||||
"revoked_at": time.Now(),
|
||||
"update_user_id": user.ID,
|
||||
"update_user_name": user.Username,
|
||||
"updated_at": time.Now(),
|
||||
}); dbErr != nil {
|
||||
return dbErr
|
||||
}
|
||||
if ret, dbErr = s.issueTokens(ctx, user, clientIP, userAgent, authCfg); dbErr != nil {
|
||||
return dbErr
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (s *authService) Logout(accessToken, refreshToken string) error {
|
||||
accessToken = s.extractBearerToken(accessToken)
|
||||
now := time.Now()
|
||||
if accessToken != "" {
|
||||
if session := LoginSessionService.FindOne(sqls.NewCnd().Eq("token_id", accessToken).Eq("token_type", constants.TokenTypeAccess)); session != nil && session.RevokedAt == nil {
|
||||
if err := LoginSessionService.Updates(session.ID, map[string]any{
|
||||
"revoked_at": now,
|
||||
"updated_at": now,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if refreshToken != "" {
|
||||
if session := LoginSessionService.FindOne(sqls.NewCnd().Eq("token_id", refreshToken).Eq("token_type", constants.TokenTypeRefresh)); session != nil && session.RevokedAt == nil {
|
||||
if err := LoginSessionService.Updates(session.ID, map[string]any{
|
||||
"revoked_at": now,
|
||||
"updated_at": now,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *authService) Authenticate(ctx iris.Context) (*dto.AuthPrincipal, error) {
|
||||
if principal := s.GetAuthPrincipal(ctx); principal != nil {
|
||||
return principal, nil
|
||||
}
|
||||
|
||||
token := s.extractBearerToken(ctx.GetHeader("Authorization"))
|
||||
if token == "" {
|
||||
token = strings.TrimSpace(ctx.URLParam("accessToken"))
|
||||
}
|
||||
if token == "" {
|
||||
return nil, errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
|
||||
session, err := s.validateSessionToken(token, constants.TokenTypeAccess)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user := UserService.Get(session.UserID)
|
||||
if user == nil || user.Status != enums.StatusOk {
|
||||
return nil, errorsx.Unauthorized("用户不存在或已被禁用")
|
||||
}
|
||||
|
||||
roles, permissions, err := s.loadUserAuthScope(sqls.DB(), user.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
principal := s.setAuthPrincipal(ctx, user, roles, permissions)
|
||||
|
||||
now := time.Now()
|
||||
_ = LoginSessionService.Updates(session.ID, map[string]any{
|
||||
"last_seen_at": now,
|
||||
"updated_at": now,
|
||||
})
|
||||
|
||||
return principal, nil
|
||||
}
|
||||
|
||||
func (s *authService) HasPermission(ctx iris.Context, permissionCode string) bool {
|
||||
principal := s.GetAuthPrincipal(ctx)
|
||||
if principal == nil {
|
||||
return false
|
||||
}
|
||||
return slices.Contains(principal.Permissions, permissionCode)
|
||||
}
|
||||
|
||||
func (s *authService) CurrentProfile(ctx iris.Context) (*response.LoginResponse, error) {
|
||||
principal, err := s.Authenticate(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &response.LoginResponse{
|
||||
User: &response.AuthUserResponse{
|
||||
ID: principal.UserID,
|
||||
Username: principal.Username,
|
||||
Nickname: principal.Nickname,
|
||||
Avatar: principal.Avatar,
|
||||
Status: principal.Status,
|
||||
Roles: principal.Roles,
|
||||
},
|
||||
Permissions: principal.Permissions,
|
||||
Roles: principal.Roles,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *authService) GetUserRoles(userID int64) ([]models.Role, error) {
|
||||
return s.loadUserRoles(sqls.DB(), userID)
|
||||
}
|
||||
|
||||
func (s *authService) GetUserPermissions(userID int64) ([]string, error) {
|
||||
return s.loadUserPermissionCodes(sqls.DB(), userID)
|
||||
}
|
||||
|
||||
func (s *authService) issueTokens(ctx *sqls.TxContext, user *models.User, clientIP, userAgent string, authCfg config.AuthConfig) (*response.LoginResponse, error) {
|
||||
roles, permissions, err := s.loadUserAuthScope(ctx.Tx, user.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
accessTTL, refreshTTL := s.resolveTokenTTL(authCfg)
|
||||
accessToken, err := randomToken(constants.AccessTokenPrefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
refreshToken, err := randomToken(constants.RefreshTokenPrefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
// accessSession
|
||||
if err := repositories.LoginSessionRepository.Create(ctx.Tx, &models.LoginSession{
|
||||
UserID: user.ID,
|
||||
TokenID: accessToken,
|
||||
TokenType: constants.TokenTypeAccess,
|
||||
ClientType: constants.ClientTypeAdminWeb,
|
||||
ClientIP: clientIP,
|
||||
UserAgent: userAgent,
|
||||
ExpiredAt: now.Add(accessTTL),
|
||||
LastSeenAt: &now,
|
||||
AuditFields: models.AuditFields{
|
||||
CreatedAt: now,
|
||||
CreateUserID: user.ID,
|
||||
CreateUserName: user.Username,
|
||||
UpdatedAt: now,
|
||||
UpdateUserID: user.ID,
|
||||
UpdateUserName: user.Username,
|
||||
},
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// refreshSession
|
||||
if err := repositories.LoginSessionRepository.Create(ctx.Tx, &models.LoginSession{
|
||||
UserID: user.ID,
|
||||
TokenID: refreshToken,
|
||||
TokenType: constants.TokenTypeRefresh,
|
||||
ClientType: constants.ClientTypeAdminWeb,
|
||||
ClientIP: clientIP,
|
||||
UserAgent: userAgent,
|
||||
ExpiredAt: now.Add(refreshTTL),
|
||||
LastSeenAt: &now,
|
||||
AuditFields: models.AuditFields{
|
||||
CreatedAt: now,
|
||||
CreateUserID: user.ID,
|
||||
CreateUserName: user.Username,
|
||||
UpdatedAt: now,
|
||||
UpdateUserID: user.ID,
|
||||
UpdateUserName: user.Username,
|
||||
},
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &response.LoginResponse{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresAt: now.Add(accessTTL).Format(time.DateTime),
|
||||
User: &response.AuthUserResponse{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Nickname: user.Nickname,
|
||||
Avatar: user.Avatar,
|
||||
Status: user.Status,
|
||||
Roles: roles,
|
||||
},
|
||||
Permissions: permissions,
|
||||
Roles: roles,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *authService) resolveTokenTTL(authCfg config.AuthConfig) (time.Duration, time.Duration) {
|
||||
accessTTL := 12 * time.Hour
|
||||
refreshTTL := 7 * 24 * time.Hour
|
||||
if authCfg.AccessTokenTTLHours > 0 {
|
||||
accessTTL = time.Duration(authCfg.AccessTokenTTLHours) * time.Hour
|
||||
}
|
||||
if authCfg.RefreshTokenTTLDays > 0 {
|
||||
refreshTTL = time.Duration(authCfg.RefreshTokenTTLDays) * 24 * time.Hour
|
||||
}
|
||||
return accessTTL, refreshTTL
|
||||
}
|
||||
|
||||
func (s *authService) validateSessionToken(token, tokenType string) (*models.LoginSession, error) {
|
||||
if strings.TrimSpace(token) == "" {
|
||||
return nil, errorsx.InvalidToken("token 不能为空")
|
||||
}
|
||||
session := LoginSessionService.FindOne(sqls.NewCnd().
|
||||
Eq("token_id", token).
|
||||
Eq("token_type", tokenType))
|
||||
if session == nil {
|
||||
return nil, errorsx.InvalidToken("token 无效")
|
||||
}
|
||||
if session.RevokedAt != nil {
|
||||
return nil, errorsx.InvalidToken("token 已失效")
|
||||
}
|
||||
if time.Now().After(session.ExpiredAt) {
|
||||
return nil, errorsx.InvalidToken("token 已过期")
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (s *authService) loadUserAuthScope(tx *gorm.DB, userID int64) ([]string, []string, error) {
|
||||
roleCodes, err := s.loadUserRoleCodes(tx, userID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
permissionCodes, err := s.loadUserPermissionCodes(tx, userID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return roleCodes, permissionCodes, nil
|
||||
}
|
||||
|
||||
func (s *authService) loadUserRoleCodes(tx *gorm.DB, userID int64) ([]string, error) {
|
||||
roles, err := s.loadUserRoles(tx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
roleCodes := make([]string, 0, len(roles))
|
||||
for _, role := range roles {
|
||||
roleCodes = append(roleCodes, role.Code)
|
||||
}
|
||||
return roleCodes, nil
|
||||
}
|
||||
|
||||
func (s *authService) loadUserRoles(tx *gorm.DB, userID int64) ([]models.Role, error) {
|
||||
roles := make([]models.Role, 0)
|
||||
if err := tx.
|
||||
Table("t_role AS r").
|
||||
Select("r.*").
|
||||
Joins("JOIN t_user_role AS ur ON ur.role_id = r.id").
|
||||
Where("ur.user_id = ? AND r.status = ?", userID, enums.StatusOk).
|
||||
Order("r.sort_no ASC, r.id ASC").
|
||||
Scan(&roles).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return roles, nil
|
||||
}
|
||||
|
||||
func (s *authService) loadUserPermissionCodes(tx *gorm.DB, userID int64) ([]string, error) {
|
||||
permissionRows := make([]struct {
|
||||
Code string
|
||||
}, 0)
|
||||
db := tx.Table("t_permission AS p").
|
||||
Select("DISTINCT p.code").
|
||||
Joins("JOIN t_role_permission AS rp ON rp.permission_id = p.id").
|
||||
Joins("JOIN t_user_role AS ur ON ur.role_id = rp.role_id").
|
||||
Where("ur.user_id = ?", userID).
|
||||
Where("p.status = ?", enums.StatusOk)
|
||||
if err := db.Order("p.sort_no ASC, p.id ASC").Scan(&permissionRows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
permissionCodes := make([]string, 0, len(permissionRows))
|
||||
for _, permission := range permissionRows {
|
||||
permissionCodes = append(permissionCodes, permission.Code)
|
||||
}
|
||||
|
||||
overrideRows := make([]struct {
|
||||
Code string
|
||||
Effect int
|
||||
}, 0)
|
||||
if err := tx.
|
||||
Table("t_user_permission AS up").
|
||||
Select("p.code, up.effect").
|
||||
Joins("JOIN t_permission AS p ON p.id = up.permission_id").
|
||||
Where("up.user_id = ? AND (up.expired_at IS NULL OR up.expired_at > ?)", userID, time.Now()).
|
||||
Scan(&overrideRows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
permissionSet := make(map[string]bool, len(permissionCodes))
|
||||
for _, code := range permissionCodes {
|
||||
permissionSet[code] = true
|
||||
}
|
||||
for _, override := range overrideRows {
|
||||
if override.Effect < 0 {
|
||||
delete(permissionSet, override.Code)
|
||||
continue
|
||||
}
|
||||
permissionSet[override.Code] = true
|
||||
}
|
||||
|
||||
permissionCodes = permissionCodes[:0]
|
||||
for code := range permissionSet {
|
||||
permissionCodes = append(permissionCodes, code)
|
||||
}
|
||||
sort.Strings(permissionCodes)
|
||||
return permissionCodes, nil
|
||||
}
|
||||
|
||||
func (s *authService) extractBearerToken(header string) string {
|
||||
header = strings.TrimSpace(header)
|
||||
if header == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.SplitN(header, " ", 2)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(parts[1])
|
||||
}
|
||||
|
||||
func (s *authService) createLoginCredentialLog(principal string, userID int64, success bool, clientIP, userAgent, reason string) error {
|
||||
return LoginCredentialLogService.Create(&models.LoginCredentialLog{
|
||||
Principal: principal,
|
||||
UserID: userID,
|
||||
Success: success,
|
||||
ClientIP: clientIP,
|
||||
UserAgent: userAgent,
|
||||
Reason: reason,
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func randomToken(prefix string) (string, error) {
|
||||
buf := make([]byte, 24)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return prefix + hex.EncodeToString(buf), nil
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package services
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExtractBearerToken(t *testing.T) {
|
||||
svc := newAuthService()
|
||||
|
||||
if got := svc.extractBearerToken("Bearer token_123"); got != "token_123" {
|
||||
t.Fatalf("expected bearer token to be extracted, got %q", got)
|
||||
}
|
||||
|
||||
if got := svc.extractBearerToken("token_123"); got != "" {
|
||||
t.Fatalf("expected raw token to be rejected by bearer extractor, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"cs-agent/internal/repositories"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"github.com/mlogclub/simple/web/params"
|
||||
)
|
||||
|
||||
var ChannelMessageOutboxService = newChannelMessageOutboxService()
|
||||
|
||||
func newChannelMessageOutboxService() *channelMessageOutboxService {
|
||||
return &channelMessageOutboxService{}
|
||||
}
|
||||
|
||||
type channelMessageOutboxService struct {
|
||||
}
|
||||
|
||||
func (s *channelMessageOutboxService) Get(id int64) *models.ChannelMessageOutbox {
|
||||
return repositories.ChannelMessageOutboxRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *channelMessageOutboxService) Take(where ...interface{}) *models.ChannelMessageOutbox {
|
||||
return repositories.ChannelMessageOutboxRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *channelMessageOutboxService) Find(cnd *sqls.Cnd) []models.ChannelMessageOutbox {
|
||||
return repositories.ChannelMessageOutboxRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *channelMessageOutboxService) FindOne(cnd *sqls.Cnd) *models.ChannelMessageOutbox {
|
||||
return repositories.ChannelMessageOutboxRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *channelMessageOutboxService) FindPageByParams(params *params.QueryParams) (list []models.ChannelMessageOutbox, paging *sqls.Paging) {
|
||||
return repositories.ChannelMessageOutboxRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *channelMessageOutboxService) FindPageByCnd(cnd *sqls.Cnd) (list []models.ChannelMessageOutbox, paging *sqls.Paging) {
|
||||
return repositories.ChannelMessageOutboxRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *channelMessageOutboxService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.ChannelMessageOutboxRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *channelMessageOutboxService) Create(t *models.ChannelMessageOutbox) error {
|
||||
return repositories.ChannelMessageOutboxRepository.Create(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *channelMessageOutboxService) Update(t *models.ChannelMessageOutbox) error {
|
||||
return repositories.ChannelMessageOutboxRepository.Update(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *channelMessageOutboxService) Updates(id int64, columns map[string]interface{}) error {
|
||||
return repositories.ChannelMessageOutboxRepository.Updates(sqls.DB(), id, columns)
|
||||
}
|
||||
|
||||
func (s *channelMessageOutboxService) UpdateColumn(id int64, name string, value interface{}) error {
|
||||
return repositories.ChannelMessageOutboxRepository.UpdateColumn(sqls.DB(), id, name, value)
|
||||
}
|
||||
|
||||
func (s *channelMessageOutboxService) Delete(id int64) {
|
||||
repositories.ChannelMessageOutboxRepository.Delete(sqls.DB(), id)
|
||||
}
|
||||
|
||||
// GetByMessageID retrieves the outbox entry by message ID and channel type.
|
||||
func (s *channelMessageOutboxService) GetByMessageID(channelType string, messageID int64) *models.ChannelMessageOutbox {
|
||||
return repositories.ChannelMessageOutboxRepository.Take(sqls.DB(), "channel_type = ? AND message_id = ?", channelType, messageID)
|
||||
}
|
||||
|
||||
func (s *channelMessageOutboxService) EnqueueWxWorkKFMessage(conversation *models.Conversation, message *models.Message) error {
|
||||
if conversation == nil || message == nil {
|
||||
return nil
|
||||
}
|
||||
if conversation.ExternalSource != enums.ExternalSourceWxWorkKF {
|
||||
return nil
|
||||
}
|
||||
if message.SenderType != enums.IMSenderTypeAgent && message.SenderType != enums.IMSenderTypeAI {
|
||||
return nil
|
||||
}
|
||||
if message.MessageType != enums.IMMessageTypeText && message.MessageType != enums.IMMessageTypeHTML {
|
||||
return nil
|
||||
}
|
||||
if existing := s.GetByMessageID(enums.ChannelTypeWxWorkKF, message.ID); existing != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(map[string]any{
|
||||
"conversationId": conversation.ID,
|
||||
"messageId": message.ID,
|
||||
"messageType": message.MessageType,
|
||||
"content": strings.TrimSpace(message.Content),
|
||||
"payload": strings.TrimSpace(message.Payload),
|
||||
"senderId": message.SenderID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
return s.Create(&models.ChannelMessageOutbox{
|
||||
ChannelType: enums.ChannelTypeWxWorkKF,
|
||||
ConversationID: conversation.ID,
|
||||
MessageID: message.ID,
|
||||
Payload: string(payload),
|
||||
SendStatus: string(enums.ChannelMessageOutboxStatusPending),
|
||||
AuditFields: models.AuditFields{
|
||||
CreatedAt: now,
|
||||
CreateUserID: message.UpdateUserID,
|
||||
CreateUserName: message.UpdateUserName,
|
||||
UpdatedAt: now,
|
||||
UpdateUserID: message.UpdateUserID,
|
||||
UpdateUserName: message.UpdateUserName,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *channelMessageOutboxService) ListPending(channelType string, limit int) []models.ChannelMessageOutbox {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
cnd := sqls.NewCnd().
|
||||
Eq("channel_type", strings.TrimSpace(channelType)).
|
||||
In("send_status", []string{
|
||||
string(enums.ChannelMessageOutboxStatusPending),
|
||||
string(enums.ChannelMessageOutboxStatusFailed),
|
||||
}).
|
||||
Asc("id").
|
||||
Limit(limit)
|
||||
return s.Find(cnd)
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
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"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mlogclub/simple/common/strs"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"github.com/mlogclub/simple/web/params"
|
||||
)
|
||||
|
||||
var ChannelService = newChannelService()
|
||||
|
||||
func newChannelService() *channelService {
|
||||
return &channelService{}
|
||||
}
|
||||
|
||||
type channelService struct {
|
||||
}
|
||||
|
||||
type WxWorkKFChannelConfig struct {
|
||||
OpenKfID string `json:"openKfId"`
|
||||
}
|
||||
|
||||
func (s *channelService) Get(id int64) *models.Channel {
|
||||
return repositories.ChannelRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *channelService) Take(where ...interface{}) *models.Channel {
|
||||
return repositories.ChannelRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *channelService) Find(cnd *sqls.Cnd) []models.Channel {
|
||||
return repositories.ChannelRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *channelService) FindOne(cnd *sqls.Cnd) *models.Channel {
|
||||
return repositories.ChannelRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *channelService) FindPageByParams(params *params.QueryParams) (list []models.Channel, paging *sqls.Paging) {
|
||||
return repositories.ChannelRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *channelService) FindPageByCnd(cnd *sqls.Cnd) (list []models.Channel, paging *sqls.Paging) {
|
||||
return repositories.ChannelRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *channelService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.ChannelRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *channelService) Create(t *models.Channel) error {
|
||||
return repositories.ChannelRepository.Create(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *channelService) Update(t *models.Channel) error {
|
||||
return repositories.ChannelRepository.Update(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *channelService) Updates(id int64, columns map[string]interface{}) error {
|
||||
return repositories.ChannelRepository.Updates(sqls.DB(), id, columns)
|
||||
}
|
||||
|
||||
func (s *channelService) UpdateColumn(id int64, name string, value interface{}) error {
|
||||
return repositories.ChannelRepository.UpdateColumn(sqls.DB(), id, name, value)
|
||||
}
|
||||
|
||||
func (s *channelService) CreateChannel(req request.CreateChannelRequest, operator *dto.AuthPrincipal) (*models.Channel, error) {
|
||||
if operator == nil {
|
||||
return nil, errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
item, err := s.buildChannelModel(0, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.AuditFields = utils.BuildAuditFields(operator)
|
||||
if err := repositories.ChannelRepository.Create(sqls.DB(), item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *channelService) UpdateChannel(req request.UpdateChannelRequest, 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.buildChannelModel(req.ID, req.CreateChannelRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return repositories.ChannelRepository.Updates(sqls.DB(), req.ID, map[string]any{
|
||||
"channel_type": item.ChannelType,
|
||||
"channel_id": item.ChannelID,
|
||||
"ai_agent_id": item.AIAgentID,
|
||||
"name": item.Name,
|
||||
"config_json": item.ConfigJSON,
|
||||
"status": item.Status,
|
||||
"remark": item.Remark,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *channelService) UpdateStatus(id int64, status int, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
item := s.Get(id)
|
||||
if item == nil || item.Status == enums.StatusDeleted {
|
||||
return errorsx.InvalidParam("接入渠道不存在")
|
||||
}
|
||||
if status != int(enums.StatusOk) && status != int(enums.StatusDisabled) {
|
||||
return errorsx.InvalidParam("状态值不合法")
|
||||
}
|
||||
return s.Updates(id, map[string]any{
|
||||
"status": status,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *channelService) DeleteChannel(id int64, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
item := s.Get(id)
|
||||
if item == nil || item.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 *channelService) ParseWxWorkKFChannelConfig(raw string) (*WxWorkKFChannelConfig, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return &WxWorkKFChannelConfig{}, nil
|
||||
}
|
||||
cfg := &WxWorkKFChannelConfig{}
|
||||
if err := json.Unmarshal([]byte(raw), cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.OpenKfID = strings.TrimSpace(cfg.OpenKfID)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (s *channelService) GetEnabledWxWorkKFChannelByOpenKfID(openKfID string) *models.Channel {
|
||||
openKfID = strings.TrimSpace(openKfID)
|
||||
if openKfID == "" {
|
||||
return nil
|
||||
}
|
||||
channels := s.Find(sqls.NewCnd().
|
||||
Eq("channel_type", enums.ChannelTypeWxWorkKF).
|
||||
Eq("status", enums.StatusOk).
|
||||
Asc("id"))
|
||||
for i := range channels {
|
||||
cfg, err := s.ParseWxWorkKFChannelConfig(channels[i].ConfigJSON)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if cfg != nil && cfg.OpenKfID == openKfID {
|
||||
return &channels[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *channelService) GetEnabledWebChannelByChannelID(channelID string) *models.Channel {
|
||||
channelID = strings.TrimSpace(channelID)
|
||||
if channelID == "" {
|
||||
return nil
|
||||
}
|
||||
return s.Take("channel_type = ? AND channel_id = ? AND status = ?", enums.ChannelTypeWeb, channelID, enums.StatusOk)
|
||||
}
|
||||
|
||||
func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRequest) (*models.Channel, error) {
|
||||
channelType := strings.TrimSpace(req.ChannelType)
|
||||
if channelType != enums.ChannelTypeWeb && channelType != enums.ChannelTypeWxWorkKF {
|
||||
return nil, errorsx.InvalidParam("渠道类型不合法")
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
return nil, errorsx.InvalidParam("渠道名称不能为空")
|
||||
}
|
||||
if req.AIAgentID <= 0 {
|
||||
return nil, errorsx.InvalidParam("请选择 AI Agent")
|
||||
}
|
||||
aiAgent := AIAgentService.Get(req.AIAgentID)
|
||||
if aiAgent == nil || aiAgent.Status != enums.StatusOk {
|
||||
return nil, errorsx.InvalidParam("AI Agent 不存在或未启用")
|
||||
}
|
||||
status := enums.Status(req.Status)
|
||||
if req.Status == 0 {
|
||||
status = enums.StatusOk
|
||||
}
|
||||
if status != enums.StatusOk && status != enums.StatusDisabled {
|
||||
return nil, errorsx.InvalidParam("渠道状态不合法")
|
||||
}
|
||||
|
||||
channelID := ""
|
||||
if id > 0 {
|
||||
current := s.Get(id)
|
||||
if current == nil || current.Status == enums.StatusDeleted {
|
||||
return nil, errorsx.InvalidParam("接入渠道不存在")
|
||||
}
|
||||
channelID = strings.TrimSpace(current.ChannelID)
|
||||
}
|
||||
configJSON := strings.TrimSpace(req.ConfigJSON)
|
||||
switch channelType {
|
||||
case enums.ChannelTypeWeb:
|
||||
if channelID == "" {
|
||||
channelID = strs.UUID()
|
||||
}
|
||||
if exists := s.Take("channel_id = ? AND status <> ? AND id <> ?", channelID, enums.StatusDeleted, id); exists != nil {
|
||||
return nil, errorsx.InvalidParam("渠道标识已存在")
|
||||
}
|
||||
case enums.ChannelTypeWxWorkKF:
|
||||
if channelID == "" {
|
||||
channelID = strs.UUID()
|
||||
}
|
||||
if exists := s.Take("channel_id = ? AND status <> ? AND id <> ?", channelID, enums.StatusDeleted, id); exists != nil {
|
||||
return nil, errorsx.InvalidParam("渠道标识已存在")
|
||||
}
|
||||
cfg, err := s.ParseWxWorkKFChannelConfig(configJSON)
|
||||
if err != nil {
|
||||
return nil, errorsx.InvalidParam("企业微信渠道配置不合法")
|
||||
}
|
||||
if cfg == nil || cfg.OpenKfID == "" {
|
||||
return nil, errorsx.InvalidParam("企业微信渠道配置缺少 openKfId")
|
||||
}
|
||||
if channel := s.GetEnabledWxWorkKFChannelByOpenKfID(cfg.OpenKfID); channel != nil && channel.ID != id {
|
||||
return nil, errorsx.InvalidParam("openKfId 已被其他渠道使用")
|
||||
}
|
||||
}
|
||||
|
||||
return &models.Channel{
|
||||
ChannelType: channelType,
|
||||
ChannelID: channelID,
|
||||
AIAgentID: req.AIAgentID,
|
||||
Name: name,
|
||||
ConfigJSON: configJSON,
|
||||
Status: status,
|
||||
Remark: strings.TrimSpace(req.Remark),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
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 CompanyService = newCompanyService()
|
||||
|
||||
func newCompanyService() *companyService {
|
||||
return &companyService{}
|
||||
}
|
||||
|
||||
type companyService struct {
|
||||
}
|
||||
|
||||
func (s *companyService) Get(id int64) *models.Company {
|
||||
if id <= 0 {
|
||||
return nil
|
||||
}
|
||||
return repositories.CompanyRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *companyService) Take(where ...interface{}) *models.Company {
|
||||
return repositories.CompanyRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *companyService) Find(cnd *sqls.Cnd) []models.Company {
|
||||
return repositories.CompanyRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *companyService) FindOne(cnd *sqls.Cnd) *models.Company {
|
||||
return repositories.CompanyRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *companyService) FindPageByParams(params *params.QueryParams) (list []models.Company, paging *sqls.Paging) {
|
||||
return repositories.CompanyRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *companyService) FindPageByCnd(cnd *sqls.Cnd) (list []models.Company, paging *sqls.Paging) {
|
||||
return repositories.CompanyRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *companyService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.CompanyRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *companyService) CreateCompany(req request.CreateCompanyRequest, operator *dto.AuthPrincipal) (*models.Company, error) {
|
||||
if operator == nil {
|
||||
return nil, errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
return nil, errorsx.InvalidParam("公司名称不能为空")
|
||||
}
|
||||
|
||||
existing := repositories.CompanyRepository.GetByName(sqls.DB(), name)
|
||||
if existing != nil && existing.Status != enums.StatusDeleted {
|
||||
return nil, errorsx.InvalidParam("公司名称已存在")
|
||||
}
|
||||
|
||||
item := &models.Company{
|
||||
Name: name,
|
||||
Code: strings.TrimSpace(req.Code),
|
||||
Status: enums.StatusOk,
|
||||
Remark: strings.TrimSpace(req.Remark),
|
||||
AuditFields: utils.BuildAuditFields(operator),
|
||||
}
|
||||
if err := repositories.CompanyRepository.Create(sqls.DB(), item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *companyService) UpdateCompany(req request.UpdateCompanyRequest, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
item := s.Get(req.ID)
|
||||
if item == nil {
|
||||
return errorsx.InvalidParam("公司不存在")
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
return errorsx.InvalidParam("公司名称不能为空")
|
||||
}
|
||||
|
||||
existing := repositories.CompanyRepository.GetByName(sqls.DB(), name)
|
||||
if existing != nil && existing.ID != req.ID {
|
||||
return errorsx.InvalidParam("公司名称已存在")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if err := repositories.CompanyRepository.Updates(sqls.DB(), req.ID, map[string]any{
|
||||
"name": name,
|
||||
"code": strings.TrimSpace(req.Code),
|
||||
"remark": strings.TrimSpace(req.Remark),
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": now,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *companyService) DeleteCompany(id int64, operator dto.AuthPrincipal) error {
|
||||
item := s.Get(id)
|
||||
if item == nil {
|
||||
return errorsx.InvalidParam("公司不存在")
|
||||
}
|
||||
|
||||
return repositories.CompanyRepository.Updates(sqls.DB(), id, map[string]any{
|
||||
"status": enums.StatusDeleted,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *companyService) UpdateStatus(id int64, status int, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
item := s.Get(id)
|
||||
if item == nil {
|
||||
return errorsx.InvalidParam("公司不存在")
|
||||
}
|
||||
if status != int(enums.StatusOk) && status != int(enums.StatusDisabled) {
|
||||
return errorsx.InvalidParam("状态值不合法")
|
||||
}
|
||||
now := time.Now()
|
||||
return repositories.CompanyRepository.Updates(sqls.DB(), id, map[string]any{
|
||||
"status": status,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": now,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/dto"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"cs-agent/internal/repositories"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"github.com/mlogclub/simple/web/params"
|
||||
)
|
||||
|
||||
var ConversationAssignmentService = newConversationAssignmentService()
|
||||
|
||||
func newConversationAssignmentService() *conversationAssignmentService {
|
||||
return &conversationAssignmentService{}
|
||||
}
|
||||
|
||||
type conversationAssignmentService struct {
|
||||
}
|
||||
|
||||
func (s *conversationAssignmentService) Get(id int64) *models.ConversationAssignment {
|
||||
return repositories.ConversationAssignmentRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *conversationAssignmentService) Take(where ...interface{}) *models.ConversationAssignment {
|
||||
return repositories.ConversationAssignmentRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *conversationAssignmentService) Find(cnd *sqls.Cnd) []models.ConversationAssignment {
|
||||
return repositories.ConversationAssignmentRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *conversationAssignmentService) FindOne(cnd *sqls.Cnd) *models.ConversationAssignment {
|
||||
return repositories.ConversationAssignmentRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *conversationAssignmentService) FindPageByParams(params *params.QueryParams) (list []models.ConversationAssignment, paging *sqls.Paging) {
|
||||
return repositories.ConversationAssignmentRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *conversationAssignmentService) FindPageByCnd(cnd *sqls.Cnd) (list []models.ConversationAssignment, paging *sqls.Paging) {
|
||||
return repositories.ConversationAssignmentRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *conversationAssignmentService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.ConversationAssignmentRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *conversationAssignmentService) FinishActiveAssignments(ctx *sqls.TxContext, conversationID int64, finishedAt time.Time) error {
|
||||
return ctx.Tx.Model(&models.ConversationAssignment{}).
|
||||
Where("conversation_id = ? AND status = ?", conversationID, enums.IMAssignmentStatusActive).
|
||||
Updates(map[string]any{
|
||||
"status": enums.IMAssignmentStatusInactive,
|
||||
"finished_at": finishedAt,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (s *conversationAssignmentService) CreateAssignment(ctx *sqls.TxContext, conversationID, fromUserID, toUserID int64, assignType enums.IMAssignmentType, reason string, operator *dto.AuthPrincipal, now time.Time) error {
|
||||
assignment := &models.ConversationAssignment{
|
||||
ConversationID: conversationID,
|
||||
FromUserID: fromUserID,
|
||||
ToUserID: toUserID,
|
||||
AssignType: strings.TrimSpace(string(assignType)),
|
||||
Reason: strings.TrimSpace(reason),
|
||||
Status: enums.IMAssignmentStatusActive,
|
||||
CreatedAt: now,
|
||||
}
|
||||
if operator != nil {
|
||||
assignment.OperatorID = operator.UserID
|
||||
}
|
||||
return ctx.Tx.Create(assignment).Error
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/dto"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"cs-agent/internal/pkg/utils"
|
||||
"cs-agent/internal/repositories"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"math"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
var ConversationDispatchService = newConversationDispatchService()
|
||||
|
||||
func newConversationDispatchService() *conversationDispatchService {
|
||||
return &conversationDispatchService{}
|
||||
}
|
||||
|
||||
type conversationDispatchService struct{}
|
||||
|
||||
type dispatchCandidate struct {
|
||||
profile models.AgentProfile
|
||||
activeCount int
|
||||
loadRate float64
|
||||
}
|
||||
|
||||
type agentActiveConversationCount struct {
|
||||
CurrentAssigneeID int64 `gorm:"column:current_assignee_id"`
|
||||
ActiveCount int `gorm:"column:active_count"`
|
||||
}
|
||||
|
||||
type dispatchPoolReport struct {
|
||||
RequestedTeamIDs []int64
|
||||
ActiveScheduleTeams []int64
|
||||
MatchedProfiles int
|
||||
EligibleProfiles int
|
||||
CandidateCount int
|
||||
Reason string
|
||||
}
|
||||
|
||||
var errConversationDispatchConflict = errors.New("conversation dispatch conflict")
|
||||
|
||||
const pendingDispatchBatchLimit = 50
|
||||
|
||||
var pendingDispatchRunning atomic.Bool
|
||||
|
||||
func (s *conversationDispatchService) DispatchConversation(conversationID int64) (*models.Conversation, error) {
|
||||
if conversationID <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
conversation := ConversationService.Get(conversationID)
|
||||
if conversation == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if conversation.Status != enums.IMConversationStatusPending || conversation.CurrentAssigneeID > 0 {
|
||||
return nil, nil
|
||||
}
|
||||
aiAgent := AIAgentService.Get(conversation.AIAgentID)
|
||||
if aiAgent == nil || aiAgent.Status != enums.StatusOk {
|
||||
return nil, nil
|
||||
}
|
||||
return s.DispatchPendingConversation(conversation, aiAgent)
|
||||
}
|
||||
|
||||
func (s *conversationDispatchService) DispatchPendingConversation(conversation *models.Conversation, aiAgent *models.AIAgent) (*models.Conversation, error) {
|
||||
if conversation == nil || aiAgent == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if conversation.Status != enums.IMConversationStatusPending || conversation.CurrentAssigneeID > 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
teamIDs := utils.SplitInt64s(aiAgent.TeamIDs)
|
||||
if len(teamIDs) == 0 {
|
||||
slog.Debug("skip auto dispatch due to empty ai agent team ids",
|
||||
"conversation_id", conversation.ID,
|
||||
"ai_agent_id", aiAgent.ID,
|
||||
)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
candidates, report, err := s.pickDispatchCandidates(teamIDs, time.Now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
slog.Debug("no dispatch candidate available",
|
||||
"conversation_id", conversation.ID,
|
||||
"ai_agent_id", aiAgent.ID,
|
||||
"requested_team_ids", report.RequestedTeamIDs,
|
||||
"active_schedule_team_ids", report.ActiveScheduleTeams,
|
||||
"matched_profiles", report.MatchedProfiles,
|
||||
"eligible_profiles", report.EligibleProfiles,
|
||||
"reason", report.Reason,
|
||||
)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
dispatched, err := s.tryAssignConversation(conversation.ID, candidate.profile, "自动分配")
|
||||
if err != nil {
|
||||
if errors.Is(err, errConversationDispatchConflict) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if dispatched != nil {
|
||||
slog.Info("conversation auto dispatched",
|
||||
"conversation_id", dispatched.ID,
|
||||
"ai_agent_id", aiAgent.ID,
|
||||
"assignee_id", dispatched.CurrentAssigneeID,
|
||||
"team_id", dispatched.CurrentTeamID,
|
||||
"candidate_count", report.CandidateCount,
|
||||
"requested_team_ids", report.RequestedTeamIDs,
|
||||
)
|
||||
WsService.PublishConversationChanged(dispatched, enums.IMRealtimeEventConversationAssigned)
|
||||
return dispatched, nil
|
||||
}
|
||||
}
|
||||
slog.Debug("auto dispatch candidate list exhausted without assignment",
|
||||
"conversation_id", conversation.ID,
|
||||
"ai_agent_id", aiAgent.ID,
|
||||
"candidate_count", report.CandidateCount,
|
||||
)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *conversationDispatchService) DispatchPendingConversations(limit int) (int, error) {
|
||||
if !pendingDispatchRunning.CompareAndSwap(false, true) {
|
||||
return 0, nil
|
||||
}
|
||||
defer pendingDispatchRunning.Store(false)
|
||||
|
||||
if limit <= 0 {
|
||||
limit = pendingDispatchBatchLimit
|
||||
}
|
||||
conversations := ConversationService.Find(sqls.NewCnd().
|
||||
Eq("status", enums.IMConversationStatusPending).
|
||||
Eq("current_assignee_id", 0).
|
||||
Desc("id"))
|
||||
if len(conversations) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
dispatchedCount := 0
|
||||
scannedCount := 0
|
||||
for i, conversation := range conversations {
|
||||
if i >= limit {
|
||||
break
|
||||
}
|
||||
scannedCount++
|
||||
dispatched, err := s.DispatchConversation(conversation.ID)
|
||||
if err != nil {
|
||||
return dispatchedCount, err
|
||||
}
|
||||
if dispatched != nil {
|
||||
dispatchedCount++
|
||||
}
|
||||
}
|
||||
if scannedCount > 0 {
|
||||
slog.Info("pending conversation dispatch scan completed",
|
||||
"scanned_count", scannedCount,
|
||||
"dispatched_count", dispatchedCount,
|
||||
"limit", limit,
|
||||
)
|
||||
}
|
||||
return dispatchedCount, nil
|
||||
}
|
||||
|
||||
func (s *conversationDispatchService) RunPendingDispatchLoop(interval time.Duration) {
|
||||
if interval <= 0 {
|
||||
interval = 30 * time.Second
|
||||
}
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
slog.Info("pending conversation dispatch loop started",
|
||||
"interval_seconds", int(interval/time.Second),
|
||||
)
|
||||
|
||||
for {
|
||||
if _, err := s.DispatchPendingConversations(0); err != nil {
|
||||
slog.Warn("dispatch pending conversations loop failed", "error", err)
|
||||
}
|
||||
<-ticker.C
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// pickDispatchCandidates returns the eligible dispatch candidates for the given teamIDs at the given time, along with a report for debugging and analysis.
|
||||
func (s *conversationDispatchService) pickDispatchCandidates(teamIDs []int64, now time.Time) ([]dispatchCandidate, dispatchPoolReport, error) {
|
||||
report := dispatchPoolReport{
|
||||
RequestedTeamIDs: append([]int64(nil), teamIDs...),
|
||||
}
|
||||
|
||||
// 1. filter teams with active schedule
|
||||
activeTeamIDs := s.findActiveScheduleTeamIDs(teamIDs, now)
|
||||
report.ActiveScheduleTeams = activeTeamIDs
|
||||
if len(activeTeamIDs) == 0 {
|
||||
report.Reason = "no_active_schedule_team"
|
||||
return nil, report, nil
|
||||
}
|
||||
|
||||
// 2. find agent profiles for the active teams
|
||||
profiles := AgentProfileService.GetDispatchAgents(activeTeamIDs)
|
||||
report.MatchedProfiles = len(profiles)
|
||||
if len(profiles) == 0 {
|
||||
report.Reason = "no_matched_profile"
|
||||
return nil, report, nil
|
||||
}
|
||||
|
||||
enabledProfiles, enabledUserIDs, reason := s.filterEnabledDispatchProfiles(profiles)
|
||||
if reason != "" {
|
||||
report.Reason = reason
|
||||
return nil, report, nil
|
||||
}
|
||||
report.EligibleProfiles = len(enabledProfiles)
|
||||
|
||||
activeCounts, err := s.findActiveConversationCountMap(enabledUserIDs)
|
||||
if err != nil {
|
||||
return nil, report, err
|
||||
}
|
||||
|
||||
candidates := make([]dispatchCandidate, 0, len(enabledProfiles))
|
||||
for _, profile := range enabledProfiles {
|
||||
activeCount := activeCounts[profile.UserID]
|
||||
if profile.MaxConcurrentCount > 0 && activeCount >= profile.MaxConcurrentCount {
|
||||
continue
|
||||
}
|
||||
loadRate := float64(activeCount) / math.Max(float64(profile.MaxConcurrentCount), 1)
|
||||
candidates = append(candidates, dispatchCandidate{
|
||||
profile: profile,
|
||||
activeCount: activeCount,
|
||||
loadRate: loadRate,
|
||||
})
|
||||
}
|
||||
report.CandidateCount = len(candidates)
|
||||
if len(candidates) == 0 {
|
||||
report.Reason = "all_candidates_at_capacity"
|
||||
return nil, report, nil
|
||||
}
|
||||
|
||||
slices.SortFunc(candidates, func(a, b dispatchCandidate) int {
|
||||
switch {
|
||||
case a.loadRate < b.loadRate:
|
||||
return -1
|
||||
case a.loadRate > b.loadRate:
|
||||
return 1
|
||||
}
|
||||
switch {
|
||||
case a.activeCount < b.activeCount:
|
||||
return -1
|
||||
case a.activeCount > b.activeCount:
|
||||
return 1
|
||||
}
|
||||
switch {
|
||||
case a.profile.PriorityLevel > b.profile.PriorityLevel:
|
||||
return -1
|
||||
case a.profile.PriorityLevel < b.profile.PriorityLevel:
|
||||
return 1
|
||||
}
|
||||
aLastStatusAt := zeroTime(a.profile.LastStatusAt)
|
||||
bLastStatusAt := zeroTime(b.profile.LastStatusAt)
|
||||
switch {
|
||||
case aLastStatusAt.Before(bLastStatusAt):
|
||||
return -1
|
||||
case aLastStatusAt.After(bLastStatusAt):
|
||||
return 1
|
||||
}
|
||||
switch {
|
||||
case a.profile.UserID < b.profile.UserID:
|
||||
return -1
|
||||
case a.profile.UserID > b.profile.UserID:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
report.Reason = "ok"
|
||||
return candidates, report, nil
|
||||
}
|
||||
|
||||
func (s *conversationDispatchService) filterEnabledDispatchProfiles(profiles []models.AgentProfile) ([]models.AgentProfile, []int64, string) {
|
||||
userIDs := make([]int64, 0, len(profiles))
|
||||
for _, profile := range profiles {
|
||||
if profile.UserID > 0 {
|
||||
userIDs = append(userIDs, profile.UserID)
|
||||
}
|
||||
}
|
||||
if len(userIDs) == 0 {
|
||||
return nil, nil, "no_profile_with_capacity_config"
|
||||
}
|
||||
|
||||
enabledUsers := UserService.Find(sqls.NewCnd().
|
||||
In("id", userIDs).
|
||||
Eq("status", enums.StatusOk))
|
||||
if len(enabledUsers) == 0 {
|
||||
return nil, nil, "no_enabled_user"
|
||||
}
|
||||
|
||||
enabledUserSet := make(map[int64]struct{}, len(enabledUsers))
|
||||
for _, user := range enabledUsers {
|
||||
enabledUserSet[user.ID] = struct{}{}
|
||||
}
|
||||
|
||||
enabledProfiles := make([]models.AgentProfile, 0, len(profiles))
|
||||
enabledUserIDs := make([]int64, 0, len(profiles))
|
||||
for _, profile := range profiles {
|
||||
if _, exists := enabledUserSet[profile.UserID]; !exists {
|
||||
continue
|
||||
}
|
||||
enabledProfiles = append(enabledProfiles, profile)
|
||||
enabledUserIDs = append(enabledUserIDs, profile.UserID)
|
||||
}
|
||||
if len(enabledProfiles) == 0 {
|
||||
return nil, nil, "no_profile_for_enabled_user"
|
||||
}
|
||||
return enabledProfiles, enabledUserIDs, ""
|
||||
}
|
||||
|
||||
// findActiveScheduleTeamIDs returns the subset of teamIDs that have active schedule at the given time.
|
||||
func (s *conversationDispatchService) findActiveScheduleTeamIDs(teamIDs []int64, now time.Time) []int64 {
|
||||
if len(teamIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
teams := AgentTeamService.Find(sqls.NewCnd().
|
||||
In("id", teamIDs).
|
||||
Eq("status", enums.StatusOk))
|
||||
if len(teams) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
enabledTeamIDs := make([]int64, 0, len(teams))
|
||||
for _, team := range teams {
|
||||
enabledTeamIDs = append(enabledTeamIDs, team.ID)
|
||||
}
|
||||
|
||||
schedules := AgentTeamScheduleService.Find(sqls.NewCnd().
|
||||
In("team_id", enabledTeamIDs).
|
||||
Eq("status", enums.StatusOk).
|
||||
Lte("start_at", now).
|
||||
Gt("end_at", now))
|
||||
|
||||
ret := make([]int64, 0, len(schedules))
|
||||
seen := make(map[int64]struct{})
|
||||
for _, schedule := range schedules {
|
||||
if _, exists := seen[schedule.TeamID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[schedule.TeamID] = struct{}{}
|
||||
ret = append(ret, schedule.TeamID)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func (s *conversationDispatchService) findActiveConversationCountMap(userIDs []int64) (map[int64]int, error) {
|
||||
ret := make(map[int64]int, len(userIDs))
|
||||
if len(userIDs) == 0 {
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
rows := make([]agentActiveConversationCount, 0)
|
||||
if err := sqls.DB().
|
||||
Model(&models.Conversation{}).
|
||||
Select("current_assignee_id, COUNT(1) AS active_count").
|
||||
Where("status = ? AND current_assignee_id IN ?", enums.IMConversationStatusActive, userIDs).
|
||||
Group("current_assignee_id").
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.CurrentAssigneeID <= 0 {
|
||||
continue
|
||||
}
|
||||
ret[row.CurrentAssigneeID] = row.ActiveCount
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (s *conversationDispatchService) tryAssignConversation(conversationID int64, candidate models.AgentProfile, reason string) (*models.Conversation, error) {
|
||||
now := time.Now()
|
||||
operator := systemDispatchPrincipal()
|
||||
|
||||
err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
conversation := repositories.ConversationRepository.Get(ctx.Tx, conversationID)
|
||||
if conversation == nil {
|
||||
return errConversationDispatchConflict
|
||||
}
|
||||
if conversation.Status != enums.IMConversationStatusPending || conversation.CurrentAssigneeID > 0 {
|
||||
return errConversationDispatchConflict
|
||||
}
|
||||
|
||||
if err := ConversationAssignmentService.FinishActiveAssignments(ctx, conversationID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ConversationAssignmentService.CreateAssignment(ctx, conversationID, conversation.CurrentAssigneeID, candidate.UserID, enums.IMAssignmentTypeAssign, reason, operator, now); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result := ctx.Tx.Model(&models.Conversation{}).
|
||||
Where("id = ? AND status = ? AND current_assignee_id = ?", conversationID, enums.IMConversationStatusPending, 0).
|
||||
Updates(map[string]any{
|
||||
"current_assignee_id": candidate.UserID,
|
||||
"current_team_id": candidate.TeamID,
|
||||
"status": enums.IMConversationStatusActive,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return errConversationDispatchConflict
|
||||
}
|
||||
|
||||
return ConversationEventLogService.CreateEvent(ctx, conversationID, enums.IMEventTypeAssign, enums.IMSenderTypeSystem, operator.UserID, "会话已自动分配", buildDispatchEventPayload(conversation.CurrentAssigneeID, candidate.UserID, candidate.TeamID, reason))
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ConversationService.Get(conversationID), nil
|
||||
}
|
||||
|
||||
func buildDispatchEventPayload(fromAssigneeID, toAssigneeID, toTeamID int64, reason string) string {
|
||||
return ConversationService.buildEventPayload(map[string]any{
|
||||
"fromStatus": enums.IMConversationStatusPending,
|
||||
"toStatus": enums.IMConversationStatusActive,
|
||||
"fromAssigneeId": fromAssigneeID,
|
||||
"toAssigneeId": toAssigneeID,
|
||||
"toTeamId": toTeamID,
|
||||
"reason": strings.TrimSpace(reason),
|
||||
})
|
||||
}
|
||||
|
||||
func systemDispatchPrincipal() *dto.AuthPrincipal {
|
||||
return &dto.AuthPrincipal{
|
||||
UserID: 0,
|
||||
Username: "system",
|
||||
Nickname: "system",
|
||||
}
|
||||
}
|
||||
|
||||
func zeroTime(value *time.Time) time.Time {
|
||||
if value == nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return *value
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"cs-agent/internal/repositories"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"github.com/mlogclub/simple/web/params"
|
||||
)
|
||||
|
||||
var ConversationEventLogService = newConversationEventLogService()
|
||||
|
||||
func newConversationEventLogService() *conversationEventLogService {
|
||||
return &conversationEventLogService{}
|
||||
}
|
||||
|
||||
type conversationEventLogService struct {
|
||||
}
|
||||
|
||||
func (s *conversationEventLogService) Get(id int64) *models.ConversationEventLog {
|
||||
return repositories.ConversationEventLogRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *conversationEventLogService) Take(where ...interface{}) *models.ConversationEventLog {
|
||||
return repositories.ConversationEventLogRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *conversationEventLogService) Find(cnd *sqls.Cnd) []models.ConversationEventLog {
|
||||
return repositories.ConversationEventLogRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *conversationEventLogService) FindOne(cnd *sqls.Cnd) *models.ConversationEventLog {
|
||||
return repositories.ConversationEventLogRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *conversationEventLogService) FindPageByParams(params *params.QueryParams) (list []models.ConversationEventLog, paging *sqls.Paging) {
|
||||
return repositories.ConversationEventLogRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *conversationEventLogService) FindPageByCnd(cnd *sqls.Cnd) (list []models.ConversationEventLog, paging *sqls.Paging) {
|
||||
return repositories.ConversationEventLogRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *conversationEventLogService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.ConversationEventLogRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *conversationEventLogService) Create(t *models.ConversationEventLog) error {
|
||||
return repositories.ConversationEventLogRepository.Create(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *conversationEventLogService) Update(t *models.ConversationEventLog) error {
|
||||
return repositories.ConversationEventLogRepository.Update(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *conversationEventLogService) Updates(id int64, columns map[string]interface{}) error {
|
||||
return repositories.ConversationEventLogRepository.Updates(sqls.DB(), id, columns)
|
||||
}
|
||||
|
||||
func (s *conversationEventLogService) UpdateColumn(id int64, name string, value interface{}) error {
|
||||
return repositories.ConversationEventLogRepository.UpdateColumn(sqls.DB(), id, name, value)
|
||||
}
|
||||
|
||||
func (s *conversationEventLogService) Delete(id int64) {
|
||||
repositories.ConversationEventLogRepository.Delete(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *conversationEventLogService) CreateEvent(ctx *sqls.TxContext, conversationID int64, eventType enums.IMEventType,
|
||||
operatorType enums.IMSenderType, operatorID int64, content, payload string) error {
|
||||
return repositories.ConversationEventLogRepository.Create(ctx.Tx, &models.ConversationEventLog{
|
||||
ConversationID: conversationID,
|
||||
EventType: eventType,
|
||||
OperatorType: operatorType,
|
||||
OperatorID: operatorID,
|
||||
Content: strings.TrimSpace(content),
|
||||
Payload: strings.TrimSpace(payload),
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/repositories"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
var ConversationInterruptService = newConversationInterruptService()
|
||||
|
||||
func newConversationInterruptService() *conversationInterruptService {
|
||||
return &conversationInterruptService{}
|
||||
}
|
||||
|
||||
type conversationInterruptService struct{}
|
||||
|
||||
func (s *conversationInterruptService) Get(id int64) *models.ConversationInterrupt {
|
||||
return repositories.ConversationInterruptRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *conversationInterruptService) GetByCheckPointID(checkPointID string) *models.ConversationInterrupt {
|
||||
checkPointID = strings.TrimSpace(checkPointID)
|
||||
if checkPointID == "" {
|
||||
return nil
|
||||
}
|
||||
return repositories.ConversationInterruptRepository.GetByCheckPointID(sqls.DB(), checkPointID)
|
||||
}
|
||||
|
||||
func (s *conversationInterruptService) FindLatestPendingByConversationID(conversationID int64) *models.ConversationInterrupt {
|
||||
if conversationID <= 0 {
|
||||
return nil
|
||||
}
|
||||
return repositories.ConversationInterruptRepository.FindLatestPendingByConversationID(sqls.DB(), conversationID)
|
||||
}
|
||||
|
||||
func (s *conversationInterruptService) SaveCheckpoint(checkPointID string, data []byte) error {
|
||||
checkPointID = strings.TrimSpace(checkPointID)
|
||||
if checkPointID == "" {
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
item := &models.ConversationInterrupt{
|
||||
CheckPointID: checkPointID,
|
||||
CheckPointData: base64.StdEncoding.EncodeToString(data),
|
||||
Status: "checkpointed",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
item = s.mergeForCheckpointUpdate(s.GetByCheckPointID(checkPointID), item)
|
||||
return repositories.ConversationInterruptRepository.UpsertByCheckPointID(sqls.DB(), item)
|
||||
}
|
||||
|
||||
func (s *conversationInterruptService) LoadCheckpoint(checkPointID string) ([]byte, bool, error) {
|
||||
item := s.GetByCheckPointID(checkPointID)
|
||||
if item == nil || strings.TrimSpace(item.CheckPointData) == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(item.CheckPointData)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return data, true, nil
|
||||
}
|
||||
|
||||
func (s *conversationInterruptService) CreateOrUpdatePending(item *models.ConversationInterrupt) error {
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
if item.CreatedAt.IsZero() {
|
||||
item.CreatedAt = now
|
||||
}
|
||||
item.UpdatedAt = now
|
||||
item.Status = strings.TrimSpace(item.Status)
|
||||
if item.Status == "" {
|
||||
item.Status = "pending"
|
||||
}
|
||||
item = s.mergeForPendingUpdate(s.GetByCheckPointID(item.CheckPointID), item)
|
||||
return repositories.ConversationInterruptRepository.UpsertByCheckPointID(sqls.DB(), item)
|
||||
}
|
||||
|
||||
func (s *conversationInterruptService) mergeForCheckpointUpdate(current, next *models.ConversationInterrupt) *models.ConversationInterrupt {
|
||||
if next == nil {
|
||||
return nil
|
||||
}
|
||||
if current == nil {
|
||||
return next
|
||||
}
|
||||
merged := *current
|
||||
merged.ConversationID = current.ConversationID
|
||||
merged.AIAgentID = current.AIAgentID
|
||||
merged.SourceMessageID = current.SourceMessageID
|
||||
merged.LastResumeMessageID = current.LastResumeMessageID
|
||||
merged.InterruptID = current.InterruptID
|
||||
merged.InterruptType = current.InterruptType
|
||||
merged.Status = current.Status
|
||||
merged.PromptText = current.PromptText
|
||||
merged.RequestData = current.RequestData
|
||||
merged.ResumeCount = current.ResumeCount
|
||||
merged.ExpiresAt = current.ExpiresAt
|
||||
merged.CheckPointData = next.CheckPointData
|
||||
merged.UpdatedAt = next.UpdatedAt
|
||||
return &merged
|
||||
}
|
||||
|
||||
func (s *conversationInterruptService) mergeForPendingUpdate(current, next *models.ConversationInterrupt) *models.ConversationInterrupt {
|
||||
if next == nil {
|
||||
return nil
|
||||
}
|
||||
if current == nil {
|
||||
return next
|
||||
}
|
||||
merged := *current
|
||||
merged.ConversationID = next.ConversationID
|
||||
merged.AIAgentID = next.AIAgentID
|
||||
merged.SourceMessageID = next.SourceMessageID
|
||||
merged.InterruptID = next.InterruptID
|
||||
merged.InterruptType = next.InterruptType
|
||||
merged.Status = next.Status
|
||||
merged.PromptText = next.PromptText
|
||||
merged.RequestData = next.RequestData
|
||||
merged.UpdatedAt = next.UpdatedAt
|
||||
return &merged
|
||||
}
|
||||
|
||||
func (s *conversationInterruptService) MarkResolved(id int64, lastResumeMessageID int64) error {
|
||||
current := s.Get(id)
|
||||
nextCount := 1
|
||||
if current != nil {
|
||||
nextCount = current.ResumeCount + 1
|
||||
}
|
||||
return repositories.ConversationInterruptRepository.Updates(sqls.DB(), id, map[string]any{
|
||||
"status": "resolved",
|
||||
"last_resume_message_id": lastResumeMessageID,
|
||||
"resume_count": nextCount,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *conversationInterruptService) MarkCancelled(id int64, lastResumeMessageID int64) error {
|
||||
current := s.Get(id)
|
||||
nextCount := 1
|
||||
if current != nil {
|
||||
nextCount = current.ResumeCount + 1
|
||||
}
|
||||
return repositories.ConversationInterruptRepository.Updates(sqls.DB(), id, map[string]any{
|
||||
"status": "cancelled",
|
||||
"last_resume_message_id": lastResumeMessageID,
|
||||
"resume_count": nextCount,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *conversationInterruptService) MarkExpired(id int64, lastResumeMessageID int64) error {
|
||||
current := s.Get(id)
|
||||
nextCount := 1
|
||||
if current != nil {
|
||||
nextCount = current.ResumeCount + 1
|
||||
}
|
||||
return repositories.ConversationInterruptRepository.Updates(sqls.DB(), id, map[string]any{
|
||||
"status": "expired",
|
||||
"last_resume_message_id": lastResumeMessageID,
|
||||
"resume_count": nextCount,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *conversationInterruptService) MarkPendingAgain(id int64, interruptID, promptText string, lastResumeMessageID int64) error {
|
||||
current := s.Get(id)
|
||||
nextCount := 1
|
||||
if current != nil {
|
||||
nextCount = current.ResumeCount + 1
|
||||
}
|
||||
return repositories.ConversationInterruptRepository.Updates(sqls.DB(), id, map[string]any{
|
||||
"status": "pending",
|
||||
"interrupt_id": strings.TrimSpace(interruptID),
|
||||
"prompt_text": strings.TrimSpace(promptText),
|
||||
"last_resume_message_id": lastResumeMessageID,
|
||||
"resume_count": nextCount,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"cs-agent/internal/pkg/openidentity"
|
||||
"cs-agent/internal/pkg/utils"
|
||||
"cs-agent/internal/repositories"
|
||||
"time"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"github.com/mlogclub/simple/web/params"
|
||||
)
|
||||
|
||||
var ConversationParticipantService = newConversationParticipantService()
|
||||
|
||||
func newConversationParticipantService() *conversationParticipantService {
|
||||
return &conversationParticipantService{}
|
||||
}
|
||||
|
||||
type conversationParticipantService struct {
|
||||
}
|
||||
|
||||
func (s *conversationParticipantService) Get(id int64) *models.ConversationParticipant {
|
||||
return repositories.ConversationParticipantRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *conversationParticipantService) Take(where ...interface{}) *models.ConversationParticipant {
|
||||
return repositories.ConversationParticipantRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *conversationParticipantService) Find(cnd *sqls.Cnd) []models.ConversationParticipant {
|
||||
return repositories.ConversationParticipantRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *conversationParticipantService) FindOne(cnd *sqls.Cnd) *models.ConversationParticipant {
|
||||
return repositories.ConversationParticipantRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *conversationParticipantService) FindPageByParams(params *params.QueryParams) (list []models.ConversationParticipant, paging *sqls.Paging) {
|
||||
return repositories.ConversationParticipantRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *conversationParticipantService) FindPageByCnd(cnd *sqls.Cnd) (list []models.ConversationParticipant, paging *sqls.Paging) {
|
||||
return repositories.ConversationParticipantRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *conversationParticipantService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.ConversationParticipantRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *conversationParticipantService) Create(t *models.ConversationParticipant) error {
|
||||
return repositories.ConversationParticipantRepository.Create(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *conversationParticipantService) Update(t *models.ConversationParticipant) error {
|
||||
return repositories.ConversationParticipantRepository.Update(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *conversationParticipantService) Updates(id int64, columns map[string]interface{}) error {
|
||||
return repositories.ConversationParticipantRepository.Updates(sqls.DB(), id, columns)
|
||||
}
|
||||
|
||||
func (s *conversationParticipantService) UpdateColumn(id int64, name string, value interface{}) error {
|
||||
return repositories.ConversationParticipantRepository.UpdateColumn(sqls.DB(), id, name, value)
|
||||
}
|
||||
|
||||
func (s *conversationParticipantService) Delete(id int64) {
|
||||
repositories.ConversationParticipantRepository.Delete(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *conversationParticipantService) CreateCustomerParticipant(ctx *sqls.TxContext, conversationID int64, externalInfo openidentity.ExternalInfo) error {
|
||||
return repositories.ConversationParticipantRepository.Create(ctx.Tx, &models.ConversationParticipant{
|
||||
ConversationID: conversationID,
|
||||
ParticipantType: string(enums.IMParticipantTypeCustomer),
|
||||
ParticipantID: 0,
|
||||
ExternalParticipantID: externalInfo.ExternalID,
|
||||
JoinedAt: new(time.Now()),
|
||||
Status: enums.StatusOk,
|
||||
AuditFields: utils.BuildAuditFields(nil),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
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/pkg/openidentity"
|
||||
"cs-agent/internal/repositories"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mlogclub/simple/common/strs"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"github.com/mlogclub/simple/web/params"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var ConversationReadStateService = newConversationReadStateService()
|
||||
|
||||
func newConversationReadStateService() *conversationReadStateService {
|
||||
return &conversationReadStateService{}
|
||||
}
|
||||
|
||||
type conversationReadStateService struct {
|
||||
}
|
||||
|
||||
// readerCursor 已读游标行的身份键(包内私有,供客服 / 客户两条路径共用)。
|
||||
type readerCursor struct {
|
||||
readerType enums.IMSenderType
|
||||
readerID int64
|
||||
externalReaderID string
|
||||
auditUserID int64
|
||||
auditUserName string
|
||||
}
|
||||
|
||||
func agentReaderCursor(operator *dto.AuthPrincipal) (readerCursor, error) {
|
||||
if operator == nil {
|
||||
return readerCursor{}, errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
return readerCursor{
|
||||
readerType: enums.IMSenderTypeAgent,
|
||||
readerID: operator.UserID,
|
||||
externalReaderID: "",
|
||||
auditUserID: operator.UserID,
|
||||
auditUserName: operator.Username,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func customerReaderCursor(external *openidentity.ExternalInfo) (readerCursor, error) {
|
||||
if external == nil || strings.TrimSpace(external.ExternalID) == "" {
|
||||
return readerCursor{}, errorsx.Unauthorized("外部用户标识不能为空")
|
||||
}
|
||||
extID := strings.TrimSpace(external.ExternalID)
|
||||
name := strings.TrimSpace(external.ExternalName)
|
||||
if name == "" {
|
||||
name = extID
|
||||
}
|
||||
return readerCursor{
|
||||
readerType: enums.IMSenderTypeCustomer,
|
||||
readerID: 0,
|
||||
externalReaderID: extID,
|
||||
auditUserID: 0,
|
||||
auditUserName: name,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *conversationReadStateService) Get(id int64) *models.ConversationReadState {
|
||||
return repositories.ConversationReadStateRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *conversationReadStateService) Take(where ...any) *models.ConversationReadState {
|
||||
return repositories.ConversationReadStateRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *conversationReadStateService) Find(cnd *sqls.Cnd) []models.ConversationReadState {
|
||||
return repositories.ConversationReadStateRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *conversationReadStateService) FindOne(cnd *sqls.Cnd) *models.ConversationReadState {
|
||||
return repositories.ConversationReadStateRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *conversationReadStateService) FindPageByParams(queryParams *params.QueryParams) (list []models.ConversationReadState, paging *sqls.Paging) {
|
||||
return repositories.ConversationReadStateRepository.FindPageByParams(sqls.DB(), queryParams)
|
||||
}
|
||||
|
||||
func (s *conversationReadStateService) FindPageByCnd(cnd *sqls.Cnd) (list []models.ConversationReadState, paging *sqls.Paging) {
|
||||
return repositories.ConversationReadStateRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *conversationReadStateService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.ConversationReadStateRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *conversationReadStateService) Create(item *models.ConversationReadState) error {
|
||||
return repositories.ConversationReadStateRepository.Create(sqls.DB(), item)
|
||||
}
|
||||
|
||||
func (s *conversationReadStateService) Update(item *models.ConversationReadState) error {
|
||||
return repositories.ConversationReadStateRepository.Update(sqls.DB(), item)
|
||||
}
|
||||
|
||||
func (s *conversationReadStateService) Updates(id int64, columns map[string]any) error {
|
||||
return repositories.ConversationReadStateRepository.Updates(sqls.DB(), id, columns)
|
||||
}
|
||||
|
||||
func (s *conversationReadStateService) UpdateColumn(id int64, name string, value any) error {
|
||||
return repositories.ConversationReadStateRepository.UpdateColumn(sqls.DB(), id, name, value)
|
||||
}
|
||||
|
||||
func (s *conversationReadStateService) Delete(id int64) {
|
||||
repositories.ConversationReadStateRepository.Delete(sqls.DB(), id)
|
||||
}
|
||||
|
||||
// GetByAgentReader 查询客服侧已读游标。
|
||||
func (s *conversationReadStateService) GetByAgentReader(conversationID int64, operator *dto.AuthPrincipal) *models.ConversationReadState {
|
||||
if operator == nil {
|
||||
return nil
|
||||
}
|
||||
return s.getByCursor(conversationID, readerCursor{
|
||||
readerType: enums.IMSenderTypeAgent,
|
||||
readerID: operator.UserID,
|
||||
externalReaderID: "",
|
||||
})
|
||||
}
|
||||
|
||||
// GetByCustomerReader 查询 IM 客户侧已读游标(按 ExternalID)。
|
||||
func (s *conversationReadStateService) GetByCustomerReader(conversationID int64, external *openidentity.ExternalInfo) *models.ConversationReadState {
|
||||
if external == nil || strings.TrimSpace(external.ExternalID) == "" {
|
||||
return nil
|
||||
}
|
||||
return s.getByCursor(conversationID, readerCursor{
|
||||
readerType: enums.IMSenderTypeCustomer,
|
||||
readerID: 0,
|
||||
externalReaderID: strings.TrimSpace(external.ExternalID),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *conversationReadStateService) getByCursor(conversationID int64, c readerCursor) *models.ConversationReadState {
|
||||
return s.FindOne(sqls.NewCnd().
|
||||
Eq("conversation_id", conversationID).
|
||||
Eq("reader_type", c.readerType).
|
||||
Eq("reader_id", c.readerID).
|
||||
Eq("external_reader_id", c.externalReaderID))
|
||||
}
|
||||
|
||||
func (s *conversationReadStateService) GetConversationReadStates(conversationID int64) (agentState, customerState *models.ConversationReadState) {
|
||||
return s.getConversationReadStates(sqls.DB(), conversationID)
|
||||
}
|
||||
|
||||
func (s *conversationReadStateService) getConversationReadStates(db *gorm.DB, conversationID int64) (agentState, customerState *models.ConversationReadState) {
|
||||
list := repositories.ConversationReadStateRepository.Find(db, sqls.NewCnd().Eq("conversation_id", conversationID))
|
||||
return s.pickConversationReadStates(list)
|
||||
}
|
||||
|
||||
func (s *conversationReadStateService) pickConversationReadStates(list []models.ConversationReadState) (agentState, customerState *models.ConversationReadState) {
|
||||
for i := range list {
|
||||
item := &list[i]
|
||||
switch item.ReaderType {
|
||||
case enums.IMSenderTypeAgent:
|
||||
if agentState == nil || item.LastReadSeqNo > agentState.LastReadSeqNo {
|
||||
agentState = item
|
||||
}
|
||||
case enums.IMSenderTypeCustomer:
|
||||
if customerState == nil || item.LastReadSeqNo > customerState.LastReadSeqNo {
|
||||
customerState = item
|
||||
}
|
||||
}
|
||||
}
|
||||
return agentState, customerState
|
||||
}
|
||||
|
||||
// MarkAgentRead 在事务内更新/创建客服已读游标。
|
||||
func (s *conversationReadStateService) MarkAgentRead(ctx *sqls.TxContext, conversation *models.Conversation, operator *dto.AuthPrincipal, message *models.Message, now time.Time) (*models.ConversationReadState, error) {
|
||||
c, err := agentReaderCursor(operator)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.markReadTxWithCursor(ctx, conversation, c, message, now)
|
||||
}
|
||||
|
||||
// MarkCustomerRead 在事务内更新/创建 IM 客户已读游标。
|
||||
func (s *conversationReadStateService) MarkCustomerRead(ctx *sqls.TxContext, conversation *models.Conversation, external *openidentity.ExternalInfo, message *models.Message, now time.Time) (*models.ConversationReadState, error) {
|
||||
c, err := customerReaderCursor(external)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.markReadTxWithCursor(ctx, conversation, c, message, now)
|
||||
}
|
||||
|
||||
func (s *conversationReadStateService) markReadTxWithCursor(ctx *sqls.TxContext, conversation *models.Conversation, c readerCursor, message *models.Message, now time.Time) (*models.ConversationReadState, error) {
|
||||
if ctx == nil || conversation == nil || message == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if c.readerType != enums.IMSenderTypeAgent && c.readerType != enums.IMSenderTypeCustomer {
|
||||
return nil, errorsx.InvalidParam("不支持的已读操作类型")
|
||||
}
|
||||
|
||||
item := &models.ConversationReadState{}
|
||||
err := ctx.Tx.Where("conversation_id = ? AND reader_type = ? AND reader_id = ? AND external_reader_id = ?",
|
||||
conversation.ID, c.readerType, c.readerID, c.externalReaderID,
|
||||
).First(item).Error
|
||||
if err != nil {
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
return nil, err
|
||||
}
|
||||
item = &models.ConversationReadState{
|
||||
ConversationID: conversation.ID,
|
||||
ReaderType: c.readerType,
|
||||
ReaderID: c.readerID,
|
||||
ExternalReaderID: c.externalReaderID,
|
||||
LastReadMessageID: message.ID,
|
||||
LastReadSeqNo: message.SeqNo,
|
||||
LastReadAt: &now,
|
||||
AuditFields: models.AuditFields{
|
||||
CreatedAt: now,
|
||||
CreateUserID: c.auditUserID,
|
||||
CreateUserName: c.auditUserName,
|
||||
UpdatedAt: now,
|
||||
UpdateUserID: c.auditUserID,
|
||||
UpdateUserName: c.auditUserName,
|
||||
},
|
||||
}
|
||||
if err := ctx.Tx.Create(item).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
if item.LastReadSeqNo >= message.SeqNo {
|
||||
return item, nil
|
||||
}
|
||||
|
||||
item.LastReadMessageID = message.ID
|
||||
item.LastReadSeqNo = message.SeqNo
|
||||
item.LastReadAt = &now
|
||||
item.UpdatedAt = now
|
||||
item.UpdateUserID = c.auditUserID
|
||||
item.UpdateUserName = c.auditUserName
|
||||
if err := repositories.ConversationReadStateRepository.Updates(ctx.Tx, item.ID, map[string]any{
|
||||
"last_read_message_id": item.LastReadMessageID,
|
||||
"last_read_seq_no": item.LastReadSeqNo,
|
||||
"last_read_at": item.LastReadAt,
|
||||
"updated_at": item.UpdatedAt,
|
||||
"update_user_id": item.UpdateUserID,
|
||||
"update_user_name": item.UpdateUserName,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *conversationReadStateService) CountUnreadMessages(ctx *sqls.TxContext, conversationID, lastReadSeqNo int64, senderTypes ...enums.IMSenderType) (int64, error) {
|
||||
normalizedSenderTypes := make([]enums.IMSenderType, 0, len(senderTypes))
|
||||
for _, senderType := range senderTypes {
|
||||
if strs.IsBlank(string(senderType)) {
|
||||
continue
|
||||
}
|
||||
normalizedSenderTypes = append(normalizedSenderTypes, senderType)
|
||||
}
|
||||
if len(normalizedSenderTypes) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var count int64
|
||||
query := ctx.Tx.Model(&models.Message{}).
|
||||
Where("conversation_id = ? AND seq_no > ? AND recalled_at IS NULL AND send_status <> ?", conversationID, lastReadSeqNo, int(enums.IMMessageStatusRecalled))
|
||||
if len(normalizedSenderTypes) == 1 {
|
||||
query = query.Where("sender_type = ?", normalizedSenderTypes[0])
|
||||
} else {
|
||||
query = query.Where("sender_type IN ?", normalizedSenderTypes)
|
||||
}
|
||||
err := query.Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
@@ -0,0 +1,734 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/constants"
|
||||
"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/openidentity"
|
||||
"cs-agent/internal/pkg/utils"
|
||||
"cs-agent/internal/repositories"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mlogclub/simple/common/strs"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
var ConversationService = newConversationService()
|
||||
|
||||
func newConversationService() *conversationService {
|
||||
return &conversationService{}
|
||||
}
|
||||
|
||||
type conversationService struct {
|
||||
}
|
||||
|
||||
func (s *conversationService) Get(id int64) *models.Conversation {
|
||||
if id <= 0 {
|
||||
return nil
|
||||
}
|
||||
return repositories.ConversationRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *conversationService) Find(cnd *sqls.Cnd) []models.Conversation {
|
||||
return repositories.ConversationRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *conversationService) FindOne(cnd *sqls.Cnd) *models.Conversation {
|
||||
return repositories.ConversationRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *conversationService) FindPageByCnd(cnd *sqls.Cnd) (list []models.Conversation, paging *sqls.Paging) {
|
||||
return repositories.ConversationRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *conversationService) ListConversations(userID int64, filter request.AgentConversationFilter, keyword string, paging *sqls.Paging) ([]models.Conversation, *sqls.Paging, error) {
|
||||
cnd := sqls.NewCnd().Page(paging.Page, paging.Limit)
|
||||
|
||||
if strs.IsNotBlank(keyword) {
|
||||
keyword = strings.TrimSpace(keyword)
|
||||
cnd.Where("subject LIKE ? OR external_id LIKE ? OR last_message_summary LIKE ?", "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%")
|
||||
}
|
||||
|
||||
switch filter {
|
||||
case request.AgentConversationFilterMine:
|
||||
cnd.Eq("current_assignee_id", userID).Desc("last_active_at").Desc("id")
|
||||
case request.AgentConversationFilterActive:
|
||||
cnd.Eq("current_assignee_id", userID).Eq("status", enums.IMConversationStatusActive).Desc("last_active_at").Desc("id")
|
||||
case request.AgentConversationFilterPending:
|
||||
cnd.Eq("current_assignee_id", 0).Eq("status", enums.IMConversationStatusPending).Asc("last_active_at").Desc("id")
|
||||
case request.AgentConversationFilterClosed:
|
||||
cnd.Eq("current_assignee_id", userID).Eq("status", enums.IMConversationStatusClosed).Desc("last_active_at").Desc("id")
|
||||
default:
|
||||
return nil, nil, errorsx.InvalidParam("会话筛选项不合法")
|
||||
}
|
||||
|
||||
list, paging := repositories.ConversationRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
return list, paging, nil
|
||||
}
|
||||
|
||||
func (s *conversationService) Updates(id int64, columns map[string]interface{}) error {
|
||||
return repositories.ConversationRepository.Updates(sqls.DB(), id, columns)
|
||||
}
|
||||
|
||||
func (s *conversationService) getLatestNotFinished(externalInfo openidentity.ExternalInfo) *models.Conversation {
|
||||
cnd := sqls.NewCnd()
|
||||
cnd.Eq("external_id", externalInfo.ExternalID)
|
||||
cnd.Eq("external_source", externalInfo.ExternalSource)
|
||||
cnd.In("status", []enums.IMConversationStatus{
|
||||
enums.IMConversationStatusPending,
|
||||
enums.IMConversationStatusActive,
|
||||
})
|
||||
cnd.Desc("id")
|
||||
return s.FindOne(cnd)
|
||||
}
|
||||
|
||||
func (s *conversationService) Create(externalInfo openidentity.ExternalInfo, aiAgentID int64) (*models.Conversation, error) {
|
||||
subject := s.buildDefaultSubject(externalInfo)
|
||||
|
||||
// 会话存在,直接返回
|
||||
if conversation := s.getLatestNotFinished(externalInfo); conversation != nil {
|
||||
return conversation, nil
|
||||
}
|
||||
|
||||
aiAgent := AIAgentService.Get(aiAgentID)
|
||||
if aiAgent == nil || aiAgent.Status != enums.StatusOk {
|
||||
return nil, errorsx.InvalidParam("AI Agent not found")
|
||||
}
|
||||
|
||||
conversation := &models.Conversation{
|
||||
AIAgentID: aiAgentID,
|
||||
ExternalSource: externalInfo.ExternalSource,
|
||||
Subject: subject,
|
||||
Status: enums.IMConversationStatusPending,
|
||||
ServiceMode: aiAgent.ServiceMode,
|
||||
Priority: 0,
|
||||
ExternalID: externalInfo.ExternalID,
|
||||
CurrentAssigneeID: 0,
|
||||
CurrentTeamID: 0,
|
||||
LastMessageAt: time.Now(),
|
||||
LastActiveAt: time.Now(),
|
||||
AuditFields: utils.BuildAuditFields(nil),
|
||||
}
|
||||
if identity := repositories.CustomerIdentityRepository.GetBy(sqls.DB(), externalInfo.ExternalSource, externalInfo.ExternalID); identity != nil {
|
||||
conversation.CustomerID = identity.CustomerID
|
||||
}
|
||||
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
if err := ctx.Tx.Create(conversation).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ConversationParticipantService.CreateCustomerParticipant(ctx, conversation.ID, externalInfo); err != nil {
|
||||
return err
|
||||
}
|
||||
return ConversationEventLogService.CreateEvent(ctx, conversation.ID, enums.IMEventTypeCreate, enums.IMSenderTypeCustomer, 0, "用户创建会话", "")
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 推送会话创建事件
|
||||
WsService.PublishConversationChanged(conversation, enums.IMRealtimeEventConversationCreated)
|
||||
|
||||
// AI Agent仅人工模式,且有值班客服,尝试自动分配会话
|
||||
if conversation.Status == enums.IMConversationStatusPending &&
|
||||
aiAgent.ServiceMode == enums.IMConversationServiceModeHumanOnly &&
|
||||
len(utils.SplitInt64s(aiAgent.TeamIDs)) > 0 {
|
||||
if dispatched, err := ConversationDispatchService.DispatchPendingConversation(conversation, aiAgent); err != nil {
|
||||
return nil, err
|
||||
} else if dispatched != nil {
|
||||
return dispatched, nil
|
||||
}
|
||||
}
|
||||
return s.Get(conversation.ID), nil
|
||||
}
|
||||
|
||||
func (s *conversationService) AssignConversation(req request.AssignConversationRequest, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
targetProfile := AgentProfileService.GetByUserID(req.AssigneeID)
|
||||
if targetProfile == nil || targetProfile.Status != enums.StatusOk {
|
||||
return errorsx.InvalidParam("目标客服不存在")
|
||||
}
|
||||
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
conversation := repositories.ConversationRepository.Get(ctx.Tx, req.ConversationID)
|
||||
if conversation == nil {
|
||||
return errorsx.InvalidParam("会话不存在")
|
||||
}
|
||||
if conversation.Status != enums.IMConversationStatusPending {
|
||||
return errorsx.InvalidParam("只有待接入会话允许分配")
|
||||
}
|
||||
now := time.Now()
|
||||
if err := ConversationAssignmentService.FinishActiveAssignments(ctx, req.ConversationID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ConversationAssignmentService.CreateAssignment(ctx, req.ConversationID, conversation.CurrentAssigneeID, req.AssigneeID, enums.IMAssignmentTypeAssign, req.Reason, operator, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := repositories.ConversationRepository.Updates(ctx.Tx, req.ConversationID, map[string]any{
|
||||
"current_assignee_id": req.AssigneeID,
|
||||
"status": enums.IMConversationStatusActive,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": now,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return ConversationEventLogService.CreateEvent(ctx, req.ConversationID, enums.IMEventTypeAssign, enums.IMSenderTypeAgent, operator.UserID, "会话已分配", s.buildEventPayload(map[string]any{
|
||||
"fromStatus": conversation.Status,
|
||||
"toStatus": enums.IMConversationStatusActive,
|
||||
"fromAssigneeId": conversation.CurrentAssigneeID,
|
||||
"toAssigneeId": req.AssigneeID,
|
||||
"reason": strings.TrimSpace(req.Reason),
|
||||
}))
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if conversation := s.Get(req.ConversationID); conversation != nil {
|
||||
WsService.PublishConversationChanged(conversation, enums.IMRealtimeEventConversationAssigned)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *conversationService) AutoAssignConversation(conversationID int64, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
|
||||
conversation := s.Get(conversationID)
|
||||
if conversation == nil {
|
||||
return errorsx.InvalidParam("会话不存在")
|
||||
}
|
||||
if conversation.Status != enums.IMConversationStatusPending {
|
||||
return errorsx.InvalidParam("只有待接入会话允许自动分配")
|
||||
}
|
||||
if conversation.CurrentAssigneeID > 0 {
|
||||
return errorsx.InvalidParam("当前会话已分配客服")
|
||||
}
|
||||
|
||||
dispatched, err := ConversationDispatchService.DispatchConversation(conversationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dispatched == nil {
|
||||
return errorsx.InvalidParam("当前暂无可自动分配的值班客服")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *conversationService) TransferConversation(conversationID, toUserID int64, reason string, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
if toUserID <= 0 {
|
||||
return errorsx.InvalidParam("目标客服不能为空")
|
||||
}
|
||||
targetProfile := AgentProfileService.GetByUserID(toUserID)
|
||||
if targetProfile == nil || targetProfile.Status != enums.StatusOk {
|
||||
return errorsx.InvalidParam("目标客服不存在")
|
||||
}
|
||||
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
conversation := repositories.ConversationRepository.Get(ctx.Tx, conversationID)
|
||||
if conversation == nil {
|
||||
return errorsx.InvalidParam("会话不存在")
|
||||
}
|
||||
if !s.canTransferConversation(conversation, operator) {
|
||||
return errorsx.Forbidden("无权转接该会话")
|
||||
}
|
||||
if conversation.Status != enums.IMConversationStatusActive {
|
||||
return errorsx.InvalidParam("只有处理中会话允许转接")
|
||||
}
|
||||
if conversation.CurrentAssigneeID <= 0 {
|
||||
return errorsx.InvalidParam("当前会话未分配客服")
|
||||
}
|
||||
if conversation.CurrentAssigneeID == toUserID {
|
||||
return errorsx.InvalidParam("目标客服不能与当前指派人相同")
|
||||
}
|
||||
now := time.Now()
|
||||
if err := ConversationAssignmentService.FinishActiveAssignments(ctx, conversationID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ConversationAssignmentService.CreateAssignment(ctx, conversationID, conversation.CurrentAssigneeID, toUserID, enums.IMAssignmentTypeTransfer, reason, operator, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := repositories.ConversationRepository.Updates(ctx.Tx, conversationID, map[string]any{
|
||||
"current_assignee_id": toUserID,
|
||||
"status": enums.IMConversationStatusActive,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": now,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return ConversationEventLogService.CreateEvent(ctx, conversationID, enums.IMEventTypeTransfer, enums.IMSenderTypeAgent, operator.UserID, "会话已转接", s.buildEventPayload(map[string]any{
|
||||
"fromStatus": conversation.Status,
|
||||
"toStatus": enums.IMConversationStatusActive,
|
||||
"fromAssigneeId": conversation.CurrentAssigneeID,
|
||||
"toAssigneeId": toUserID,
|
||||
"reason": strings.TrimSpace(reason),
|
||||
}))
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if conversation := s.Get(conversationID); conversation != nil {
|
||||
WsService.PublishConversationChanged(conversation, enums.IMRealtimeEventConversationTransferred)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *conversationService) CloseConversation(conversationID int64, closeReason string, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
return s.closeConversation(conversationID, enums.IMSenderTypeAgent, closeReason, operator)
|
||||
}
|
||||
|
||||
func (s *conversationService) CloseCustomerConversation(conversationID int64, externalInfo openidentity.ExternalInfo) error {
|
||||
conversation := s.Get(conversationID)
|
||||
if conversation == nil {
|
||||
return errorsx.InvalidParam("会话不存在")
|
||||
}
|
||||
if !s.IsCustomerConversationOwner(conversation, externalInfo) {
|
||||
return errorsx.Forbidden("无权访问该会话")
|
||||
}
|
||||
return s.closeConversation(conversationID, enums.IMSenderTypeCustomer, "", nil)
|
||||
}
|
||||
|
||||
func (s *conversationService) closeConversation(conversationID int64, senderType enums.IMSenderType, closeReason string, operator *dto.AuthPrincipal) error {
|
||||
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
conversation := repositories.ConversationRepository.Get(ctx.Tx, conversationID)
|
||||
if conversation == nil {
|
||||
return errorsx.InvalidParam("会话不存在")
|
||||
}
|
||||
if conversation.Status == enums.IMConversationStatusClosed {
|
||||
return nil
|
||||
}
|
||||
if conversation.Status != enums.IMConversationStatusPending && conversation.Status != enums.IMConversationStatusActive {
|
||||
return errorsx.InvalidParam("当前状态不允许关闭会话")
|
||||
}
|
||||
var (
|
||||
now = time.Now()
|
||||
eventDesc = "会话已关闭"
|
||||
operatorID int64
|
||||
operatorName string
|
||||
)
|
||||
closeReason = strings.TrimSpace(closeReason)
|
||||
if senderType == enums.IMSenderTypeCustomer {
|
||||
eventDesc = "客户关闭会话"
|
||||
} else {
|
||||
if operator == nil {
|
||||
return errorsx.InvalidParam("无权限操作")
|
||||
}
|
||||
if closeReason == "" {
|
||||
return errorsx.InvalidParam("关闭原因不能为空")
|
||||
}
|
||||
if !s.canCloseConversation(conversation, operator) {
|
||||
return errorsx.Forbidden("无权关闭该会话")
|
||||
}
|
||||
operatorID = operator.UserID
|
||||
operatorName = operator.Nickname
|
||||
}
|
||||
if err := ConversationAssignmentService.FinishActiveAssignments(ctx, conversationID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := repositories.ConversationRepository.Updates(ctx.Tx, conversationID, map[string]any{
|
||||
"status": enums.IMConversationStatusClosed,
|
||||
"closed_at": now,
|
||||
"closed_by": operatorID,
|
||||
"close_reason": closeReason,
|
||||
"update_user_id": operatorID,
|
||||
"update_user_name": operatorName,
|
||||
"updated_at": now,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return ConversationEventLogService.CreateEvent(ctx, conversationID, enums.IMEventTypeClose, senderType, operatorID, eventDesc, s.buildEventPayload(map[string]any{
|
||||
"fromStatus": conversation.Status,
|
||||
"toStatus": enums.IMConversationStatusClosed,
|
||||
"fromAssigneeId": conversation.CurrentAssigneeID,
|
||||
"toAssigneeId": conversation.CurrentAssigneeID,
|
||||
"closeReason": closeReason,
|
||||
}))
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if conversation := s.Get(conversationID); conversation != nil {
|
||||
WsService.PublishConversationChanged(conversation, enums.IMRealtimeEventConversationClosed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkAgentConversationReadToMessage 控制台客服将会话已读推进到指定消息。
|
||||
func (s *conversationService) MarkAgentConversationReadToMessage(conversationID, messageID int64, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
conversation := s.Get(conversationID)
|
||||
if conversation == nil {
|
||||
return errorsx.InvalidParam("会话不存在")
|
||||
}
|
||||
changed, err := s.markConversationReadWithActor(conversation, messageID, agentConversationReadActor{operator: operator})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed {
|
||||
if updated := s.Get(conversationID); updated != nil {
|
||||
WsService.PublishConversationChanged(updated, enums.IMRealtimeEventConversationRead)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkCustomerConversationReadToMessage IM 客户将会话已读推进到指定消息(需为会话归属外部身份)。
|
||||
func (s *conversationService) MarkCustomerConversationReadToMessage(conversationID, messageID int64, external *openidentity.ExternalInfo) error {
|
||||
if external == nil || strings.TrimSpace(external.ExternalID) == "" {
|
||||
return errorsx.Unauthorized("外部用户标识不能为空")
|
||||
}
|
||||
conversation := s.Get(conversationID)
|
||||
if conversation == nil {
|
||||
return errorsx.InvalidParam("会话不存在")
|
||||
}
|
||||
if !s.IsCustomerConversationOwner(conversation, *external) {
|
||||
return errorsx.Forbidden("无权访问该会话")
|
||||
}
|
||||
changed, err := s.markConversationReadWithActor(conversation, messageID, customerConversationReadActor{external: external})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed {
|
||||
if updated := s.Get(conversationID); updated != nil {
|
||||
WsService.PublishConversationChanged(updated, enums.IMRealtimeEventConversationRead)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func displayExternalName(ext *openidentity.ExternalInfo) string {
|
||||
if ext == nil {
|
||||
return ""
|
||||
}
|
||||
if n := strings.TrimSpace(ext.ExternalName); n != "" {
|
||||
return n
|
||||
}
|
||||
return strings.TrimSpace(ext.ExternalID)
|
||||
}
|
||||
|
||||
// conversationReadActor 抽象「读者身份」,供 markConversationReadWithActor 共用(包内私有)。
|
||||
type conversationReadActor interface {
|
||||
isAgentSide() bool
|
||||
getReadState(conversationID int64) *models.ConversationReadState
|
||||
markReadTx(ctx *sqls.TxContext, conversation *models.Conversation, targetMessage *models.Message, now time.Time) error
|
||||
conversationUpdateAudit() (userID int64, userName string)
|
||||
}
|
||||
|
||||
type agentConversationReadActor struct {
|
||||
operator *dto.AuthPrincipal
|
||||
}
|
||||
|
||||
func (a agentConversationReadActor) isAgentSide() bool { return true }
|
||||
|
||||
func (a agentConversationReadActor) getReadState(conversationID int64) *models.ConversationReadState {
|
||||
return ConversationReadStateService.GetByAgentReader(conversationID, a.operator)
|
||||
}
|
||||
|
||||
func (a agentConversationReadActor) markReadTx(ctx *sqls.TxContext, conversation *models.Conversation, targetMessage *models.Message, now time.Time) error {
|
||||
_, err := ConversationReadStateService.MarkAgentRead(ctx, conversation, a.operator, targetMessage, now)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a agentConversationReadActor) conversationUpdateAudit() (int64, string) {
|
||||
if a.operator == nil {
|
||||
return 0, ""
|
||||
}
|
||||
return a.operator.UserID, a.operator.Username
|
||||
}
|
||||
|
||||
type customerConversationReadActor struct {
|
||||
external *openidentity.ExternalInfo
|
||||
}
|
||||
|
||||
func (a customerConversationReadActor) isAgentSide() bool { return false }
|
||||
|
||||
func (a customerConversationReadActor) getReadState(conversationID int64) *models.ConversationReadState {
|
||||
return ConversationReadStateService.GetByCustomerReader(conversationID, a.external)
|
||||
}
|
||||
|
||||
func (a customerConversationReadActor) markReadTx(ctx *sqls.TxContext, conversation *models.Conversation, targetMessage *models.Message, now time.Time) error {
|
||||
_, err := ConversationReadStateService.MarkCustomerRead(ctx, conversation, a.external, targetMessage, now)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a customerConversationReadActor) conversationUpdateAudit() (int64, string) {
|
||||
return 0, displayExternalName(a.external)
|
||||
}
|
||||
|
||||
func (s *conversationService) markConversationReadWithActor(conversation *models.Conversation, messageID int64, actor conversationReadActor) (bool, error) {
|
||||
if conversation == nil {
|
||||
return false, errorsx.InvalidParam("会话不存在")
|
||||
}
|
||||
targetMessage, err := MessageService.GetConversationReadTarget(conversation.ID, messageID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if targetMessage == nil {
|
||||
if actor.isAgentSide() && conversation.AgentUnreadCount == 0 {
|
||||
return false, nil
|
||||
}
|
||||
if !actor.isAgentSide() && conversation.CustomerUnreadCount == 0 {
|
||||
return false, nil
|
||||
}
|
||||
now := time.Now()
|
||||
updateUserID, updateUserName := actor.conversationUpdateAudit()
|
||||
updates := map[string]any{
|
||||
"update_user_id": updateUserID,
|
||||
"update_user_name": updateUserName,
|
||||
"updated_at": now,
|
||||
}
|
||||
if actor.isAgentSide() {
|
||||
updates["agent_unread_count"] = 0
|
||||
} else {
|
||||
updates["customer_unread_count"] = 0
|
||||
}
|
||||
return true, s.Updates(conversation.ID, updates)
|
||||
}
|
||||
|
||||
currentReadState := actor.getReadState(conversation.ID)
|
||||
if currentReadState != nil && currentReadState.LastReadSeqNo >= targetMessage.SeqNo {
|
||||
if actor.isAgentSide() && conversation.AgentUnreadCount == 0 {
|
||||
return false, nil
|
||||
}
|
||||
if !actor.isAgentSide() && conversation.CustomerUnreadCount == 0 {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
err = sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
currentConversation := repositories.ConversationRepository.Get(ctx.Tx, conversation.ID)
|
||||
if currentConversation == nil {
|
||||
return errorsx.InvalidParam("会话不存在")
|
||||
}
|
||||
now := time.Now()
|
||||
if err := actor.markReadTx(ctx, currentConversation, targetMessage, now); err != nil {
|
||||
return err
|
||||
}
|
||||
agentReadState, customerReadState := ConversationReadStateService.getConversationReadStates(ctx.Tx, currentConversation.ID)
|
||||
agentUnreadCount, err := s.countUnreadByState(ctx, currentConversation.ID, agentReadState, enums.IMSenderTypeCustomer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
customerUnreadCount, err := s.countUnreadByState(ctx, currentConversation.ID, customerReadState, enums.IMSenderTypeAgent, enums.IMSenderTypeAI)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if actor.isAgentSide() && currentConversation.AgentUnreadCount == agentUnreadCount && currentReadState != nil && currentReadState.LastReadSeqNo >= targetMessage.SeqNo {
|
||||
return nil
|
||||
}
|
||||
if !actor.isAgentSide() && currentConversation.CustomerUnreadCount == customerUnreadCount && currentReadState != nil && currentReadState.LastReadSeqNo >= targetMessage.SeqNo {
|
||||
return nil
|
||||
}
|
||||
updateUserID, updateUserName := actor.conversationUpdateAudit()
|
||||
return repositories.ConversationRepository.Updates(ctx.Tx, currentConversation.ID, map[string]any{
|
||||
"agent_unread_count": agentUnreadCount,
|
||||
"customer_unread_count": customerUnreadCount,
|
||||
"update_user_id": updateUserID,
|
||||
"update_user_name": updateUserName,
|
||||
"updated_at": now,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *conversationService) countUnreadByState(ctx *sqls.TxContext, conversationID int64, state *models.ConversationReadState, senderTypes ...enums.IMSenderType) (int, error) {
|
||||
lastReadSeqNo := int64(0)
|
||||
if state != nil {
|
||||
lastReadSeqNo = state.LastReadSeqNo
|
||||
}
|
||||
normalizedSenderTypes := make([]enums.IMSenderType, 0, len(senderTypes))
|
||||
for _, senderType := range senderTypes {
|
||||
normalizedSenderTypes = append(normalizedSenderTypes, senderType)
|
||||
}
|
||||
count, err := ConversationReadStateService.CountUnreadMessages(ctx, conversationID, lastReadSeqNo, normalizedSenderTypes...)
|
||||
return int(count), err
|
||||
}
|
||||
|
||||
func (s *conversationService) IsCustomerConversationOwner(conversation *models.Conversation, externalInfo openidentity.ExternalInfo) bool {
|
||||
if conversation == nil {
|
||||
return false
|
||||
}
|
||||
extID := strings.TrimSpace(externalInfo.ExternalID)
|
||||
if extID == "" || strings.TrimSpace(conversation.ExternalID) == "" {
|
||||
return false
|
||||
}
|
||||
if conversation.ExternalID != extID {
|
||||
return false
|
||||
}
|
||||
reqSrc := strings.TrimSpace(string(externalInfo.ExternalSource))
|
||||
convSrc := strings.TrimSpace(string(conversation.ExternalSource))
|
||||
if convSrc != "" {
|
||||
if reqSrc == "" || reqSrc != convSrc {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *conversationService) BuildConversationSummary(conversation *models.Conversation) string {
|
||||
if conversation == nil {
|
||||
return ""
|
||||
}
|
||||
if strings.TrimSpace(conversation.LastMessageSummary) != "" {
|
||||
return conversation.LastMessageSummary
|
||||
}
|
||||
return strings.TrimSpace(conversation.Subject)
|
||||
}
|
||||
|
||||
func (s *conversationService) canCloseConversation(conversation *models.Conversation, operator *dto.AuthPrincipal) bool {
|
||||
if conversation == nil || operator == nil {
|
||||
return false
|
||||
}
|
||||
if s.isAdmin(operator) {
|
||||
return true
|
||||
}
|
||||
return conversation.Status == enums.IMConversationStatusActive && conversation.CurrentAssigneeID > 0 && conversation.CurrentAssigneeID == operator.UserID
|
||||
}
|
||||
|
||||
func (s *conversationService) canTransferConversation(conversation *models.Conversation, operator *dto.AuthPrincipal) bool {
|
||||
if conversation == nil || operator == nil {
|
||||
return false
|
||||
}
|
||||
if s.isAdmin(operator) {
|
||||
return true
|
||||
}
|
||||
return conversation.Status == enums.IMConversationStatusActive &&
|
||||
conversation.CurrentAssigneeID > 0 &&
|
||||
conversation.CurrentAssigneeID == operator.UserID
|
||||
}
|
||||
|
||||
func (s *conversationService) isAdmin(operator *dto.AuthPrincipal) bool {
|
||||
if operator == nil {
|
||||
return false
|
||||
}
|
||||
return slices.Contains(operator.Roles, constants.RoleCodeSuperAdmin) || slices.Contains(operator.Roles, constants.RoleCodeAdmin)
|
||||
}
|
||||
|
||||
func (s *conversationService) buildEventPayload(payload map[string]any) string {
|
||||
if len(payload) == 0 {
|
||||
return ""
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// LinkConversationCustomer 将会话绑定到指定客户;若会话带外部访客标识则维护 CustomerIdentity(与创建会话时逻辑一致)。
|
||||
func (s *conversationService) LinkConversationCustomer(conversationID, customerID int64, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
if conversationID <= 0 || customerID <= 0 {
|
||||
return errorsx.InvalidParam("参数不合法")
|
||||
}
|
||||
cust := CustomerService.Get(customerID)
|
||||
if cust == nil || cust.Status == enums.StatusDeleted {
|
||||
return errorsx.InvalidParam("客户不存在")
|
||||
}
|
||||
conv := s.Get(conversationID)
|
||||
if conv == nil {
|
||||
return errorsx.InvalidParam("会话不存在")
|
||||
}
|
||||
if conv.Status == enums.IMConversationStatusClosed {
|
||||
return errorsx.InvalidParam("已关闭的会话无法关联客户")
|
||||
}
|
||||
if !s.canLinkConversationCustomer(conv, operator) {
|
||||
return errorsx.Forbidden("无权限关联该会话")
|
||||
}
|
||||
|
||||
extID := strings.TrimSpace(conv.ExternalID)
|
||||
extSrc := strings.TrimSpace(string(conv.ExternalSource))
|
||||
|
||||
err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
current := repositories.ConversationRepository.Get(ctx.Tx, conversationID)
|
||||
if current == nil {
|
||||
return errorsx.InvalidParam("会话不存在")
|
||||
}
|
||||
if extID != "" && extSrc != "" {
|
||||
existing := repositories.CustomerIdentityRepository.GetBy(ctx.Tx, enums.ExternalSource(extSrc), extID)
|
||||
if existing != nil {
|
||||
if existing.CustomerID != customerID {
|
||||
return errorsx.BusinessError(1, "该访客身份已绑定其他客户,无法关联到当前选择")
|
||||
}
|
||||
} else {
|
||||
idRow := &models.CustomerIdentity{
|
||||
CustomerID: customerID,
|
||||
ExternalSource: enums.ExternalSource(extSrc),
|
||||
ExternalID: extID,
|
||||
Status: enums.StatusOk,
|
||||
AuditFields: utils.BuildAuditFields(operator),
|
||||
}
|
||||
if err := repositories.CustomerIdentityRepository.Create(ctx.Tx, idRow); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
return repositories.ConversationRepository.Updates(ctx.Tx, conversationID, map[string]any{
|
||||
"customer_id": customerID,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": now,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if updated := s.Get(conversationID); updated != nil {
|
||||
WsService.PublishConversationChanged(updated, enums.IMRealtimeEventConversationUpdated)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *conversationService) canLinkConversationCustomer(conv *models.Conversation, operator *dto.AuthPrincipal) bool {
|
||||
if conv == nil || operator == nil {
|
||||
return false
|
||||
}
|
||||
if s.isAdmin(operator) {
|
||||
return true
|
||||
}
|
||||
switch conv.Status {
|
||||
case enums.IMConversationStatusPending:
|
||||
return true
|
||||
case enums.IMConversationStatusActive:
|
||||
return conv.CurrentAssigneeID == 0 || conv.CurrentAssigneeID == operator.UserID
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *conversationService) buildDefaultSubject(externalInfo openidentity.ExternalInfo) string {
|
||||
if strs.IsNotBlank(externalInfo.ExternalName) {
|
||||
return externalInfo.ExternalName
|
||||
}
|
||||
return fmt.Sprintf("访客%s", hashUUID(externalInfo.ExternalID))
|
||||
}
|
||||
|
||||
func hashUUID(uuid string) string {
|
||||
if uuid == "" {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
h := md5.Sum([]byte(uuid))
|
||||
return hex.EncodeToString(h[:])[:8]
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
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/utils"
|
||||
"cs-agent/internal/repositories"
|
||||
"errors"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"github.com/mlogclub/simple/web/params"
|
||||
)
|
||||
|
||||
var ConversationTagService = newConversationTagService()
|
||||
|
||||
func newConversationTagService() *conversationTagService {
|
||||
return &conversationTagService{}
|
||||
}
|
||||
|
||||
type conversationTagService struct {
|
||||
}
|
||||
|
||||
func (s *conversationTagService) Get(id int64) *models.ConversationTag {
|
||||
return repositories.ConversationTagRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *conversationTagService) Take(where ...interface{}) *models.ConversationTag {
|
||||
return repositories.ConversationTagRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *conversationTagService) Find(cnd *sqls.Cnd) []models.ConversationTag {
|
||||
return repositories.ConversationTagRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *conversationTagService) FindOne(cnd *sqls.Cnd) *models.ConversationTag {
|
||||
return repositories.ConversationTagRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *conversationTagService) FindPageByParams(params *params.QueryParams) (list []models.ConversationTag, paging *sqls.Paging) {
|
||||
return repositories.ConversationTagRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *conversationTagService) FindPageByCnd(cnd *sqls.Cnd) (list []models.ConversationTag, paging *sqls.Paging) {
|
||||
return repositories.ConversationTagRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *conversationTagService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.ConversationTagRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *conversationTagService) Create(t *models.ConversationTag) error {
|
||||
return repositories.ConversationTagRepository.Create(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *conversationTagService) Update(t *models.ConversationTag) error {
|
||||
return repositories.ConversationTagRepository.Update(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *conversationTagService) Updates(id int64, columns map[string]interface{}) error {
|
||||
return repositories.ConversationTagRepository.Updates(sqls.DB(), id, columns)
|
||||
}
|
||||
|
||||
func (s *conversationTagService) UpdateColumn(id int64, name string, value interface{}) error {
|
||||
return repositories.ConversationTagRepository.UpdateColumn(sqls.DB(), id, name, value)
|
||||
}
|
||||
|
||||
func (s *conversationTagService) Delete(id int64) {
|
||||
repositories.ConversationTagRepository.Delete(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *conversationTagService) IsExists(conversationID int64, tagID int64) bool {
|
||||
return repositories.ConversationTagRepository.FindOne(sqls.DB(), sqls.NewCnd().Where("conversation_id = ? AND tag_id = ?", conversationID, tagID)) != nil
|
||||
}
|
||||
|
||||
func (s *conversationTagService) AddTag(req request.AddConversationTagRequest, operator *dto.AuthPrincipal) error {
|
||||
tag := TagService.Get(req.TagID)
|
||||
if tag == nil || tag.Status != enums.StatusOk {
|
||||
return errors.New("标签不存在")
|
||||
}
|
||||
if s.IsExists(req.ConversationID, req.TagID) {
|
||||
return nil
|
||||
}
|
||||
return repositories.ConversationTagRepository.Create(sqls.DB(), &models.ConversationTag{
|
||||
ConversationID: req.ConversationID,
|
||||
TagID: req.TagID,
|
||||
AuditFields: utils.BuildAuditFields(operator),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *conversationTagService) RemoveTag(req request.RemoveConversationTagRequest) error {
|
||||
return sqls.DB().Where("conversation_id = ? AND tag_id = ?", req.ConversationID, req.TagID).Delete(&models.ConversationTag{}).Error
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package cronx
|
||||
|
||||
import (
|
||||
"cs-agent/internal/services"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
c := cron.New()
|
||||
|
||||
addFunc(c, "0 4 ? * *", func() {
|
||||
fmt.Println("cron test")
|
||||
})
|
||||
|
||||
addFunc(c, "@every 30s", func() {
|
||||
if _, err := services.ConversationDispatchService.DispatchPendingConversations(0); err != nil {
|
||||
slog.Warn("dispatch pending conversations loop failed", "error", err)
|
||||
}
|
||||
})
|
||||
|
||||
addFunc(c, "@every 5s", func() {
|
||||
count := services.WxWorkKFOutboundService.DispatchPendingOutbox()
|
||||
if count > 0 {
|
||||
slog.Info("wxwork kf outbox dispatched", "count", count)
|
||||
}
|
||||
})
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
func addFunc(c *cron.Cron, sepc string, cmd func()) {
|
||||
if _, err := c.AddFunc(sepc, cmd); err != nil {
|
||||
slog.Error("add cron func error", slog.Any("err", err))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"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"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var CustomerContactService = newCustomerContactService()
|
||||
|
||||
func newCustomerContactService() *customerContactService {
|
||||
return &customerContactService{}
|
||||
}
|
||||
|
||||
type customerContactService struct {
|
||||
}
|
||||
|
||||
func (s *customerContactService) Get(id int64) *models.CustomerContact {
|
||||
return repositories.CustomerContactRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *customerContactService) Take(where ...interface{}) *models.CustomerContact {
|
||||
return repositories.CustomerContactRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *customerContactService) Find(cnd *sqls.Cnd) []models.CustomerContact {
|
||||
return repositories.CustomerContactRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *customerContactService) FindOne(cnd *sqls.Cnd) *models.CustomerContact {
|
||||
return repositories.CustomerContactRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *customerContactService) FindPageByParams(params *params.QueryParams) (list []models.CustomerContact, paging *sqls.Paging) {
|
||||
return repositories.CustomerContactRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *customerContactService) FindPageByCnd(cnd *sqls.Cnd) (list []models.CustomerContact, paging *sqls.Paging) {
|
||||
return repositories.CustomerContactRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *customerContactService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.CustomerContactRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *customerContactService) Create(t *models.CustomerContact) error {
|
||||
return repositories.CustomerContactRepository.Create(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *customerContactService) Update(t *models.CustomerContact) error {
|
||||
return repositories.CustomerContactRepository.Update(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *customerContactService) Updates(id int64, columns map[string]interface{}) error {
|
||||
return repositories.CustomerContactRepository.Updates(sqls.DB(), id, columns)
|
||||
}
|
||||
|
||||
func (s *customerContactService) UpdateColumn(id int64, name string, value interface{}) error {
|
||||
return repositories.CustomerContactRepository.UpdateColumn(sqls.DB(), id, name, value)
|
||||
}
|
||||
|
||||
func (s *customerContactService) Delete(id int64) {
|
||||
repositories.CustomerContactRepository.Delete(sqls.DB(), id)
|
||||
}
|
||||
|
||||
// FindActiveByCustomerID 返回某客户下未删除的联系方式列表。
|
||||
func (s *customerContactService) FindActiveByCustomerID(customerID int64) []models.CustomerContact {
|
||||
if customerID <= 0 {
|
||||
return nil
|
||||
}
|
||||
cnd := sqls.NewCnd().
|
||||
Where("customer_id = ?", customerID).
|
||||
Where("status <> ?", enums.StatusDeleted).
|
||||
Asc("id")
|
||||
return repositories.CustomerContactRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func normalizeContactSource(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return "manual"
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (s *customerContactService) hasDuplicateContact(
|
||||
db *gorm.DB,
|
||||
customerID int64,
|
||||
contactType enums.ContactType,
|
||||
contactValue string,
|
||||
excludeID int64,
|
||||
) bool {
|
||||
cnd := sqls.NewCnd().
|
||||
Where("customer_id = ?", customerID).
|
||||
Where("contact_type = ?", contactType).
|
||||
Where("contact_value = ?", contactValue).
|
||||
Where("status <> ?", enums.StatusDeleted)
|
||||
if excludeID > 0 {
|
||||
cnd = cnd.Where("id <> ?", excludeID)
|
||||
}
|
||||
return repositories.CustomerContactRepository.FindOne(db, cnd) != nil
|
||||
}
|
||||
|
||||
// findSoftDeletedContactByNaturalKey 按 uk_customer_contact 业务键查找已软删行;复活时用 UPDATE 代替 INSERT,避免唯一索引冲突。
|
||||
func (s *customerContactService) findSoftDeletedContactByNaturalKey(
|
||||
db *gorm.DB,
|
||||
customerID int64,
|
||||
contactType enums.ContactType,
|
||||
contactValue string,
|
||||
) *models.CustomerContact {
|
||||
cnd := sqls.NewCnd().
|
||||
Where("customer_id = ?", customerID).
|
||||
Where("contact_type = ?", contactType).
|
||||
Where("contact_value = ?", contactValue).
|
||||
Where("status = ?", enums.StatusDeleted)
|
||||
return repositories.CustomerContactRepository.FindOne(db, cnd)
|
||||
}
|
||||
|
||||
// syncCustomerPrimaryFromContacts 根据当前主联系方式更新客户表冗余字段(列表检索用)。
|
||||
func (s *customerContactService) syncCustomerPrimaryFromContacts(db *gorm.DB, customerID int64) error {
|
||||
if customerID <= 0 {
|
||||
return nil
|
||||
}
|
||||
if repositories.CustomerRepository.Get(db, customerID) == nil {
|
||||
return nil
|
||||
}
|
||||
cnd := sqls.NewCnd().
|
||||
Where("customer_id = ?", customerID).
|
||||
Where("is_primary = ?", true).
|
||||
Where("status <> ?", enums.StatusDeleted)
|
||||
primary := repositories.CustomerContactRepository.FindOne(db, cnd)
|
||||
pm, pe := "", ""
|
||||
if primary != nil {
|
||||
val := strings.TrimSpace(primary.ContactValue)
|
||||
switch primary.ContactType {
|
||||
case enums.ContactTypeEmail:
|
||||
pe = val
|
||||
default:
|
||||
pm = val
|
||||
}
|
||||
}
|
||||
return repositories.CustomerRepository.Updates(db, customerID, map[string]any{
|
||||
"primary_mobile": pm,
|
||||
"primary_email": pe,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
// ReplaceAllForCustomerInTx 在事务内全量替换客户联系方式(软删未出现在 payload 中的记录),并同步客户主联系方式冗余字段。
|
||||
func (s *customerContactService) ReplaceAllForCustomerInTx(
|
||||
ctx *sqls.TxContext,
|
||||
customerID int64,
|
||||
raw []request.CustomerProfileContactItem,
|
||||
operator *dto.AuthPrincipal,
|
||||
) error {
|
||||
if operator == nil {
|
||||
return errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
type line struct {
|
||||
id *int64
|
||||
ct enums.ContactType
|
||||
val string
|
||||
remark string
|
||||
primary bool
|
||||
}
|
||||
var items []line
|
||||
for _, r := range raw {
|
||||
ct := strings.TrimSpace(r.ContactType)
|
||||
val := strings.TrimSpace(r.ContactValue)
|
||||
if val == "" {
|
||||
continue
|
||||
}
|
||||
if !enums.IsValidContactType(ct) {
|
||||
return errorsx.InvalidParam("联系方式类型不合法")
|
||||
}
|
||||
items = append(items, line{
|
||||
id: r.ID,
|
||||
ct: enums.ContactType(ct),
|
||||
val: val,
|
||||
remark: strings.TrimSpace(r.Remark),
|
||||
primary: r.IsPrimary,
|
||||
})
|
||||
}
|
||||
if len(items) > 0 {
|
||||
primaryCount := 0
|
||||
for i := range items {
|
||||
if items[i].primary {
|
||||
primaryCount++
|
||||
}
|
||||
}
|
||||
if primaryCount == 0 {
|
||||
items[0].primary = true
|
||||
} else if primaryCount > 1 {
|
||||
return errorsx.InvalidParam("仅能指定一条主联系方式")
|
||||
}
|
||||
}
|
||||
|
||||
existing := repositories.CustomerContactRepository.Find(ctx.Tx, sqls.NewCnd().
|
||||
Where("customer_id = ?", customerID).
|
||||
Where("status <> ?", enums.StatusDeleted).
|
||||
Asc("id"))
|
||||
|
||||
wantIDs := map[int64]struct{}{}
|
||||
for i := range items {
|
||||
if items[i].id != nil && *items[i].id > 0 {
|
||||
wantIDs[*items[i].id] = struct{}{}
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
for _, ex := range existing {
|
||||
if _, ok := wantIDs[ex.ID]; !ok {
|
||||
if err := repositories.CustomerContactRepository.Updates(ctx.Tx, ex.ID, map[string]any{
|
||||
"status": enums.StatusDeleted,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": now,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
return !items[i].primary && items[j].primary
|
||||
})
|
||||
|
||||
for _, it := range items {
|
||||
if it.id != nil && *it.id > 0 {
|
||||
row := repositories.CustomerContactRepository.Get(ctx.Tx, *it.id)
|
||||
if row == nil || row.CustomerID != customerID || row.Status == enums.StatusDeleted {
|
||||
return errorsx.InvalidParam("联系方式不存在")
|
||||
}
|
||||
if s.hasDuplicateContact(ctx.Tx, customerID, it.ct, it.val, *it.id) {
|
||||
return errorsx.InvalidParam("该联系方式已存在")
|
||||
}
|
||||
if it.primary {
|
||||
if err := s.clearPrimaryExcept(ctx.Tx, customerID, *it.id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := repositories.CustomerContactRepository.Updates(ctx.Tx, *it.id, map[string]any{
|
||||
"contact_type": it.ct,
|
||||
"contact_value": it.val,
|
||||
"is_primary": it.primary,
|
||||
"remark": it.remark,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": now,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if s.hasDuplicateContact(ctx.Tx, customerID, it.ct, it.val, 0) {
|
||||
return errorsx.InvalidParam("该联系方式已存在")
|
||||
}
|
||||
if deleted := s.findSoftDeletedContactByNaturalKey(ctx.Tx, customerID, it.ct, it.val); deleted != nil {
|
||||
if it.primary {
|
||||
if err := s.clearPrimaryExcept(ctx.Tx, customerID, deleted.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := repositories.CustomerContactRepository.Updates(ctx.Tx, deleted.ID, map[string]any{
|
||||
"status": enums.StatusOk,
|
||||
"contact_type": it.ct,
|
||||
"contact_value": it.val,
|
||||
"is_primary": it.primary,
|
||||
"is_verified": false,
|
||||
"verified_at": nil,
|
||||
"remark": it.remark,
|
||||
"source": normalizeContactSource("manual"),
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": now,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if it.primary {
|
||||
if err := s.clearPrimaryExcept(ctx.Tx, customerID, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
item := &models.CustomerContact{
|
||||
CustomerID: customerID,
|
||||
ContactType: it.ct,
|
||||
ContactValue: it.val,
|
||||
IsPrimary: it.primary,
|
||||
IsVerified: false,
|
||||
Source: normalizeContactSource("manual"),
|
||||
Status: enums.StatusOk,
|
||||
Remark: it.remark,
|
||||
AuditFields: utils.BuildAuditFields(operator),
|
||||
}
|
||||
if err := repositories.CustomerContactRepository.Create(ctx.Tx, item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.syncCustomerPrimaryFromContacts(ctx.Tx, customerID)
|
||||
}
|
||||
|
||||
func (s *customerContactService) clearPrimaryExcept(db *gorm.DB, customerID int64, exceptID int64) error {
|
||||
cnd := sqls.NewCnd().
|
||||
Where("customer_id = ?", customerID).
|
||||
Where("is_primary = ?", true)
|
||||
if exceptID > 0 {
|
||||
cnd = cnd.Where("id <> ?", exceptID)
|
||||
}
|
||||
list := repositories.CustomerContactRepository.Find(db, cnd)
|
||||
for i := range list {
|
||||
if err := repositories.CustomerContactRepository.UpdateColumn(db, list[i].ID, "is_primary", false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *customerContactService) validateContactStatus(status int) error {
|
||||
if !enums.IsValidStatus(status) {
|
||||
return errorsx.InvalidParam("状态值不合法")
|
||||
}
|
||||
if status == int(enums.StatusDeleted) {
|
||||
return errorsx.InvalidParam("状态值不合法")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateCustomerContact 创建联系方式;主联系方式在同一客户下唯一。
|
||||
func (s *customerContactService) CreateCustomerContact(req request.CreateCustomerContactRequest, operator *dto.AuthPrincipal) (*models.CustomerContact, error) {
|
||||
if operator == nil {
|
||||
return nil, errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
if req.CustomerID <= 0 {
|
||||
return nil, errorsx.InvalidParam("客户不存在")
|
||||
}
|
||||
if CustomerService.Get(req.CustomerID) == nil {
|
||||
return nil, errorsx.InvalidParam("客户不存在")
|
||||
}
|
||||
ct := strings.TrimSpace(req.ContactType)
|
||||
if !enums.IsValidContactType(ct) {
|
||||
return nil, errorsx.InvalidParam("联系方式类型不合法")
|
||||
}
|
||||
val := strings.TrimSpace(req.ContactValue)
|
||||
if val == "" {
|
||||
return nil, errorsx.InvalidParam("联系方式不能为空")
|
||||
}
|
||||
if err := s.validateContactStatus(req.Status); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
status := enums.Status(req.Status)
|
||||
if status == 0 {
|
||||
status = enums.StatusOk
|
||||
}
|
||||
|
||||
var created *models.CustomerContact
|
||||
err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
if s.hasDuplicateContact(ctx.Tx, req.CustomerID, enums.ContactType(ct), val, 0) {
|
||||
return errorsx.InvalidParam("该联系方式已存在")
|
||||
}
|
||||
now := time.Now()
|
||||
if deleted := s.findSoftDeletedContactByNaturalKey(ctx.Tx, req.CustomerID, enums.ContactType(ct), val); deleted != nil {
|
||||
if req.IsPrimary {
|
||||
if err := s.clearPrimaryExcept(ctx.Tx, req.CustomerID, deleted.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var verifiedAt *time.Time
|
||||
if req.IsVerified {
|
||||
verifiedAt = &now
|
||||
}
|
||||
if err := repositories.CustomerContactRepository.Updates(ctx.Tx, deleted.ID, map[string]any{
|
||||
"status": status,
|
||||
"contact_type": enums.ContactType(ct),
|
||||
"contact_value": val,
|
||||
"is_primary": req.IsPrimary,
|
||||
"is_verified": req.IsVerified,
|
||||
"verified_at": verifiedAt,
|
||||
"source": normalizeContactSource(req.Source),
|
||||
"remark": strings.TrimSpace(req.Remark),
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": now,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
created = repositories.CustomerContactRepository.Get(ctx.Tx, deleted.ID)
|
||||
return s.syncCustomerPrimaryFromContacts(ctx.Tx, req.CustomerID)
|
||||
}
|
||||
if req.IsPrimary {
|
||||
if err := s.clearPrimaryExcept(ctx.Tx, req.CustomerID, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var verifiedAt *time.Time
|
||||
if req.IsVerified {
|
||||
verifiedAt = &now
|
||||
}
|
||||
item := &models.CustomerContact{
|
||||
CustomerID: req.CustomerID,
|
||||
ContactType: enums.ContactType(ct),
|
||||
ContactValue: val,
|
||||
IsPrimary: req.IsPrimary,
|
||||
IsVerified: req.IsVerified,
|
||||
VerifiedAt: verifiedAt,
|
||||
Source: normalizeContactSource(req.Source),
|
||||
Status: status,
|
||||
Remark: strings.TrimSpace(req.Remark),
|
||||
AuditFields: utils.BuildAuditFields(operator),
|
||||
}
|
||||
if err := repositories.CustomerContactRepository.Create(ctx.Tx, item); err != nil {
|
||||
return err
|
||||
}
|
||||
created = item
|
||||
if err := s.syncCustomerPrimaryFromContacts(ctx.Tx, req.CustomerID); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// UpdateCustomerContact 更新联系方式。
|
||||
func (s *customerContactService) UpdateCustomerContact(req request.UpdateCustomerContactRequest, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
if req.ID <= 0 {
|
||||
return errorsx.InvalidParam("联系方式不存在")
|
||||
}
|
||||
current := s.Get(req.ID)
|
||||
if current == nil {
|
||||
return errorsx.InvalidParam("联系方式不存在")
|
||||
}
|
||||
ct := strings.TrimSpace(req.ContactType)
|
||||
if !enums.IsValidContactType(ct) {
|
||||
return errorsx.InvalidParam("联系方式类型不合法")
|
||||
}
|
||||
val := strings.TrimSpace(req.ContactValue)
|
||||
if val == "" {
|
||||
return errorsx.InvalidParam("联系方式不能为空")
|
||||
}
|
||||
if err := s.validateContactStatus(req.Status); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
if s.hasDuplicateContact(ctx.Tx, current.CustomerID, enums.ContactType(ct), val, req.ID) {
|
||||
return errorsx.InvalidParam("该联系方式已存在")
|
||||
}
|
||||
if req.IsPrimary {
|
||||
if err := s.clearPrimaryExcept(ctx.Tx, current.CustomerID, req.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
verifiedAt := current.VerifiedAt
|
||||
if req.IsVerified {
|
||||
if verifiedAt == nil {
|
||||
verifiedAt = &now
|
||||
}
|
||||
} else {
|
||||
verifiedAt = nil
|
||||
}
|
||||
if err := repositories.CustomerContactRepository.Updates(ctx.Tx, req.ID, map[string]any{
|
||||
"contact_type": enums.ContactType(ct),
|
||||
"contact_value": val,
|
||||
"is_primary": req.IsPrimary,
|
||||
"is_verified": req.IsVerified,
|
||||
"verified_at": verifiedAt,
|
||||
"source": normalizeContactSource(req.Source),
|
||||
"status": req.Status,
|
||||
"remark": strings.TrimSpace(req.Remark),
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": now,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.syncCustomerPrimaryFromContacts(ctx.Tx, current.CustomerID)
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteCustomerContact 软删除联系方式并同步客户主联系方式冗余字段。
|
||||
func (s *customerContactService) DeleteCustomerContact(id int64, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
if id <= 0 {
|
||||
return errorsx.InvalidParam("联系方式不存在")
|
||||
}
|
||||
current := s.Get(id)
|
||||
if current == nil {
|
||||
return errorsx.InvalidParam("联系方式不存在")
|
||||
}
|
||||
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
now := time.Now()
|
||||
if err := repositories.CustomerContactRepository.Updates(ctx.Tx, id, map[string]any{
|
||||
"status": enums.StatusDeleted,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": now,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.syncCustomerPrimaryFromContacts(ctx.Tx, current.CustomerID)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/repositories"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"github.com/mlogclub/simple/web/params"
|
||||
)
|
||||
|
||||
var CustomerIdentityService = newCustomerIdentityService()
|
||||
|
||||
func newCustomerIdentityService() *customerIdentityService {
|
||||
return &customerIdentityService{}
|
||||
}
|
||||
|
||||
type customerIdentityService struct {
|
||||
}
|
||||
|
||||
func (s *customerIdentityService) Get(id int64) *models.CustomerIdentity {
|
||||
return repositories.CustomerIdentityRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *customerIdentityService) Take(where ...interface{}) *models.CustomerIdentity {
|
||||
return repositories.CustomerIdentityRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *customerIdentityService) Find(cnd *sqls.Cnd) []models.CustomerIdentity {
|
||||
return repositories.CustomerIdentityRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *customerIdentityService) FindOne(cnd *sqls.Cnd) *models.CustomerIdentity {
|
||||
return repositories.CustomerIdentityRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *customerIdentityService) FindPageByParams(params *params.QueryParams) (list []models.CustomerIdentity, paging *sqls.Paging) {
|
||||
return repositories.CustomerIdentityRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *customerIdentityService) FindPageByCnd(cnd *sqls.Cnd) (list []models.CustomerIdentity, paging *sqls.Paging) {
|
||||
return repositories.CustomerIdentityRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *customerIdentityService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.CustomerIdentityRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *customerIdentityService) Create(t *models.CustomerIdentity) error {
|
||||
return repositories.CustomerIdentityRepository.Create(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *customerIdentityService) Update(t *models.CustomerIdentity) error {
|
||||
return repositories.CustomerIdentityRepository.Update(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *customerIdentityService) Updates(id int64, columns map[string]interface{}) error {
|
||||
return repositories.CustomerIdentityRepository.Updates(sqls.DB(), id, columns)
|
||||
}
|
||||
|
||||
func (s *customerIdentityService) UpdateColumn(id int64, name string, value interface{}) error {
|
||||
return repositories.CustomerIdentityRepository.UpdateColumn(sqls.DB(), id, name, value)
|
||||
}
|
||||
|
||||
func (s *customerIdentityService) Delete(id int64) {
|
||||
repositories.CustomerIdentityRepository.Delete(sqls.DB(), id)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"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/common/strs"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"github.com/mlogclub/simple/web/params"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var CustomerService = newCustomerService()
|
||||
|
||||
func newCustomerService() *customerService {
|
||||
return &customerService{}
|
||||
}
|
||||
|
||||
type customerService struct {
|
||||
}
|
||||
|
||||
func (s *customerService) Get(id int64) *models.Customer {
|
||||
return repositories.CustomerRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *customerService) Take(where ...interface{}) *models.Customer {
|
||||
return repositories.CustomerRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *customerService) Find(cnd *sqls.Cnd) []models.Customer {
|
||||
return repositories.CustomerRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *customerService) FindOne(cnd *sqls.Cnd) *models.Customer {
|
||||
return repositories.CustomerRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *customerService) FindPageByParams(params *params.QueryParams) (list []models.Customer, paging *sqls.Paging) {
|
||||
return repositories.CustomerRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *customerService) FindPageByCnd(cnd *sqls.Cnd) (list []models.Customer, paging *sqls.Paging) {
|
||||
return repositories.CustomerRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
// ListCustomers 客户分页列表(连联系方式表,支持按非主联系方式检索)。
|
||||
func (s *customerService) ListCustomers(req request.CustomerListRequest) (list []models.Customer, paging *sqls.Paging) {
|
||||
if err := s.newCustomerListQuery(req).Distinct("c.*").Offset(req.Offset()).Order("c.id DESC").Limit(req.GetLimit()).Scan(&list).Error; err != nil {
|
||||
slog.Error("customer list scan failed", slog.Any("error", err))
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := s.newCustomerListQuery(req).Distinct("c.id").Count(&total).Error; err != nil {
|
||||
slog.Error("customer list count failed", slog.Any("error", err))
|
||||
}
|
||||
|
||||
paging = &sqls.Paging{
|
||||
Page: req.GetPage(),
|
||||
Limit: req.GetLimit(),
|
||||
Total: total,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *customerService) newCustomerListQuery(req request.CustomerListRequest) *gorm.DB {
|
||||
deleted := int(enums.StatusDeleted)
|
||||
tx := sqls.DB().
|
||||
Table("t_customer AS c").
|
||||
Joins("LEFT JOIN t_customer_contact AS cc ON cc.customer_id = c.id AND cc.status <> ?", deleted).
|
||||
Joins("LEFT JOIN t_company AS co ON co.id = c.company_id")
|
||||
|
||||
tx.Where("c.status <> ?", enums.StatusDeleted)
|
||||
|
||||
if req.Status != nil {
|
||||
tx.Where("c.status = ?", *req.Status)
|
||||
}
|
||||
if req.Gender != nil {
|
||||
tx.Where("c.gender = ?", *req.Gender)
|
||||
}
|
||||
if req.CompanyID != nil && *req.CompanyID > 0 {
|
||||
tx.Where("c.company_id = ?", *req.CompanyID)
|
||||
}
|
||||
if kw := strings.TrimSpace(req.Keyword); strs.IsNotBlank(kw) {
|
||||
pat := "%" + kw + "%"
|
||||
tx.Where(`(
|
||||
c.name LIKE ? OR
|
||||
c.primary_mobile LIKE ? OR
|
||||
c.primary_email LIKE ? OR
|
||||
cc.contact_value LIKE ? OR
|
||||
co.name LIKE ?
|
||||
)`, pat, pat, pat, pat, pat)
|
||||
}
|
||||
return tx
|
||||
}
|
||||
|
||||
func (s *customerService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.CustomerRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *customerService) CountByCompanyIDs(companyIDs []int64) map[int64]int64 {
|
||||
return repositories.CustomerRepository.CountByCompanyIDs(sqls.DB(), companyIDs, int(enums.StatusDeleted))
|
||||
}
|
||||
|
||||
func (s *customerService) CreateCustomer(req request.CreateCustomerRequest, operator *dto.AuthPrincipal) (*models.Customer, error) {
|
||||
if operator == nil {
|
||||
return nil, errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
return nil, errorsx.InvalidParam("客户名称不能为空")
|
||||
}
|
||||
|
||||
if req.CompanyID > 0 {
|
||||
company := CompanyService.Get(req.CompanyID)
|
||||
if company == nil {
|
||||
return nil, errorsx.InvalidParam("所属公司不存在")
|
||||
}
|
||||
}
|
||||
|
||||
item := &models.Customer{
|
||||
Name: name,
|
||||
Gender: enums.Gender(req.Gender),
|
||||
CompanyID: req.CompanyID,
|
||||
PrimaryMobile: strings.TrimSpace(req.PrimaryMobile),
|
||||
PrimaryEmail: strings.TrimSpace(req.PrimaryEmail),
|
||||
Status: enums.StatusOk,
|
||||
Remark: strings.TrimSpace(req.Remark),
|
||||
AuditFields: utils.BuildAuditFields(operator),
|
||||
}
|
||||
|
||||
if err := repositories.CustomerRepository.Create(sqls.DB(), item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *customerService) UpdateCustomer(req request.UpdateCustomerRequest, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
item := s.Get(req.ID)
|
||||
if item == nil {
|
||||
return errorsx.InvalidParam("客户不存在")
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
return errorsx.InvalidParam("客户名称不能为空")
|
||||
}
|
||||
|
||||
if req.CompanyID > 0 {
|
||||
company := CompanyService.Get(req.CompanyID)
|
||||
if company == nil {
|
||||
return errorsx.InvalidParam("所属公司不存在")
|
||||
}
|
||||
}
|
||||
|
||||
return repositories.CustomerRepository.Updates(sqls.DB(), req.ID, map[string]any{
|
||||
"name": name,
|
||||
"gender": req.Gender,
|
||||
"company_id": req.CompanyID,
|
||||
"primary_mobile": strings.TrimSpace(req.PrimaryMobile),
|
||||
"primary_email": strings.TrimSpace(req.PrimaryEmail),
|
||||
"remark": strings.TrimSpace(req.Remark),
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *customerService) DeleteCustomer(id int64, operator dto.AuthPrincipal) error {
|
||||
item := s.Get(id)
|
||||
if item == nil {
|
||||
return errorsx.InvalidParam("客户不存在")
|
||||
}
|
||||
return repositories.CustomerRepository.Updates(sqls.DB(), id, map[string]any{
|
||||
"status": enums.StatusDeleted,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *customerService) UpdateStatus(id int64, status int, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
item := s.Get(id)
|
||||
if item == nil {
|
||||
return errorsx.InvalidParam("客户不存在")
|
||||
}
|
||||
if status != int(enums.StatusOk) && status != int(enums.StatusDisabled) {
|
||||
return errorsx.InvalidParam("状态值不合法")
|
||||
}
|
||||
return repositories.CustomerRepository.Updates(sqls.DB(), id, map[string]any{
|
||||
"status": status,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
// SaveCustomerProfile 单事务保存客户主信息与联系方式全量(新建或更新)。
|
||||
func (s *customerService) SaveCustomerProfile(req request.SaveCustomerProfileRequest, operator *dto.AuthPrincipal) (*models.Customer, error) {
|
||||
if operator == nil {
|
||||
return nil, errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
return nil, errorsx.InvalidParam("客户名称不能为空")
|
||||
}
|
||||
if req.CompanyID > 0 {
|
||||
if CompanyService.Get(req.CompanyID) == nil {
|
||||
return nil, errorsx.InvalidParam("所属公司不存在")
|
||||
}
|
||||
}
|
||||
createMode := req.ID == nil || *req.ID <= 0
|
||||
|
||||
var out *models.Customer
|
||||
err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
var customerID int64
|
||||
if createMode {
|
||||
c := &models.Customer{
|
||||
Name: name,
|
||||
Gender: enums.Gender(req.Gender),
|
||||
CompanyID: req.CompanyID,
|
||||
PrimaryMobile: "",
|
||||
PrimaryEmail: "",
|
||||
Status: enums.StatusOk,
|
||||
Remark: strings.TrimSpace(req.Remark),
|
||||
AuditFields: utils.BuildAuditFields(operator),
|
||||
}
|
||||
if err := repositories.CustomerRepository.Create(ctx.Tx, c); err != nil {
|
||||
return err
|
||||
}
|
||||
customerID = c.ID
|
||||
out = c
|
||||
} else {
|
||||
customerID = *req.ID
|
||||
cur := repositories.CustomerRepository.Get(ctx.Tx, customerID)
|
||||
if cur == nil {
|
||||
return errorsx.InvalidParam("客户不存在")
|
||||
}
|
||||
now := time.Now()
|
||||
if err := repositories.CustomerRepository.Updates(ctx.Tx, customerID, map[string]any{
|
||||
"name": name,
|
||||
"gender": req.Gender,
|
||||
"company_id": req.CompanyID,
|
||||
"remark": strings.TrimSpace(req.Remark),
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": now,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
out = repositories.CustomerRepository.Get(ctx.Tx, customerID)
|
||||
}
|
||||
return CustomerContactService.ReplaceAllForCustomerInTx(ctx, customerID, req.Contacts, operator)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/dto/response"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"cs-agent/internal/repositories"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var DashboardService = newDashboardService()
|
||||
|
||||
func newDashboardService() *dashboardService {
|
||||
return &dashboardService{}
|
||||
}
|
||||
|
||||
type dashboardService struct {
|
||||
}
|
||||
|
||||
func (s *dashboardService) GetOverview(rangeValue string) response.DashboardOverviewResponse {
|
||||
now := time.Now()
|
||||
normalizedRange, trendDays := normalizeDashboardRange(rangeValue)
|
||||
todayStart := startOfDay(now)
|
||||
trendStart := todayStart.AddDate(0, 0, -(trendDays - 1))
|
||||
db := sqls.DB()
|
||||
|
||||
conversationTodayCount := repositories.DashboardRepository.CountConversations(db, func(tx *gorm.DB) *gorm.DB {
|
||||
return tx.Where("created_at >= ?", todayStart)
|
||||
})
|
||||
processingConversationCount := repositories.DashboardRepository.CountConversations(db, func(tx *gorm.DB) *gorm.DB {
|
||||
return tx.Where("status = ?", enums.IMConversationStatusActive)
|
||||
})
|
||||
pendingConversationCount := repositories.DashboardRepository.CountConversations(db, func(tx *gorm.DB) *gorm.DB {
|
||||
return tx.Where("status = ?", enums.IMConversationStatusPending)
|
||||
})
|
||||
|
||||
agentProfiles := repositories.DashboardRepository.ListEnabledAgentProfiles(db)
|
||||
agentTeams := repositories.DashboardRepository.ListEnabledAgentTeams(db)
|
||||
activeSchedules := repositories.DashboardRepository.ListActiveTeamSchedules(db, now, now)
|
||||
activeConversations := repositories.DashboardRepository.ListConversations(db, func(tx *gorm.DB) *gorm.DB {
|
||||
return tx.Where("status IN ?", []enums.IMConversationStatus{
|
||||
enums.IMConversationStatusPending,
|
||||
enums.IMConversationStatusActive,
|
||||
})
|
||||
})
|
||||
|
||||
onlineAgents, busyAgents, offlineAgents, teamLoads := s.buildAgentStats(now, agentTeams, agentProfiles, activeSchedules, activeConversations)
|
||||
|
||||
enabledAIAgentCount := repositories.DashboardRepository.CountAIAgents(db, func(tx *gorm.DB) *gorm.DB {
|
||||
return tx.Where("status = ?", enums.StatusOk)
|
||||
})
|
||||
enabledChannelCount := repositories.DashboardRepository.CountChannels(db, func(tx *gorm.DB) *gorm.DB {
|
||||
return tx.Where("status = ?", enums.StatusOk)
|
||||
})
|
||||
knowledgeRetrieveCount := repositories.DashboardRepository.CountKnowledgeRetrieveLogs(db, func(tx *gorm.DB) *gorm.DB {
|
||||
return tx.Where("created_at >= ?", todayStart)
|
||||
})
|
||||
knowledgeRetrieveFailCount := repositories.DashboardRepository.CountKnowledgeRetrieveLogs(db, func(tx *gorm.DB) *gorm.DB {
|
||||
return tx.Where("created_at >= ? AND answer_status IN ?", todayStart, []int{2, 3, 4})
|
||||
})
|
||||
skillRunFailCount := repositories.DashboardRepository.CountSkillRunLogs(db, func(tx *gorm.DB) *gorm.DB {
|
||||
return tx.Where("created_at >= ? AND error_message <> ''", todayStart)
|
||||
})
|
||||
aiHandoffCount := repositories.DashboardRepository.CountConversations(db, func(tx *gorm.DB) *gorm.DB {
|
||||
return tx.Where("handoff_at >= ?", todayStart)
|
||||
})
|
||||
|
||||
enabledAIAgents := repositories.DashboardRepository.ListAIAgents(db, func(tx *gorm.DB) *gorm.DB {
|
||||
return tx.Where("status = ?", enums.StatusOk)
|
||||
})
|
||||
alerts := s.buildAlerts(now, db, activeConversations, enabledAIAgents, agentTeams, activeSchedules)
|
||||
|
||||
return response.DashboardOverviewResponse{
|
||||
Range: normalizedRange,
|
||||
GeneratedAt: now.Format("2006-01-02 15:04:05"),
|
||||
Summary: response.DashboardSummaryResponse{
|
||||
TodayNewConversations: conversationTodayCount,
|
||||
ProcessingConversations: processingConversationCount,
|
||||
PendingDispatchConversations: pendingConversationCount,
|
||||
OnlineAgents: onlineAgents,
|
||||
AIServiceRate: calcAIServiceRate(activeConversations),
|
||||
},
|
||||
ConversationStats: response.DashboardSectionStatsResponse{
|
||||
StatusDistribution: buildConversationStatusDistribution(db),
|
||||
Trend: buildConversationTrend(db, trendStart),
|
||||
},
|
||||
AgentStats: response.DashboardAgentStatsResponse{
|
||||
OnlineAgents: onlineAgents,
|
||||
BusyAgents: busyAgents,
|
||||
OfflineAgents: offlineAgents,
|
||||
TeamLoads: teamLoads,
|
||||
},
|
||||
AIStats: response.DashboardAIStatsResponse{
|
||||
EnabledAIAgents: enabledAIAgentCount,
|
||||
EnabledChannels: enabledChannelCount,
|
||||
TodayKnowledgeRetrieves: knowledgeRetrieveCount,
|
||||
TodayKnowledgeRetrieveFailCount: knowledgeRetrieveFailCount,
|
||||
TodayKnowledgeRetrieveFailRate: calcRate(knowledgeRetrieveFailCount, knowledgeRetrieveCount),
|
||||
TodaySkillRunFailCount: skillRunFailCount,
|
||||
TodayAIHandoffCount: aiHandoffCount,
|
||||
},
|
||||
Alerts: alerts,
|
||||
QuickLinks: buildDashboardQuickLinks(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *dashboardService) buildAgentStats(now time.Time, teams []models.AgentTeam, profiles []models.AgentProfile, schedules []models.AgentTeamSchedule, conversations []models.Conversation) (int64, int64, int64, []response.DashboardTeamLoadResponse) {
|
||||
const onlineWindow = 15 * time.Minute
|
||||
|
||||
scheduledTeamIDs := make(map[int64]bool, len(schedules))
|
||||
for _, item := range schedules {
|
||||
scheduledTeamIDs[item.TeamID] = true
|
||||
}
|
||||
|
||||
type teamCounter struct {
|
||||
totalAgents int64
|
||||
onlineAgents int64
|
||||
busyAgents int64
|
||||
offlineAgents int64
|
||||
waitingConversations int64
|
||||
processingConversations int64
|
||||
maxConcurrentCapacity int64
|
||||
}
|
||||
|
||||
teamCounters := make(map[int64]*teamCounter, len(teams))
|
||||
for _, team := range teams {
|
||||
teamCounters[team.ID] = &teamCounter{}
|
||||
}
|
||||
|
||||
var onlineAgents int64
|
||||
var busyAgents int64
|
||||
var offlineAgents int64
|
||||
|
||||
for _, profile := range profiles {
|
||||
counter := teamCounters[profile.TeamID]
|
||||
if counter == nil {
|
||||
counter = &teamCounter{}
|
||||
teamCounters[profile.TeamID] = counter
|
||||
}
|
||||
counter.totalAgents++
|
||||
counter.maxConcurrentCapacity += int64(profile.MaxConcurrentCount)
|
||||
if profile.LastOnlineAt != nil && now.Sub(*profile.LastOnlineAt) <= onlineWindow {
|
||||
counter.onlineAgents++
|
||||
onlineAgents++
|
||||
if profile.ServiceStatus == enums.ServiceStatusBusy {
|
||||
counter.busyAgents++
|
||||
busyAgents++
|
||||
}
|
||||
continue
|
||||
}
|
||||
counter.offlineAgents++
|
||||
offlineAgents++
|
||||
}
|
||||
|
||||
for _, item := range conversations {
|
||||
if item.CurrentTeamID <= 0 {
|
||||
continue
|
||||
}
|
||||
counter := teamCounters[item.CurrentTeamID]
|
||||
if counter == nil {
|
||||
counter = &teamCounter{}
|
||||
teamCounters[item.CurrentTeamID] = counter
|
||||
}
|
||||
switch item.Status {
|
||||
case enums.IMConversationStatusPending:
|
||||
counter.waitingConversations++
|
||||
case enums.IMConversationStatusActive:
|
||||
counter.processingConversations++
|
||||
}
|
||||
}
|
||||
|
||||
teamLoads := make([]response.DashboardTeamLoadResponse, 0, len(teams))
|
||||
for _, team := range teams {
|
||||
counter := teamCounters[team.ID]
|
||||
if counter == nil {
|
||||
counter = &teamCounter{}
|
||||
}
|
||||
teamLoads = append(teamLoads, response.DashboardTeamLoadResponse{
|
||||
TeamID: team.ID,
|
||||
TeamName: team.Name,
|
||||
TotalAgents: counter.totalAgents,
|
||||
OnlineAgents: counter.onlineAgents,
|
||||
BusyAgents: counter.busyAgents,
|
||||
OfflineAgents: counter.offlineAgents,
|
||||
WaitingConversations: counter.waitingConversations,
|
||||
ProcessingConversations: counter.processingConversations,
|
||||
MaxConcurrentCapacity: counter.maxConcurrentCapacity,
|
||||
LoadRate: calcRate(counter.processingConversations, counter.maxConcurrentCapacity),
|
||||
HasScheduleNow: scheduledTeamIDs[team.ID],
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(teamLoads, func(i, j int) bool {
|
||||
if teamLoads[i].WaitingConversations == teamLoads[j].WaitingConversations {
|
||||
if teamLoads[i].LoadRate == teamLoads[j].LoadRate {
|
||||
return teamLoads[i].TeamID < teamLoads[j].TeamID
|
||||
}
|
||||
return teamLoads[i].LoadRate > teamLoads[j].LoadRate
|
||||
}
|
||||
return teamLoads[i].WaitingConversations > teamLoads[j].WaitingConversations
|
||||
})
|
||||
|
||||
return onlineAgents, busyAgents, offlineAgents, teamLoads
|
||||
}
|
||||
|
||||
func (s *dashboardService) buildAlerts(now time.Time, db *gorm.DB, activeConversations []models.Conversation, aiAgents []models.AIAgent, teams []models.AgentTeam, schedules []models.AgentTeamSchedule) []response.DashboardAlertResponse {
|
||||
alerts := make([]response.DashboardAlertResponse, 0, 4)
|
||||
pendingTimeout := now.Add(-10 * time.Minute)
|
||||
activeTimeout := now.Add(-30 * time.Minute)
|
||||
|
||||
pendingLongWaitCount := repositories.DashboardRepository.CountConversations(db, func(tx *gorm.DB) *gorm.DB {
|
||||
return tx.Where("status = ? AND created_at < ?", enums.IMConversationStatusPending, pendingTimeout)
|
||||
})
|
||||
if pendingLongWaitCount > 0 {
|
||||
alerts = append(alerts, response.DashboardAlertResponse{
|
||||
ID: "pending-long-wait",
|
||||
Level: "warning",
|
||||
Title: "待接入会话堆积",
|
||||
Description: "存在超过 10 分钟仍未接入的会话,建议优先处理分配。",
|
||||
Count: pendingLongWaitCount,
|
||||
Link: "/conversations",
|
||||
})
|
||||
}
|
||||
|
||||
staleProcessingCount := repositories.DashboardRepository.CountConversations(db, func(tx *gorm.DB) *gorm.DB {
|
||||
return tx.Where("status = ? AND (last_message_at IS NULL OR last_message_at < ?)", enums.IMConversationStatusActive, activeTimeout)
|
||||
})
|
||||
if staleProcessingCount > 0 {
|
||||
alerts = append(alerts, response.DashboardAlertResponse{
|
||||
ID: "stale-processing",
|
||||
Level: "warning",
|
||||
Title: "处理中会话长时间无响应",
|
||||
Description: "部分处理中会话已超过 30 分钟没有最新消息,需要确认跟进状态。",
|
||||
Count: staleProcessingCount,
|
||||
Link: "/conversations",
|
||||
})
|
||||
}
|
||||
|
||||
scheduledTeamIDs := make(map[int64]bool, len(schedules))
|
||||
for _, item := range schedules {
|
||||
scheduledTeamIDs[item.TeamID] = true
|
||||
}
|
||||
var scheduleMissingCount int64
|
||||
for _, team := range teams {
|
||||
if !scheduledTeamIDs[team.ID] {
|
||||
scheduleMissingCount++
|
||||
}
|
||||
}
|
||||
if scheduleMissingCount > 0 {
|
||||
alerts = append(alerts, response.DashboardAlertResponse{
|
||||
ID: "team-no-schedule",
|
||||
Level: "info",
|
||||
Title: "客服组当前无生效排班",
|
||||
Description: "部分启用中的客服组当前没有生效排班,可能影响自动分配。",
|
||||
Count: scheduleMissingCount,
|
||||
Link: "/dashboard/agent-team-schedules",
|
||||
})
|
||||
}
|
||||
|
||||
var aiAgentWithoutKnowledgeCount int64
|
||||
for _, item := range aiAgents {
|
||||
if strings.TrimSpace(item.KnowledgeIDs) == "" {
|
||||
aiAgentWithoutKnowledgeCount++
|
||||
}
|
||||
}
|
||||
if aiAgentWithoutKnowledgeCount > 0 {
|
||||
alerts = append(alerts, response.DashboardAlertResponse{
|
||||
ID: "ai-no-knowledge",
|
||||
Level: "info",
|
||||
Title: "AI Agent 未绑定知识库",
|
||||
Description: "部分启用中的 AI Agent 尚未绑定知识库,回答质量可能不稳定。",
|
||||
Count: aiAgentWithoutKnowledgeCount,
|
||||
Link: "/dashboard/ai-agents",
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(alerts, func(i, j int) bool {
|
||||
if alerts[i].Count == alerts[j].Count {
|
||||
return alerts[i].ID < alerts[j].ID
|
||||
}
|
||||
return alerts[i].Count > alerts[j].Count
|
||||
})
|
||||
|
||||
return alerts
|
||||
}
|
||||
|
||||
func buildConversationStatusDistribution(db *gorm.DB) []response.DashboardStatusDistributionItem {
|
||||
ret := make([]response.DashboardStatusDistributionItem, 0, len(enums.IMConversationStatusValues))
|
||||
for _, status := range enums.IMConversationStatusValues {
|
||||
ret = append(ret, response.DashboardStatusDistributionItem{
|
||||
Status: int(status),
|
||||
Label: labelOrDefault(enums.GetIMConversationStatusLabel(status), fmt.Sprintf("状态 %d", status)),
|
||||
Count: repositories.DashboardRepository.CountConversations(db, func(tx *gorm.DB) *gorm.DB {
|
||||
return tx.Where("status = ?", status)
|
||||
}),
|
||||
})
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func buildConversationTrend(db *gorm.DB, start time.Time) []response.DashboardTrendItem {
|
||||
created := repositories.DashboardRepository.ListConversations(db, func(tx *gorm.DB) *gorm.DB {
|
||||
return tx.Select("created_at").Where("created_at >= ?", start)
|
||||
})
|
||||
closed := repositories.DashboardRepository.ListConversations(db, func(tx *gorm.DB) *gorm.DB {
|
||||
return tx.Select("closed_at").Where("closed_at IS NOT NULL AND closed_at >= ?", start)
|
||||
})
|
||||
return buildTrendItems(start, created, closed, func(item models.Conversation) *time.Time {
|
||||
return &item.CreatedAt
|
||||
}, func(item models.Conversation) *time.Time {
|
||||
return item.ClosedAt
|
||||
})
|
||||
}
|
||||
|
||||
func buildTrendItems(start time.Time, created []models.Conversation, closed []models.Conversation, createdAt func(models.Conversation) *time.Time, closedAt func(models.Conversation) *time.Time) []response.DashboardTrendItem {
|
||||
series := initTrendMap(start, time.Now())
|
||||
for _, item := range created {
|
||||
if ts := createdAt(item); ts != nil {
|
||||
series[ts.Format("2006-01-02")].NewCount++
|
||||
}
|
||||
}
|
||||
for _, item := range closed {
|
||||
if ts := closedAt(item); ts != nil {
|
||||
series[ts.Format("2006-01-02")].ClosedCount++
|
||||
}
|
||||
}
|
||||
return flattenTrendMap(series)
|
||||
}
|
||||
|
||||
func initTrendMap(start, end time.Time) map[string]*response.DashboardTrendItem {
|
||||
series := make(map[string]*response.DashboardTrendItem)
|
||||
for current := startOfDay(start); !current.After(end); current = current.AddDate(0, 0, 1) {
|
||||
key := current.Format("2006-01-02")
|
||||
series[key] = &response.DashboardTrendItem{Date: key}
|
||||
}
|
||||
return series
|
||||
}
|
||||
|
||||
func flattenTrendMap(series map[string]*response.DashboardTrendItem) []response.DashboardTrendItem {
|
||||
keys := make([]string, 0, len(series))
|
||||
for key := range series {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
ret := make([]response.DashboardTrendItem, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
ret = append(ret, *series[key])
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func buildDashboardQuickLinks() []response.DashboardQuickLinkResponse {
|
||||
return []response.DashboardQuickLinkResponse{
|
||||
{Title: "会话管理", Description: "查看待接入与处理中会话", Link: "/conversations"},
|
||||
{Title: "客服档案", Description: "查看客服状态与分组配置", Link: "/agents"},
|
||||
{Title: "知识库", Description: "维护文档与查看检索日志", Link: "/knowledge"},
|
||||
{Title: "AI Agent", Description: "配置 AI 接待策略与知识绑定", Link: "/ai-agents"},
|
||||
{Title: "接入渠道", Description: "管理接入渠道与默认 Agent", Link: "/channels"},
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeDashboardRange(value string) (string, int) {
|
||||
switch value {
|
||||
case "30d":
|
||||
return "30d", 30
|
||||
case "today":
|
||||
return "today", 1
|
||||
default:
|
||||
return "7d", 7
|
||||
}
|
||||
}
|
||||
|
||||
func startOfDay(value time.Time) time.Time {
|
||||
return time.Date(value.Year(), value.Month(), value.Day(), 0, 0, 0, 0, value.Location())
|
||||
}
|
||||
|
||||
func calcRate(numerator, denominator int64) float64 {
|
||||
if denominator <= 0 {
|
||||
return 0
|
||||
}
|
||||
ratio := float64(numerator) / float64(denominator) * 100
|
||||
return float64(int(ratio*10+0.5)) / 10
|
||||
}
|
||||
|
||||
func calcAIServiceRate(conversations []models.Conversation) float64 {
|
||||
var aiCount int64
|
||||
var total int64
|
||||
for _, item := range conversations {
|
||||
total++
|
||||
if item.ServiceMode == enums.IMConversationServiceModeAIOnly || item.ServiceMode == enums.IMConversationServiceModeAIFirst {
|
||||
aiCount++
|
||||
}
|
||||
}
|
||||
return calcRate(aiCount, total)
|
||||
}
|
||||
|
||||
func labelOrDefault(value, fallback string) string {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"cs-agent/internal/pkg/errorsx"
|
||||
"cs-agent/internal/services/storage"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type imMessageAssetPayload struct {
|
||||
AssetID string `json:"assetId"`
|
||||
Filename string `json:"filename,omitempty"`
|
||||
FileSize int64 `json:"fileSize,omitempty"`
|
||||
MimeType string `json:"mimeType,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
func parseIMMessageAssetPayload(payload string) (*imMessageAssetPayload, error) {
|
||||
payload = strings.TrimSpace(payload)
|
||||
if payload == "" {
|
||||
return nil, errorsx.InvalidParam("附件消息缺少 payload")
|
||||
}
|
||||
ret := &imMessageAssetPayload{}
|
||||
if err := json.Unmarshal([]byte(payload), ret); err != nil {
|
||||
return nil, errorsx.InvalidParam("附件消息 payload 格式错误")
|
||||
}
|
||||
ret.AssetID = strings.TrimSpace(ret.AssetID)
|
||||
if ret.AssetID == "" {
|
||||
return nil, errorsx.InvalidParam("附件消息缺少 assetId")
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func buildIMMessageAssetPayload(asset *models.Asset) (string, error) {
|
||||
if asset == nil {
|
||||
return "", errorsx.InvalidParam("附件不存在")
|
||||
}
|
||||
provider, err := storage.NewProvider(asset.Provider)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
payload, err := json.Marshal(imMessageAssetPayload{
|
||||
AssetID: asset.AssetID,
|
||||
Filename: asset.Filename,
|
||||
FileSize: asset.FileSize,
|
||||
MimeType: asset.MimeType,
|
||||
URL: provider.GetURL(asset.StorageKey),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(payload), nil
|
||||
}
|
||||
|
||||
func validateConversationAsset(asset *models.Asset, conversationID int64, messageType enums.IMMessageType) error {
|
||||
if asset == nil {
|
||||
return errorsx.InvalidParam("附件不存在")
|
||||
}
|
||||
if asset.Status != enums.AssetStatusSuccess {
|
||||
return errorsx.InvalidParam("附件尚未上传完成")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"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 KnowledgeBaseService = newKnowledgeBaseService()
|
||||
|
||||
func newKnowledgeBaseService() *knowledgeBaseService {
|
||||
return &knowledgeBaseService{}
|
||||
}
|
||||
|
||||
type knowledgeBaseService struct {
|
||||
}
|
||||
|
||||
func (s *knowledgeBaseService) Get(id int64) *models.KnowledgeBase {
|
||||
return repositories.KnowledgeBaseRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *knowledgeBaseService) Take(where ...interface{}) *models.KnowledgeBase {
|
||||
return repositories.KnowledgeBaseRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *knowledgeBaseService) Find(cnd *sqls.Cnd) []models.KnowledgeBase {
|
||||
return repositories.KnowledgeBaseRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *knowledgeBaseService) FindOne(cnd *sqls.Cnd) *models.KnowledgeBase {
|
||||
return repositories.KnowledgeBaseRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *knowledgeBaseService) FindPageByParams(params *params.QueryParams) (list []models.KnowledgeBase, paging *sqls.Paging) {
|
||||
return repositories.KnowledgeBaseRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *knowledgeBaseService) FindPageByCnd(cnd *sqls.Cnd) (list []models.KnowledgeBase, paging *sqls.Paging) {
|
||||
return repositories.KnowledgeBaseRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *knowledgeBaseService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.KnowledgeBaseRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *knowledgeBaseService) Create(t *models.KnowledgeBase) error {
|
||||
return repositories.KnowledgeBaseRepository.Create(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *knowledgeBaseService) Update(t *models.KnowledgeBase) error {
|
||||
return repositories.KnowledgeBaseRepository.Update(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *knowledgeBaseService) Updates(id int64, columns map[string]interface{}) error {
|
||||
return repositories.KnowledgeBaseRepository.Updates(sqls.DB(), id, columns)
|
||||
}
|
||||
|
||||
func (s *knowledgeBaseService) UpdateColumn(id int64, name string, value interface{}) error {
|
||||
return repositories.KnowledgeBaseRepository.UpdateColumn(sqls.DB(), id, name, value)
|
||||
}
|
||||
|
||||
func (s *knowledgeBaseService) Delete(id int64) {
|
||||
repositories.KnowledgeBaseRepository.Delete(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *knowledgeBaseService) CreateKnowledgeBase(req request.CreateKnowledgeBaseRequest, operator *dto.AuthPrincipal) (*models.KnowledgeBase, error) {
|
||||
if operator == nil {
|
||||
return nil, errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
item, err := s.buildKnowledgeBaseModel(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Status = enums.StatusOk
|
||||
item.AuditFields = utils.BuildAuditFields(operator)
|
||||
if err := repositories.KnowledgeBaseRepository.Create(sqls.DB(), item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *knowledgeBaseService) UpdateKnowledgeBase(req request.UpdateKnowledgeBaseRequest, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
current := s.Get(req.ID)
|
||||
if current == nil {
|
||||
return errorsx.InvalidParam("知识库不存在")
|
||||
}
|
||||
item, err := s.buildKnowledgeBaseModel(req.CreateKnowledgeBaseRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return repositories.KnowledgeBaseRepository.Updates(sqls.DB(), req.ID, map[string]any{
|
||||
"name": item.Name,
|
||||
"description": item.Description,
|
||||
"knowledge_type": item.KnowledgeType,
|
||||
"default_top_k": item.DefaultTopK,
|
||||
"default_score_threshold": item.DefaultScoreThreshold,
|
||||
"default_rerank_limit": item.DefaultRerankLimit,
|
||||
"chunk_provider": item.ChunkProvider,
|
||||
"chunk_target_tokens": item.ChunkTargetTokens,
|
||||
"chunk_max_tokens": item.ChunkMaxTokens,
|
||||
"chunk_overlap_tokens": item.ChunkOverlapTokens,
|
||||
"answer_mode": item.AnswerMode,
|
||||
"fallback_mode": item.FallbackMode,
|
||||
"remark": item.Remark,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *knowledgeBaseService) DeleteKnowledgeBase(id int64) error {
|
||||
current := s.Get(id)
|
||||
if current == nil {
|
||||
return errorsx.InvalidParam("知识库不存在")
|
||||
}
|
||||
docCount := repositories.KnowledgeDocumentRepository.CountByKnowledgeBaseID(sqls.DB(), id)
|
||||
if docCount > 0 {
|
||||
return errorsx.InvalidParam("知识库下存在文档,无法删除")
|
||||
}
|
||||
faqCount := repositories.KnowledgeFAQRepository.CountByKnowledgeBaseID(sqls.DB(), id)
|
||||
if faqCount > 0 {
|
||||
return errorsx.InvalidParam("知识库下存在FAQ,无法删除")
|
||||
}
|
||||
repositories.KnowledgeBaseRepository.Delete(sqls.DB(), id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *knowledgeBaseService) UpdateSort(ids []int64) error {
|
||||
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
for i, id := range ids {
|
||||
if err := repositories.KnowledgeBaseRepository.UpdateColumn(ctx.Tx, id, "sort_no", i); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *knowledgeBaseService) buildKnowledgeBaseModel(req request.CreateKnowledgeBaseRequest) (*models.KnowledgeBase, error) {
|
||||
item := &models.KnowledgeBase{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
KnowledgeType: req.KnowledgeType,
|
||||
DefaultTopK: req.DefaultTopK,
|
||||
DefaultScoreThreshold: req.DefaultScoreThreshold,
|
||||
DefaultRerankLimit: req.DefaultRerankLimit,
|
||||
ChunkProvider: req.ChunkProvider,
|
||||
ChunkTargetTokens: req.ChunkTargetTokens,
|
||||
ChunkMaxTokens: req.ChunkMaxTokens,
|
||||
ChunkOverlapTokens: req.ChunkOverlapTokens,
|
||||
AnswerMode: req.AnswerMode,
|
||||
FallbackMode: req.FallbackMode,
|
||||
Remark: req.Remark,
|
||||
}
|
||||
if item.DefaultTopK == 0 {
|
||||
item.DefaultTopK = 10
|
||||
}
|
||||
if item.KnowledgeType == "" {
|
||||
item.KnowledgeType = string(enums.KnowledgeBaseTypeDocument)
|
||||
}
|
||||
if !isValidKnowledgeType(item.KnowledgeType) {
|
||||
return nil, errorsx.InvalidParam("知识库类型不支持")
|
||||
}
|
||||
if item.DefaultScoreThreshold == 0 {
|
||||
item.DefaultScoreThreshold = 0.2
|
||||
}
|
||||
if item.DefaultRerankLimit == 0 {
|
||||
item.DefaultRerankLimit = 5
|
||||
}
|
||||
if item.ChunkProvider == "" {
|
||||
item.ChunkProvider = string(enums.KnowledgeChunkProviderStructured)
|
||||
}
|
||||
if item.KnowledgeType == string(enums.KnowledgeBaseTypeFAQ) {
|
||||
item.ChunkProvider = string(enums.KnowledgeChunkProviderFAQ)
|
||||
item.ChunkTargetTokens = 0
|
||||
item.ChunkMaxTokens = 0
|
||||
item.ChunkOverlapTokens = 0
|
||||
} else if item.ChunkProvider == string(enums.KnowledgeChunkProviderFAQ) {
|
||||
return nil, errorsx.InvalidParam("文档知识库不能使用FAQ分块策略")
|
||||
}
|
||||
if !isValidChunkProvider(item.ChunkProvider) {
|
||||
return nil, errorsx.InvalidParam("分块策略不支持")
|
||||
}
|
||||
if item.KnowledgeType != string(enums.KnowledgeBaseTypeFAQ) && item.ChunkTargetTokens == 0 {
|
||||
item.ChunkTargetTokens = 300
|
||||
}
|
||||
if item.KnowledgeType != string(enums.KnowledgeBaseTypeFAQ) && item.ChunkMaxTokens == 0 {
|
||||
item.ChunkMaxTokens = 400
|
||||
}
|
||||
if item.KnowledgeType != string(enums.KnowledgeBaseTypeFAQ) && item.ChunkMaxTokens < item.ChunkTargetTokens {
|
||||
item.ChunkMaxTokens = item.ChunkTargetTokens
|
||||
}
|
||||
if item.KnowledgeType != string(enums.KnowledgeBaseTypeFAQ) && item.ChunkOverlapTokens == 0 {
|
||||
item.ChunkOverlapTokens = 40
|
||||
}
|
||||
if item.AnswerMode == 0 {
|
||||
item.AnswerMode = 1
|
||||
}
|
||||
if item.FallbackMode == 0 {
|
||||
item.FallbackMode = 1
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func isValidChunkProvider(provider string) bool {
|
||||
switch provider {
|
||||
case string(enums.KnowledgeChunkProviderFixed),
|
||||
string(enums.KnowledgeChunkProviderStructured),
|
||||
string(enums.KnowledgeChunkProviderFAQ),
|
||||
string(enums.KnowledgeChunkProviderSemantic):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isValidKnowledgeType(knowledgeType string) bool {
|
||||
switch knowledgeType {
|
||||
case string(enums.KnowledgeBaseTypeDocument), string(enums.KnowledgeBaseTypeFAQ):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"cs-agent/internal/pkg/dto/request"
|
||||
)
|
||||
|
||||
func TestBuildKnowledgeBaseModelUsesLowerDefaultScoreThreshold(t *testing.T) {
|
||||
item, err := KnowledgeBaseService.buildKnowledgeBaseModel(request.CreateKnowledgeBaseRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("build knowledge base model failed: %v", err)
|
||||
}
|
||||
if item.DefaultScoreThreshold != 0.2 {
|
||||
t.Fatalf("expected default score threshold 0.2, got %v", item.DefaultScoreThreshold)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"cs-agent/internal/ai/rag"
|
||||
"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/common/strs"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"github.com/mlogclub/simple/web/params"
|
||||
)
|
||||
|
||||
var KnowledgeDocumentService = newKnowledgeDocumentService()
|
||||
|
||||
func newKnowledgeDocumentService() *knowledgeDocumentService {
|
||||
return &knowledgeDocumentService{}
|
||||
}
|
||||
|
||||
type knowledgeDocumentService struct {
|
||||
}
|
||||
|
||||
func (s *knowledgeDocumentService) Get(id int64) *models.KnowledgeDocument {
|
||||
return repositories.KnowledgeDocumentRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *knowledgeDocumentService) Take(where ...interface{}) *models.KnowledgeDocument {
|
||||
return repositories.KnowledgeDocumentRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *knowledgeDocumentService) Find(cnd *sqls.Cnd) []models.KnowledgeDocument {
|
||||
return repositories.KnowledgeDocumentRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *knowledgeDocumentService) FindOne(cnd *sqls.Cnd) *models.KnowledgeDocument {
|
||||
return repositories.KnowledgeDocumentRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *knowledgeDocumentService) FindPageByParams(params *params.QueryParams) (list []models.KnowledgeDocument, paging *sqls.Paging) {
|
||||
return repositories.KnowledgeDocumentRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *knowledgeDocumentService) FindPageByCnd(cnd *sqls.Cnd) (list []models.KnowledgeDocument, paging *sqls.Paging) {
|
||||
return repositories.KnowledgeDocumentRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *knowledgeDocumentService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.KnowledgeDocumentRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *knowledgeDocumentService) Create(t *models.KnowledgeDocument) error {
|
||||
return repositories.KnowledgeDocumentRepository.Create(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *knowledgeDocumentService) Update(t *models.KnowledgeDocument) error {
|
||||
return repositories.KnowledgeDocumentRepository.Update(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *knowledgeDocumentService) Updates(id int64, columns map[string]interface{}) error {
|
||||
return repositories.KnowledgeDocumentRepository.Updates(sqls.DB(), id, columns)
|
||||
}
|
||||
|
||||
func (s *knowledgeDocumentService) UpdateColumn(id int64, name string, value interface{}) error {
|
||||
return repositories.KnowledgeDocumentRepository.UpdateColumn(sqls.DB(), id, name, value)
|
||||
}
|
||||
|
||||
func (s *knowledgeDocumentService) Delete(id int64) {
|
||||
repositories.KnowledgeDocumentRepository.Delete(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *knowledgeDocumentService) CreateKnowledgeDocument(req request.CreateKnowledgeDocumentRequest, operator *dto.AuthPrincipal) (*models.KnowledgeDocument, error) {
|
||||
if operator == nil {
|
||||
return nil, errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
kb := KnowledgeBaseService.Get(req.KnowledgeBaseID)
|
||||
if kb == nil {
|
||||
return nil, errorsx.InvalidParam("知识库不存在")
|
||||
}
|
||||
if kb.KnowledgeType == string(enums.KnowledgeBaseTypeFAQ) {
|
||||
return nil, errorsx.InvalidParam("FAQ知识库不支持文档")
|
||||
}
|
||||
item, err := s.buildKnowledgeDocumentModel(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Status = enums.StatusOk
|
||||
item.IndexStatus = enums.KnowledgeDocumentIndexStatusPending
|
||||
item.IndexError = ""
|
||||
item.IndexedAt = nil
|
||||
item.AuditFields = utils.BuildAuditFields(operator)
|
||||
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
if err := ctx.Tx.Create(item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rag.Index.IndexDocumentByID(context.Background(), item.ID); err != nil {
|
||||
slog.Error("failed to index created knowledge document", "document_id", item.ID, "error", err)
|
||||
}
|
||||
item = s.Get(item.ID)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *knowledgeDocumentService) UpdateKnowledgeDocument(req request.UpdateKnowledgeDocumentRequest, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
current := s.Get(req.ID)
|
||||
if current == nil {
|
||||
return errorsx.InvalidParam("文档不存在")
|
||||
}
|
||||
kb := KnowledgeBaseService.Get(req.KnowledgeBaseID)
|
||||
if kb == nil {
|
||||
return errorsx.InvalidParam("知识库不存在")
|
||||
}
|
||||
if kb.KnowledgeType == string(enums.KnowledgeBaseTypeFAQ) {
|
||||
return errorsx.InvalidParam("FAQ知识库不支持文档")
|
||||
}
|
||||
item, err := s.buildKnowledgeDocumentModel(req.CreateKnowledgeDocumentRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
oldKnowledgeBaseID := current.KnowledgeBaseID
|
||||
if err := repositories.KnowledgeDocumentRepository.Updates(sqls.DB(), req.ID, map[string]any{
|
||||
"knowledge_base_id": item.KnowledgeBaseID,
|
||||
"title": item.Title,
|
||||
"content_type": item.ContentType,
|
||||
"content_hash": item.ContentHash,
|
||||
"content": item.Content,
|
||||
"index_status": enums.KnowledgeDocumentIndexStatusPending,
|
||||
"indexed_at": nil,
|
||||
"index_error": "",
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if oldKnowledgeBaseID != item.KnowledgeBaseID {
|
||||
if err := rag.Index.RemoveDocumentIndexFromKnowledgeBase(context.Background(), oldKnowledgeBaseID, req.ID); err != nil {
|
||||
slog.Error("failed to remove old document index after knowledge base change", "document_id", req.ID, "knowledge_base_id", oldKnowledgeBaseID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := rag.Index.IndexDocumentByID(context.Background(), req.ID); err != nil {
|
||||
slog.Error("failed to reindex updated knowledge document", "document_id", req.ID, "error", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *knowledgeDocumentService) DeleteKnowledgeDocument(id int64) error {
|
||||
current := s.Get(id)
|
||||
if current == nil {
|
||||
return errorsx.InvalidParam("文档不存在")
|
||||
}
|
||||
chunks := repositories.KnowledgeChunkRepository.FindByDocumentID(sqls.DB(), id)
|
||||
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
_ = repositories.KnowledgeDocumentRepository.Updates(ctx.Tx, id, map[string]any{
|
||||
"status": enums.StatusDeleted,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
ctx.Tx.Delete(&models.KnowledgeChunk{}, "document_id = ?", id)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return rag.Index.RemoveDocumentIndexByChunkModels(context.Background(), current.KnowledgeBaseID, id, chunks)
|
||||
}
|
||||
|
||||
func (s *knowledgeDocumentService) buildKnowledgeDocumentModel(req request.CreateKnowledgeDocumentRequest) (*models.KnowledgeDocument, error) {
|
||||
if strs.IsBlank(string(req.ContentType)) {
|
||||
req.ContentType = enums.KnowledgeDocumentContentTypeHTML
|
||||
}
|
||||
if req.ContentType != enums.KnowledgeDocumentContentTypeHTML && req.ContentType != enums.KnowledgeDocumentContentTypeMarkdown {
|
||||
return nil, errorsx.InvalidParam("内容类型不支持")
|
||||
}
|
||||
|
||||
plainText := rag.ExtractPlainText(req.Content, req.ContentType)
|
||||
item := &models.KnowledgeDocument{
|
||||
KnowledgeBaseID: req.KnowledgeBaseID,
|
||||
Title: req.Title,
|
||||
ContentType: req.ContentType,
|
||||
Content: req.Content,
|
||||
}
|
||||
if plainText != "" {
|
||||
hash := sha256.Sum256([]byte(plainText))
|
||||
item.ContentHash = hex.EncodeToString(hash[:])
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"cs-agent/internal/ai/rag"
|
||||
"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 KnowledgeFAQService = newKnowledgeFAQService()
|
||||
|
||||
func newKnowledgeFAQService() *knowledgeFAQService {
|
||||
return &knowledgeFAQService{}
|
||||
}
|
||||
|
||||
type knowledgeFAQService struct{}
|
||||
|
||||
func (s *knowledgeFAQService) Get(id int64) *models.KnowledgeFAQ {
|
||||
return repositories.KnowledgeFAQRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *knowledgeFAQService) FindPageByCnd(cnd *sqls.Cnd) (list []models.KnowledgeFAQ, paging *sqls.Paging) {
|
||||
return repositories.KnowledgeFAQRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *knowledgeFAQService) FindPageByParams(queryParams *params.QueryParams) (list []models.KnowledgeFAQ, paging *sqls.Paging) {
|
||||
return repositories.KnowledgeFAQRepository.FindPageByParams(sqls.DB(), queryParams)
|
||||
}
|
||||
|
||||
func (s *knowledgeFAQService) CreateKnowledgeFAQ(req request.CreateKnowledgeFAQRequest, operator *dto.AuthPrincipal) (*models.KnowledgeFAQ, error) {
|
||||
if operator == nil {
|
||||
return nil, errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
kb, err := s.requireFAQKnowledgeBase(req.KnowledgeBaseID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item, err := s.buildKnowledgeFAQModel(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Status = kb.Status
|
||||
item.IndexStatus = enums.KnowledgeDocumentIndexStatusPending
|
||||
item.IndexError = ""
|
||||
item.IndexedAt = nil
|
||||
item.AuditFields = utils.BuildAuditFields(operator)
|
||||
if err := repositories.KnowledgeFAQRepository.Create(sqls.DB(), item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rag.Index.IndexFAQByID(context.Background(), item.ID); err != nil {
|
||||
slog.Error("failed to index created knowledge faq", "faq_id", item.ID, "error", err)
|
||||
return item, nil
|
||||
}
|
||||
return s.Get(item.ID), nil
|
||||
}
|
||||
|
||||
func (s *knowledgeFAQService) UpdateKnowledgeFAQ(req request.UpdateKnowledgeFAQRequest, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
current := s.Get(req.ID)
|
||||
if current == nil {
|
||||
return errorsx.InvalidParam("FAQ不存在")
|
||||
}
|
||||
if _, err := s.requireFAQKnowledgeBase(req.KnowledgeBaseID); err != nil {
|
||||
return err
|
||||
}
|
||||
item, err := s.buildKnowledgeFAQModel(req.CreateKnowledgeFAQRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := repositories.KnowledgeFAQRepository.Updates(sqls.DB(), req.ID, map[string]any{
|
||||
"knowledge_base_id": item.KnowledgeBaseID,
|
||||
"question": item.Question,
|
||||
"answer": item.Answer,
|
||||
"similar_questions": item.SimilarQuestions,
|
||||
"index_status": enums.KnowledgeDocumentIndexStatusPending,
|
||||
"indexed_at": nil,
|
||||
"index_error": "",
|
||||
"remark": item.Remark,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return rag.Index.IndexFAQByID(context.Background(), req.ID)
|
||||
}
|
||||
|
||||
func (s *knowledgeFAQService) DeleteKnowledgeFAQ(id int64) error {
|
||||
current := s.Get(id)
|
||||
if current == nil {
|
||||
return errorsx.InvalidParam("FAQ不存在")
|
||||
}
|
||||
chunks := repositories.KnowledgeChunkRepository.FindByFaqID(sqls.DB(), id)
|
||||
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
ctx.Tx.Delete(&models.KnowledgeFAQ{}, "id = ?", id)
|
||||
ctx.Tx.Delete(&models.KnowledgeChunk{}, "faq_id = ?", id)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return rag.Index.RemoveFAQIndexByChunkModels(context.Background(), current.KnowledgeBaseID, id, chunks)
|
||||
}
|
||||
|
||||
func (s *knowledgeFAQService) buildKnowledgeFAQModel(req request.CreateKnowledgeFAQRequest) (*models.KnowledgeFAQ, error) {
|
||||
if req.KnowledgeBaseID <= 0 {
|
||||
return nil, errorsx.InvalidParam("知识库不存在")
|
||||
}
|
||||
if req.Question == "" {
|
||||
return nil, errorsx.InvalidParam("问题不能为空")
|
||||
}
|
||||
if req.Answer == "" {
|
||||
return nil, errorsx.InvalidParam("答案不能为空")
|
||||
}
|
||||
similarQuestions, err := json.Marshal(normalizeSimilarQuestions(req.SimilarQuestions))
|
||||
if err != nil {
|
||||
return nil, errorsx.InvalidParam("相似问格式不合法")
|
||||
}
|
||||
return &models.KnowledgeFAQ{
|
||||
KnowledgeBaseID: req.KnowledgeBaseID,
|
||||
Question: req.Question,
|
||||
Answer: req.Answer,
|
||||
SimilarQuestions: string(similarQuestions),
|
||||
Remark: req.Remark,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *knowledgeFAQService) requireFAQKnowledgeBase(knowledgeBaseID int64) (*models.KnowledgeBase, error) {
|
||||
kb := KnowledgeBaseService.Get(knowledgeBaseID)
|
||||
if kb == nil {
|
||||
return nil, errorsx.InvalidParam("知识库不存在")
|
||||
}
|
||||
if kb.KnowledgeType != "faq" {
|
||||
return nil, errorsx.InvalidParam("当前知识库不是FAQ知识库")
|
||||
}
|
||||
return kb, nil
|
||||
}
|
||||
|
||||
func normalizeSimilarQuestions(values []string) []string {
|
||||
items := make([]string, 0, len(values))
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, item := range values {
|
||||
value := item
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
items = append(items, value)
|
||||
}
|
||||
return items
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"cs-agent/internal/models"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"github.com/mlogclub/simple/web/params"
|
||||
)
|
||||
|
||||
var KnowledgeRetrieveLogService = newKnowledgeRetrieveLogService()
|
||||
|
||||
func newKnowledgeRetrieveLogService() *knowledgeRetrieveLogService {
|
||||
return &knowledgeRetrieveLogService{}
|
||||
}
|
||||
|
||||
type knowledgeRetrieveLogService struct {
|
||||
}
|
||||
|
||||
func (s *knowledgeRetrieveLogService) Get(id int64) *models.KnowledgeRetrieveLog {
|
||||
ret := &models.KnowledgeRetrieveLog{}
|
||||
if err := sqls.DB().First(ret, "id = ?", id).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func (s *knowledgeRetrieveLogService) FindPageByParams(params *params.QueryParams) (list []models.KnowledgeRetrieveLog, paging *sqls.Paging) {
|
||||
cnd := ¶ms.Cnd
|
||||
cnd.Find(sqls.DB(), &list)
|
||||
count := cnd.Count(sqls.DB(), &models.KnowledgeRetrieveLog{})
|
||||
paging = &sqls.Paging{
|
||||
Page: cnd.Paging.Page,
|
||||
Limit: cnd.Paging.Limit,
|
||||
Total: count,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *knowledgeRetrieveLogService) FindHitsByRetrieveLogID(retrieveLogID int64) []models.KnowledgeRetrieveHit {
|
||||
if retrieveLogID <= 0 {
|
||||
return nil
|
||||
}
|
||||
var list []models.KnowledgeRetrieveHit
|
||||
sqls.DB().Where("retrieve_log_id = ?", retrieveLogID).Order("rank_no asc, id asc").Find(&list)
|
||||
return list
|
||||
}
|
||||
@@ -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 LoginCredentialLogService = newLoginCredentialLogService()
|
||||
|
||||
func newLoginCredentialLogService() *loginCredentialLogService {
|
||||
return &loginCredentialLogService{}
|
||||
}
|
||||
|
||||
type loginCredentialLogService struct {
|
||||
}
|
||||
|
||||
func (s *loginCredentialLogService) Get(id int64) *models.LoginCredentialLog {
|
||||
return repositories.LoginCredentialLogRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *loginCredentialLogService) Take(where ...interface{}) *models.LoginCredentialLog {
|
||||
return repositories.LoginCredentialLogRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *loginCredentialLogService) Find(cnd *sqls.Cnd) []models.LoginCredentialLog {
|
||||
return repositories.LoginCredentialLogRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *loginCredentialLogService) FindOne(cnd *sqls.Cnd) *models.LoginCredentialLog {
|
||||
return repositories.LoginCredentialLogRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *loginCredentialLogService) FindPageByParams(params *params.QueryParams) (list []models.LoginCredentialLog, paging *sqls.Paging) {
|
||||
return repositories.LoginCredentialLogRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *loginCredentialLogService) FindPageByCnd(cnd *sqls.Cnd) (list []models.LoginCredentialLog, paging *sqls.Paging) {
|
||||
return repositories.LoginCredentialLogRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *loginCredentialLogService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.LoginCredentialLogRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *loginCredentialLogService) Create(t *models.LoginCredentialLog) error {
|
||||
return repositories.LoginCredentialLogRepository.Create(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *loginCredentialLogService) Update(t *models.LoginCredentialLog) error {
|
||||
return repositories.LoginCredentialLogRepository.Update(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *loginCredentialLogService) Updates(id int64, columns map[string]interface{}) error {
|
||||
return repositories.LoginCredentialLogRepository.Updates(sqls.DB(), id, columns)
|
||||
}
|
||||
|
||||
func (s *loginCredentialLogService) UpdateColumn(id int64, name string, value interface{}) error {
|
||||
return repositories.LoginCredentialLogRepository.UpdateColumn(sqls.DB(), id, name, value)
|
||||
}
|
||||
|
||||
func (s *loginCredentialLogService) Delete(id int64) {
|
||||
repositories.LoginCredentialLogRepository.Delete(sqls.DB(), id)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/errorsx"
|
||||
"cs-agent/internal/repositories"
|
||||
"time"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"github.com/mlogclub/simple/web/params"
|
||||
)
|
||||
|
||||
var LoginSessionService = newLoginSessionService()
|
||||
|
||||
func newLoginSessionService() *loginSessionService {
|
||||
return &loginSessionService{}
|
||||
}
|
||||
|
||||
type loginSessionService struct {
|
||||
}
|
||||
|
||||
func (s *loginSessionService) Get(id int64) *models.LoginSession {
|
||||
return repositories.LoginSessionRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *loginSessionService) Take(where ...interface{}) *models.LoginSession {
|
||||
return repositories.LoginSessionRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *loginSessionService) Find(cnd *sqls.Cnd) []models.LoginSession {
|
||||
return repositories.LoginSessionRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *loginSessionService) FindOne(cnd *sqls.Cnd) *models.LoginSession {
|
||||
return repositories.LoginSessionRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *loginSessionService) FindPageByParams(params *params.QueryParams) (list []models.LoginSession, paging *sqls.Paging) {
|
||||
return repositories.LoginSessionRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *loginSessionService) FindPageByCnd(cnd *sqls.Cnd) (list []models.LoginSession, paging *sqls.Paging) {
|
||||
return repositories.LoginSessionRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *loginSessionService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.LoginSessionRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *loginSessionService) Create(t *models.LoginSession) error {
|
||||
return repositories.LoginSessionRepository.Create(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *loginSessionService) Update(t *models.LoginSession) error {
|
||||
return repositories.LoginSessionRepository.Update(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *loginSessionService) Updates(id int64, columns map[string]interface{}) error {
|
||||
return repositories.LoginSessionRepository.Updates(sqls.DB(), id, columns)
|
||||
}
|
||||
|
||||
func (s *loginSessionService) UpdateColumn(id int64, name string, value interface{}) error {
|
||||
return repositories.LoginSessionRepository.UpdateColumn(sqls.DB(), id, name, value)
|
||||
}
|
||||
|
||||
func (s *loginSessionService) Delete(id int64) {
|
||||
repositories.LoginSessionRepository.Delete(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *loginSessionService) Revoke(id int64, operatorID int64, operatorName string) error {
|
||||
session := s.Get(id)
|
||||
if session == nil {
|
||||
return errorsx.InvalidParam("会话不存在")
|
||||
}
|
||||
now := time.Now()
|
||||
return s.Updates(id, map[string]any{
|
||||
"revoked_at": now,
|
||||
"update_user_id": operatorID,
|
||||
"update_user_name": operatorName,
|
||||
"updated_at": now,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *loginSessionService) RevokeByUser(userID int64, operatorID int64, operatorName string) error {
|
||||
now := time.Now()
|
||||
return sqls.DB().Model(&models.LoginSession{}).
|
||||
Where("user_id = ? AND revoked_at IS NULL", userID).
|
||||
Updates(map[string]any{
|
||||
"revoked_at": now,
|
||||
"update_user_id": operatorID,
|
||||
"update_user_name": operatorName,
|
||||
"updated_at": now,
|
||||
}).Error
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cs-agent/internal/ai/mcps"
|
||||
"cs-agent/internal/pkg/config"
|
||||
"cs-agent/internal/pkg/errorsx"
|
||||
)
|
||||
|
||||
var MCPDebugService = newMCPDebugService()
|
||||
|
||||
func newMCPDebugService() *mCPDebugService {
|
||||
return &mCPDebugService{
|
||||
client: mcps.NewClient(),
|
||||
}
|
||||
}
|
||||
|
||||
type mCPDebugService struct {
|
||||
client *mcps.Client
|
||||
}
|
||||
|
||||
func (s *mCPDebugService) ListServers() []mcps.ServerInfo {
|
||||
cfg := config.Current()
|
||||
if len(cfg.MCP.Servers) == 0 {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, len(cfg.MCP.Servers))
|
||||
for code := range cfg.MCP.Servers {
|
||||
keys = append(keys, code)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
|
||||
ret := make([]mcps.ServerInfo, 0, len(keys))
|
||||
for _, code := range keys {
|
||||
server := cfg.MCP.Servers[code]
|
||||
ret = append(ret, mcps.ServerInfo{
|
||||
Code: code,
|
||||
Enabled: server.Enabled,
|
||||
Endpoint: strings.TrimSpace(server.Endpoint),
|
||||
TimeoutMS: server.TimeoutMS,
|
||||
})
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func (s *mCPDebugService) TestConnection(ctx context.Context, serverCode string) (*mcps.ConnectionResult, error) {
|
||||
server, err := s.resolveServer(serverCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
startedAt := time.Now()
|
||||
result, err := s.client.TestConnection(ctx, server)
|
||||
s.logResult("test_connection", serverCode, "", time.Since(startedAt), err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *mCPDebugService) ListTools(ctx context.Context, serverCode string) ([]mcps.ToolInfo, error) {
|
||||
server, err := s.resolveServer(serverCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
startedAt := time.Now()
|
||||
result, err := s.client.ListTools(ctx, server)
|
||||
s.logResult("list_tools", serverCode, "", time.Since(startedAt), err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *mCPDebugService) CallTool(ctx context.Context, serverCode string, toolName string, arguments map[string]any) (*mcps.ToolCallResult, error) {
|
||||
server, err := s.resolveServer(serverCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
startedAt := time.Now()
|
||||
result, err := s.client.CallTool(ctx, server, toolName, arguments)
|
||||
s.logResult("call_tool", serverCode, toolName, time.Since(startedAt), err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *mCPDebugService) resolveServer(serverCode string) (mcps.ServerConfig, error) {
|
||||
cfg := config.Current()
|
||||
if !cfg.MCP.Enabled {
|
||||
return mcps.ServerConfig{}, errorsx.InvalidParam("MCP未启用")
|
||||
}
|
||||
serverCode = strings.TrimSpace(serverCode)
|
||||
if serverCode == "" {
|
||||
return mcps.ServerConfig{}, errorsx.InvalidParam("serverCode不能为空")
|
||||
}
|
||||
server, ok := cfg.MCP.Servers[serverCode]
|
||||
if !ok {
|
||||
return mcps.ServerConfig{}, errorsx.InvalidParam("MCP服务配置不存在")
|
||||
}
|
||||
if !server.Enabled {
|
||||
return mcps.ServerConfig{}, errorsx.InvalidParam("MCP服务未启用")
|
||||
}
|
||||
return mcps.ServerConfig{
|
||||
Code: serverCode,
|
||||
Endpoint: strings.TrimSpace(server.Endpoint),
|
||||
TimeoutMS: server.TimeoutMS,
|
||||
Headers: cloneHeaders(server.Headers),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *mCPDebugService) logResult(action string, serverCode string, toolName string, elapsed time.Duration, err error) {
|
||||
fields := []any{
|
||||
"action", action,
|
||||
"server_code", serverCode,
|
||||
"tool_name", toolName,
|
||||
"elapsed_ms", elapsed.Milliseconds(),
|
||||
}
|
||||
if err != nil {
|
||||
fields = append(fields, "success", false, "error", err.Error())
|
||||
slog.Warn("mcp debug request failed", fields...)
|
||||
return
|
||||
}
|
||||
fields = append(fields, "success", true)
|
||||
slog.Info("mcp debug request finished", fields...)
|
||||
}
|
||||
|
||||
func cloneHeaders(headers map[string]string) map[string]string {
|
||||
if len(headers) == 0 {
|
||||
return nil
|
||||
}
|
||||
ret := make(map[string]string, len(headers))
|
||||
for key, value := range headers {
|
||||
ret[key] = value
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func DumpPayload(value any) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
buf, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%v", value)
|
||||
}
|
||||
return string(buf)
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
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/pkg/openidentity"
|
||||
"cs-agent/internal/repositories"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"github.com/mlogclub/simple/common/strs"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"github.com/mlogclub/simple/web/params"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
var MessageService = newMessageService()
|
||||
|
||||
func newMessageService() *messageService {
|
||||
return &messageService{}
|
||||
}
|
||||
|
||||
type messageService struct {
|
||||
}
|
||||
|
||||
func (s *messageService) Get(id int64) *models.Message {
|
||||
return repositories.MessageRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *messageService) Take(where ...interface{}) *models.Message {
|
||||
return repositories.MessageRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *messageService) Find(cnd *sqls.Cnd) []models.Message {
|
||||
return repositories.MessageRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
// FindByConversationIDCursor 按 id 游标分页:cursor=0 取最新 limit 条;cursor>0 取 id<cursor 的更旧消息。
|
||||
// 返回的 list 已按 id 升序(时间正序)。nextCursor 为下一页请求传入的游标(本批最小 id);hasMore 表示可能还有更旧消息。
|
||||
func (s *messageService) FindByConversationIDCursor(conversationID int64, cursor int64, limit int, senderType, messageType string) (list []models.Message, nextCursor int64, hasMore bool) {
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
} else if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
cnd := sqls.NewCnd().Eq("conversation_id", conversationID).Limit(limit).Desc("id")
|
||||
if cursor > 0 {
|
||||
cnd.Lt("id", cursor)
|
||||
}
|
||||
if strs.IsNotBlank(senderType) {
|
||||
cnd.Eq("sender_type", senderType)
|
||||
}
|
||||
if strs.IsNotBlank(messageType) {
|
||||
cnd.Eq("message_type", messageType)
|
||||
}
|
||||
list = s.Find(cnd)
|
||||
nextCursor = cursor
|
||||
hasMore = false
|
||||
if len(list) > 0 {
|
||||
nextCursor = list[len(list)-1].ID
|
||||
hasMore = len(list) == limit
|
||||
}
|
||||
slices.Reverse(list)
|
||||
return list, nextCursor, hasMore
|
||||
}
|
||||
|
||||
func (s *messageService) FindOne(cnd *sqls.Cnd) *models.Message {
|
||||
return repositories.MessageRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *messageService) FindPageByParams(params *params.QueryParams) (list []models.Message, paging *sqls.Paging) {
|
||||
return repositories.MessageRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *messageService) FindPageByCnd(cnd *sqls.Cnd) (list []models.Message, paging *sqls.Paging) {
|
||||
return repositories.MessageRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
// FindPageByCndForImListAscending 与 FindPageByCnd 相同分页条件,将结果按 seq 升序排列(开放 IM 时间正序展示)。
|
||||
func (s *messageService) FindPageByCndForImListAscending(cnd *sqls.Cnd) (list []models.Message, paging *sqls.Paging) {
|
||||
list, paging = s.FindPageByCnd(cnd)
|
||||
if len(list) <= 1 {
|
||||
return list, paging
|
||||
}
|
||||
for i, j := 0, len(list)-1; i < j; i, j = i+1, j-1 {
|
||||
list[i], list[j] = list[j], list[i]
|
||||
}
|
||||
return list, paging
|
||||
}
|
||||
|
||||
func (s *messageService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.MessageRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *messageService) Create(t *models.Message) error {
|
||||
return repositories.MessageRepository.Create(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *messageService) Update(t *models.Message) error {
|
||||
return repositories.MessageRepository.Update(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *messageService) Updates(id int64, columns map[string]interface{}) error {
|
||||
return repositories.MessageRepository.Updates(sqls.DB(), id, columns)
|
||||
}
|
||||
|
||||
func (s *messageService) UpdateColumn(id int64, name string, value interface{}) error {
|
||||
return repositories.MessageRepository.UpdateColumn(sqls.DB(), id, name, value)
|
||||
}
|
||||
|
||||
func (s *messageService) Delete(id int64) {
|
||||
repositories.MessageRepository.Delete(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *messageService) GetConversationReadTarget(conversationID, messageID int64) (*models.Message, error) {
|
||||
if messageID > 0 {
|
||||
message := s.Get(messageID)
|
||||
if message == nil || message.ConversationID != conversationID {
|
||||
return nil, errorsx.InvalidParam("消息不存在")
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
return s.FindOne(sqls.NewCnd().Eq("conversation_id", conversationID).Desc("seq_no").Desc("id")), nil
|
||||
}
|
||||
|
||||
func (s *messageService) SendMessage(conversationID int64, senderType enums.IMSenderType, reqSenderID int64, clientMsgID string, messageType enums.IMMessageType, content, payload string, operator *dto.AuthPrincipal, external *openidentity.ExternalInfo) (*models.Message, error) {
|
||||
switch senderType {
|
||||
case enums.IMSenderTypeAgent:
|
||||
return s.sendMessage(conversationID, enums.IMSenderTypeAgent, reqSenderID, clientMsgID, messageType, content, payload, operator, nil)
|
||||
case enums.IMSenderTypeAI:
|
||||
return s.sendMessage(conversationID, enums.IMSenderTypeAI, reqSenderID, clientMsgID, messageType, content, payload, operator, nil)
|
||||
case enums.IMSenderTypeCustomer:
|
||||
return s.sendMessage(conversationID, enums.IMSenderTypeCustomer, 0, clientMsgID, messageType, content, payload, nil, external)
|
||||
default:
|
||||
return nil, errorsx.InvalidParam("不支持的发送人类型")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *messageService) SendAgentMessage(conversationID int64, reqSenderID int64, clientMsgID string, messageType enums.IMMessageType, content, payload string, operator *dto.AuthPrincipal) (*models.Message, error) {
|
||||
return s.sendMessage(conversationID, enums.IMSenderTypeAgent, reqSenderID, clientMsgID, messageType, content, payload, operator, nil)
|
||||
}
|
||||
|
||||
func (s *messageService) RecallAgentMessage(messageID int64, operator *dto.AuthPrincipal) (*models.Message, error) {
|
||||
if operator == nil {
|
||||
return nil, errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
if messageID <= 0 {
|
||||
return nil, errorsx.InvalidParam("消息不存在")
|
||||
}
|
||||
|
||||
message := s.Get(messageID)
|
||||
if message == nil {
|
||||
return nil, errorsx.InvalidParam("消息不存在")
|
||||
}
|
||||
if message.SenderType != enums.IMSenderTypeAgent {
|
||||
return nil, errorsx.InvalidParam("仅支持撤回客服消息")
|
||||
}
|
||||
if message.SenderID != operator.UserID {
|
||||
return nil, errorsx.Forbidden("仅允许撤回自己发送的消息")
|
||||
}
|
||||
if message.RecalledAt != nil || message.SendStatus == int(enums.IMMessageStatusRecalled) {
|
||||
return nil, errorsx.InvalidParam("消息已撤回")
|
||||
}
|
||||
|
||||
conversation, err := s.ValidateConversationSender(message.ConversationID, enums.IMSenderTypeAgent, operator, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
err = sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
updates := map[string]any{
|
||||
"send_status": int(enums.IMMessageStatusRecalled),
|
||||
"recalled_at": now,
|
||||
"updated_at": now,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
}
|
||||
if err := repositories.MessageRepository.Updates(ctx.Tx, message.ID, updates); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
message.SendStatus = int(enums.IMMessageStatusRecalled)
|
||||
message.RecalledAt = &now
|
||||
message.UpdatedAt = now
|
||||
message.UpdateUserID = operator.UserID
|
||||
message.UpdateUserName = operator.Username
|
||||
|
||||
agentReadState, customerReadState := ConversationReadStateService.getConversationReadStates(ctx.Tx, conversation.ID)
|
||||
agentUnreadCount, err := ConversationReadStateService.CountUnreadMessages(ctx, conversation.ID, readSeqNo(agentReadState), enums.IMSenderTypeCustomer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
customerUnreadCount, err := ConversationReadStateService.CountUnreadMessages(ctx, conversation.ID, readSeqNo(customerReadState), enums.IMSenderTypeAgent, enums.IMSenderTypeAI)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
conversationUpdates := map[string]any{
|
||||
"agent_unread_count": agentUnreadCount,
|
||||
"customer_unread_count": customerUnreadCount,
|
||||
"updated_at": now,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
}
|
||||
if conversation.LastMessageID == message.ID {
|
||||
lastMessage := repositories.MessageRepository.FindLastUnrecalledByConversationID(ctx.Tx, conversation.ID)
|
||||
if lastMessage != nil {
|
||||
conversationUpdates["last_message_id"] = lastMessage.ID
|
||||
conversationUpdates["last_message_at"] = lastMessage.SentAt
|
||||
conversationUpdates["last_message_summary"] = limitText(buildMessageSummary(lastMessage.MessageType, lastMessage.Content), 255)
|
||||
} else {
|
||||
conversationUpdates["last_message_id"] = 0
|
||||
conversationUpdates["last_message_at"] = nil
|
||||
conversationUpdates["last_message_summary"] = ""
|
||||
}
|
||||
}
|
||||
if err := repositories.ConversationRepository.Updates(ctx.Tx, conversation.ID, conversationUpdates); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := ConversationEventLogService.CreateEvent(ctx, conversation.ID, enums.IMEventTypeMessageRecall, enums.IMSenderTypeAgent, operator.UserID, "客服撤回消息", ""); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if updatedConversation := ConversationService.Get(conversation.ID); updatedConversation != nil {
|
||||
WsService.PublishMessageRecalled(updatedConversation, message)
|
||||
WsService.PublishConversationChanged(updatedConversation, enums.IMRealtimeEventConversationUpdated)
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func (s *messageService) SendAIMessage(conversationID int64, aiAgentID int64, clientMsgID string, messageType enums.IMMessageType, content, payload string, operator *dto.AuthPrincipal) (*models.Message, error) {
|
||||
return s.sendMessage(conversationID, enums.IMSenderTypeAI, aiAgentID, clientMsgID, messageType, content, payload, operator, nil)
|
||||
}
|
||||
|
||||
func (s *messageService) SendCustomerMessage(conversationID int64, clientMsgID string, messageType enums.IMMessageType, content, payload string, external openidentity.ExternalInfo) (*models.Message, error) {
|
||||
ext := external
|
||||
return s.sendMessage(conversationID, enums.IMSenderTypeCustomer, 0, clientMsgID, messageType, content, payload, nil, &ext)
|
||||
}
|
||||
|
||||
func (s *messageService) sendMessage(conversationID int64, senderType enums.IMSenderType, reqSenderID int64, clientMsgID string,
|
||||
messageType enums.IMMessageType, content, payload string, operator *dto.AuthPrincipal, external *openidentity.ExternalInfo) (*models.Message, error) {
|
||||
|
||||
if senderType == enums.IMSenderTypeCustomer {
|
||||
if external == nil || strings.TrimSpace(external.ExternalID) == "" {
|
||||
return nil, errorsx.Unauthorized("外部用户标识不能为空")
|
||||
}
|
||||
} else if operator == nil {
|
||||
return nil, errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
|
||||
if strs.IsBlank(string(messageType)) {
|
||||
messageType = enums.IMMessageTypeText
|
||||
}
|
||||
conversation, err := s.ValidateConversationSender(conversationID, senderType, operator, external)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var summary string
|
||||
content, payload, summary, err = s.normalizeMessageContent(conversationID, messageType, content, payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strs.IsBlank(content) && strs.IsBlank(payload) {
|
||||
return nil, errorsx.InvalidParam("消息内容不能为空")
|
||||
}
|
||||
|
||||
// 防抖,消息存在就不再发送了
|
||||
if strs.IsNotBlank(clientMsgID) {
|
||||
if existing := repositories.MessageRepository.GetByClientMsgID(sqls.DB(), conversationID, clientMsgID); existing != nil {
|
||||
return existing, nil
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
now = time.Now()
|
||||
auditUserID = int64(0)
|
||||
auditUserName = ""
|
||||
nextSeq = repositories.MessageRepository.NextSeqNo(sqls.DB(), conversationID)
|
||||
)
|
||||
if operator != nil {
|
||||
auditUserID = operator.UserID
|
||||
auditUserName = operator.Username
|
||||
}
|
||||
if senderType == enums.IMSenderTypeCustomer && external != nil {
|
||||
auditUserID = 0
|
||||
auditUserName = displayExternalName(external)
|
||||
}
|
||||
message := &models.Message{
|
||||
ConversationID: conversationID,
|
||||
ClientMsgID: clientMsgID,
|
||||
SenderType: senderType,
|
||||
SenderID: reqSenderID,
|
||||
MessageType: messageType,
|
||||
Content: content,
|
||||
Payload: payload,
|
||||
SeqNo: nextSeq,
|
||||
SendStatus: int(enums.IMMessageStatusSent),
|
||||
SentAt: &now,
|
||||
AuditFields: models.AuditFields{
|
||||
CreatedAt: now,
|
||||
CreateUserID: auditUserID,
|
||||
CreateUserName: auditUserName,
|
||||
UpdatedAt: now,
|
||||
UpdateUserID: auditUserID,
|
||||
UpdateUserName: auditUserName,
|
||||
},
|
||||
}
|
||||
|
||||
switch senderType {
|
||||
case enums.IMSenderTypeAgent:
|
||||
if message.SenderID == 0 {
|
||||
message.SenderID = operator.UserID
|
||||
}
|
||||
case enums.IMSenderTypeAI:
|
||||
if message.SenderID == 0 {
|
||||
message.SenderID = reqSenderID
|
||||
}
|
||||
default:
|
||||
message.SenderID = 0
|
||||
}
|
||||
|
||||
err = sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
if err := repositories.MessageRepository.Create(ctx.Tx, message); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 处理已读、维度
|
||||
readStateType := senderType
|
||||
if senderType == enums.IMSenderTypeAI {
|
||||
readStateType = enums.IMSenderTypeAgent
|
||||
}
|
||||
if readStateType == enums.IMSenderTypeAgent {
|
||||
if _, err := ConversationReadStateService.MarkAgentRead(ctx, conversation, operator, message, now); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if _, err := ConversationReadStateService.MarkCustomerRead(ctx, conversation, external, message, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
agentReadState, customerReadState := ConversationReadStateService.getConversationReadStates(ctx.Tx, conversationID)
|
||||
agentUnreadCount, err := ConversationReadStateService.CountUnreadMessages(ctx, conversationID, readSeqNo(agentReadState), enums.IMSenderTypeCustomer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
customerUnreadCount, err := ConversationReadStateService.CountUnreadMessages(ctx, conversationID, readSeqNo(customerReadState), enums.IMSenderTypeAgent, enums.IMSenderTypeAI)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updateUserID := int64(0)
|
||||
updateUserName := ""
|
||||
if operator != nil {
|
||||
updateUserID = operator.UserID
|
||||
updateUserName = operator.Username
|
||||
}
|
||||
if senderType == enums.IMSenderTypeCustomer && external != nil {
|
||||
updateUserID = 0
|
||||
updateUserName = displayExternalName(external)
|
||||
}
|
||||
if err := repositories.ConversationRepository.Updates(ctx.Tx, conversationID, map[string]any{
|
||||
"last_message_id": message.ID,
|
||||
"last_message_at": now,
|
||||
"last_active_at": now,
|
||||
"last_message_summary": limitText(summary, 255),
|
||||
"update_user_id": updateUserID,
|
||||
"update_user_name": updateUserName,
|
||||
"updated_at": now,
|
||||
"agent_unread_count": agentUnreadCount,
|
||||
"customer_unread_count": customerUnreadCount,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ConversationEventLogService.CreateEvent(ctx,
|
||||
conversationID,
|
||||
enums.IMEventTypeMessageSend,
|
||||
senderType,
|
||||
func() int64 {
|
||||
if operator != nil {
|
||||
return operator.UserID
|
||||
}
|
||||
return 0
|
||||
}(),
|
||||
enums.GetIMSenderTypeLabel(senderType)+"发送消息",
|
||||
"",
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
WsService.PublishMessageCreated(conversation, message)
|
||||
WsService.PublishConversationChanged(conversation, enums.IMRealtimeEventConversationUpdated)
|
||||
if enqueueErr := ChannelMessageOutboxService.EnqueueWxWorkKFMessage(conversation, message); enqueueErr != nil {
|
||||
slog.Error("enqueue wxwork kf outbox failed",
|
||||
"conversation_id", conversation.ID,
|
||||
"message_id", message.ID,
|
||||
"external_source", conversation.ExternalSource,
|
||||
"error", enqueueErr,
|
||||
)
|
||||
}
|
||||
if senderType == enums.IMSenderTypeCustomer {
|
||||
if TriggerAIReplyAsyncHook != nil {
|
||||
TriggerAIReplyAsyncHook(*conversation, *message)
|
||||
}
|
||||
}
|
||||
return message, err
|
||||
}
|
||||
|
||||
func limitText(value string, maxLen int) string {
|
||||
if maxLen <= 0 {
|
||||
return ""
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
runes := []rune(value)
|
||||
if len(runes) <= maxLen {
|
||||
return value
|
||||
}
|
||||
return string(runes[:maxLen])
|
||||
}
|
||||
|
||||
func buildMessageSummary(messageType enums.IMMessageType, content string) string {
|
||||
content = strings.TrimSpace(content)
|
||||
if content != "" {
|
||||
return content
|
||||
}
|
||||
switch messageType {
|
||||
case enums.IMMessageTypeImage:
|
||||
return "[图片]"
|
||||
case enums.IMMessageTypeAttachment:
|
||||
return "[附件]"
|
||||
case enums.IMMessageTypeHTML:
|
||||
return buildHTMLSummary(content)
|
||||
case "":
|
||||
return ""
|
||||
default:
|
||||
return "[" + string(messageType) + "]"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *messageService) normalizeMessageContent(conversationID int64, messageType enums.IMMessageType, content, payload string) (string, string, string, error) {
|
||||
switch messageType {
|
||||
case enums.IMMessageTypeHTML:
|
||||
sanitized := sanitizeMessageHTML(content)
|
||||
summary := buildHTMLSummary(sanitized)
|
||||
if summary == "" {
|
||||
return "", "", "", errorsx.InvalidParam("消息内容不能为空")
|
||||
}
|
||||
return sanitized, "", summary, nil
|
||||
case enums.IMMessageTypeImage, enums.IMMessageTypeAttachment:
|
||||
assetPayload, err := parseIMMessageAssetPayload(payload)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
asset := AssetService.GetByAssetID(assetPayload.AssetID)
|
||||
if err := validateConversationAsset(asset, conversationID, messageType); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
canonicalPayload, err := buildIMMessageAssetPayload(asset)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
summary := "[附件]"
|
||||
if messageType == enums.IMMessageTypeImage {
|
||||
summary = "[图片]"
|
||||
}
|
||||
content = strings.TrimSpace(asset.Filename)
|
||||
return content, canonicalPayload, summary + suffixFilenameForSummary(asset.Filename), nil
|
||||
default:
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" && strings.TrimSpace(payload) == "" {
|
||||
return "", "", "", errorsx.InvalidParam("消息内容不能为空")
|
||||
}
|
||||
return content, strings.TrimSpace(payload), buildMessageSummary(messageType, content), nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *messageService) ValidateConversationSender(conversationID int64, senderType enums.IMSenderType, operator *dto.AuthPrincipal, external *openidentity.ExternalInfo) (*models.Conversation, error) {
|
||||
conversation := ConversationService.Get(conversationID)
|
||||
if conversation == nil {
|
||||
return nil, errorsx.InvalidParam("会话不存在")
|
||||
}
|
||||
if conversation.Status == enums.IMConversationStatusClosed {
|
||||
return nil, errorsx.InvalidParam("会话已关闭")
|
||||
}
|
||||
switch senderType {
|
||||
case enums.IMSenderTypeAgent:
|
||||
if operator == nil {
|
||||
return nil, errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
if conversation.Status != enums.IMConversationStatusActive || conversation.CurrentAssigneeID == 0 {
|
||||
return nil, errorsx.InvalidParam("会话未分配客服,暂不允许发送消息")
|
||||
}
|
||||
if conversation.CurrentAssigneeID != operator.UserID {
|
||||
return nil, errorsx.Forbidden("当前会话已分配给其他客服")
|
||||
}
|
||||
case enums.IMSenderTypeAI:
|
||||
if operator == nil {
|
||||
return nil, errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
if conversation.CurrentAssigneeID != 0 {
|
||||
return nil, errorsx.Forbidden("当前会话已由人工客服接管")
|
||||
}
|
||||
case enums.IMSenderTypeCustomer:
|
||||
if external == nil || !ConversationService.IsCustomerConversationOwner(conversation, *external) {
|
||||
return nil, errorsx.Forbidden("无权访问该会话")
|
||||
}
|
||||
default:
|
||||
return nil, errorsx.InvalidParam("不支持的发送人类型")
|
||||
}
|
||||
return conversation, nil
|
||||
}
|
||||
|
||||
func suffixFilenameForSummary(filename string) string {
|
||||
filename = strings.TrimSpace(filename)
|
||||
if filename == "" {
|
||||
return ""
|
||||
}
|
||||
return " " + filename
|
||||
}
|
||||
|
||||
func readSeqNo(state *models.ConversationReadState) int64 {
|
||||
if state == nil {
|
||||
return 0
|
||||
}
|
||||
return state.LastReadSeqNo
|
||||
}
|
||||
|
||||
func sanitizeMessageHTML(content string) string {
|
||||
policy := bluemonday.UGCPolicy()
|
||||
policy.AllowElements("img")
|
||||
policy.AllowAttrs("src", "alt", "title").OnElements("img")
|
||||
policy.AllowURLSchemes("http", "https")
|
||||
policy.AllowStandardURLs()
|
||||
policy.AllowElements("p", "br")
|
||||
return strings.TrimSpace(policy.Sanitize(content))
|
||||
}
|
||||
|
||||
func buildHTMLSummary(content string) string {
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return ""
|
||||
}
|
||||
doc, err := html.Parse(strings.NewReader("<div>" + content + "</div>"))
|
||||
if err != nil {
|
||||
return strings.TrimSpace(content)
|
||||
}
|
||||
parts := make([]string, 0, 8)
|
||||
var walk func(*html.Node)
|
||||
walk = func(node *html.Node) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
if node.Type == html.TextNode {
|
||||
text := strings.TrimSpace(node.Data)
|
||||
if text != "" {
|
||||
parts = append(parts, text)
|
||||
}
|
||||
}
|
||||
if node.Type == html.ElementNode && node.Data == "img" {
|
||||
parts = append(parts, "[图片]")
|
||||
}
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(doc)
|
||||
return strings.TrimSpace(strings.Join(parts, " "))
|
||||
}
|
||||
@@ -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 MigrationService = newMigrationService()
|
||||
|
||||
func newMigrationService() *migrationService {
|
||||
return &migrationService{}
|
||||
}
|
||||
|
||||
type migrationService struct {
|
||||
}
|
||||
|
||||
func (s *migrationService) Get(id int64) *models.Migration {
|
||||
return repositories.MigrationRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *migrationService) Take(where ...interface{}) *models.Migration {
|
||||
return repositories.MigrationRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *migrationService) Find(cnd *sqls.Cnd) []models.Migration {
|
||||
return repositories.MigrationRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *migrationService) FindOne(cnd *sqls.Cnd) *models.Migration {
|
||||
return repositories.MigrationRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *migrationService) FindPageByParams(params *params.QueryParams) (list []models.Migration, paging *sqls.Paging) {
|
||||
return repositories.MigrationRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *migrationService) FindPageByCnd(cnd *sqls.Cnd) (list []models.Migration, paging *sqls.Paging) {
|
||||
return repositories.MigrationRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *migrationService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.MigrationRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *migrationService) Create(t *models.Migration) error {
|
||||
return repositories.MigrationRepository.Create(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *migrationService) Update(t *models.Migration) error {
|
||||
return repositories.MigrationRepository.Update(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *migrationService) Updates(id int64, columns map[string]interface{}) error {
|
||||
return repositories.MigrationRepository.Updates(sqls.DB(), id, columns)
|
||||
}
|
||||
|
||||
func (s *migrationService) UpdateColumn(id int64, name string, value interface{}) error {
|
||||
return repositories.MigrationRepository.UpdateColumn(sqls.DB(), id, name, value)
|
||||
}
|
||||
|
||||
func (s *migrationService) Delete(id int64) {
|
||||
repositories.MigrationRepository.Delete(sqls.DB(), id)
|
||||
}
|
||||
@@ -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 PermissionService = newPermissionService()
|
||||
|
||||
func newPermissionService() *permissionService {
|
||||
return &permissionService{}
|
||||
}
|
||||
|
||||
type permissionService struct {
|
||||
}
|
||||
|
||||
func (s *permissionService) Get(id int64) *models.Permission {
|
||||
return repositories.PermissionRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *permissionService) Take(where ...interface{}) *models.Permission {
|
||||
return repositories.PermissionRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *permissionService) Find(cnd *sqls.Cnd) []models.Permission {
|
||||
return repositories.PermissionRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *permissionService) FindOne(cnd *sqls.Cnd) *models.Permission {
|
||||
return repositories.PermissionRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *permissionService) FindPageByParams(params *params.QueryParams) (list []models.Permission, paging *sqls.Paging) {
|
||||
return repositories.PermissionRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *permissionService) FindPageByCnd(cnd *sqls.Cnd) (list []models.Permission, paging *sqls.Paging) {
|
||||
return repositories.PermissionRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *permissionService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.PermissionRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *permissionService) Create(t *models.Permission) error {
|
||||
return repositories.PermissionRepository.Create(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *permissionService) Update(t *models.Permission) error {
|
||||
return repositories.PermissionRepository.Update(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *permissionService) Updates(id int64, columns map[string]interface{}) error {
|
||||
return repositories.PermissionRepository.Updates(sqls.DB(), id, columns)
|
||||
}
|
||||
|
||||
func (s *permissionService) UpdateColumn(id int64, name string, value interface{}) error {
|
||||
return repositories.PermissionRepository.UpdateColumn(sqls.DB(), id, name, value)
|
||||
}
|
||||
|
||||
func (s *permissionService) Delete(id int64) {
|
||||
repositories.PermissionRepository.Delete(sqls.DB(), id)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/dto"
|
||||
"cs-agent/internal/pkg/dto/request"
|
||||
"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 QuickReplyService = newQuickReplyService()
|
||||
|
||||
func newQuickReplyService() *quickReplyService {
|
||||
return &quickReplyService{}
|
||||
}
|
||||
|
||||
type quickReplyService struct {
|
||||
}
|
||||
|
||||
func (s *quickReplyService) Get(id int64) *models.QuickReply {
|
||||
return repositories.QuickReplyRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *quickReplyService) Take(where ...interface{}) *models.QuickReply {
|
||||
return repositories.QuickReplyRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *quickReplyService) Find(cnd *sqls.Cnd) []models.QuickReply {
|
||||
return repositories.QuickReplyRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *quickReplyService) FindOne(cnd *sqls.Cnd) *models.QuickReply {
|
||||
return repositories.QuickReplyRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *quickReplyService) FindPageByParams(params *params.QueryParams) (list []models.QuickReply, paging *sqls.Paging) {
|
||||
return repositories.QuickReplyRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *quickReplyService) FindPageByCnd(cnd *sqls.Cnd) (list []models.QuickReply, paging *sqls.Paging) {
|
||||
return repositories.QuickReplyRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *quickReplyService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.QuickReplyRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *quickReplyService) Create(t *models.QuickReply) error {
|
||||
return repositories.QuickReplyRepository.Create(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *quickReplyService) Update(t *models.QuickReply) error {
|
||||
return repositories.QuickReplyRepository.Update(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *quickReplyService) Updates(id int64, columns map[string]interface{}) error {
|
||||
return repositories.QuickReplyRepository.Updates(sqls.DB(), id, columns)
|
||||
}
|
||||
|
||||
func (s *quickReplyService) UpdateColumn(id int64, name string, value interface{}) error {
|
||||
return repositories.QuickReplyRepository.UpdateColumn(sqls.DB(), id, name, value)
|
||||
}
|
||||
|
||||
func (s *quickReplyService) Delete(id int64) {
|
||||
repositories.QuickReplyRepository.Delete(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *quickReplyService) CreateQuickReply(req request.CreateQuickReplyRequest, operator *dto.AuthPrincipal) (*models.QuickReply, error) {
|
||||
if operator == nil {
|
||||
return nil, errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
title := strings.TrimSpace(req.Title)
|
||||
content := strings.TrimSpace(req.Content)
|
||||
if title == "" || content == "" {
|
||||
return nil, errorsx.InvalidParam("标题和内容不能为空")
|
||||
}
|
||||
item := &models.QuickReply{
|
||||
GroupName: strings.TrimSpace(req.GroupName),
|
||||
Title: title,
|
||||
Content: content,
|
||||
Status: req.Status,
|
||||
SortNo: req.SortNo,
|
||||
AuditFields: utils.BuildAuditFields(operator),
|
||||
}
|
||||
if err := s.Create(item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *quickReplyService) UpdateQuickReply(req request.UpdateQuickReplyRequest, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
item := s.Get(req.ID)
|
||||
if item == nil {
|
||||
return errorsx.InvalidParam("快捷回复不存在")
|
||||
}
|
||||
return s.Updates(req.ID, map[string]any{
|
||||
"group_name": strings.TrimSpace(req.GroupName),
|
||||
"title": strings.TrimSpace(req.Title),
|
||||
"content": strings.TrimSpace(req.Content),
|
||||
"status": req.Status,
|
||||
"sort_no": req.SortNo,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *quickReplyService) DeleteQuickReply(id int64) error {
|
||||
item := s.Get(id)
|
||||
if item == nil {
|
||||
return errorsx.InvalidParam("快捷回复不存在")
|
||||
}
|
||||
s.Delete(id)
|
||||
return nil
|
||||
}
|
||||
@@ -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 RolePermissionService = newRolePermissionService()
|
||||
|
||||
func newRolePermissionService() *rolePermissionService {
|
||||
return &rolePermissionService{}
|
||||
}
|
||||
|
||||
type rolePermissionService struct {
|
||||
}
|
||||
|
||||
func (s *rolePermissionService) Get(id int64) *models.RolePermission {
|
||||
return repositories.RolePermissionRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *rolePermissionService) Take(where ...interface{}) *models.RolePermission {
|
||||
return repositories.RolePermissionRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *rolePermissionService) Find(cnd *sqls.Cnd) []models.RolePermission {
|
||||
return repositories.RolePermissionRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *rolePermissionService) FindOne(cnd *sqls.Cnd) *models.RolePermission {
|
||||
return repositories.RolePermissionRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *rolePermissionService) FindPageByParams(params *params.QueryParams) (list []models.RolePermission, paging *sqls.Paging) {
|
||||
return repositories.RolePermissionRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *rolePermissionService) FindPageByCnd(cnd *sqls.Cnd) (list []models.RolePermission, paging *sqls.Paging) {
|
||||
return repositories.RolePermissionRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *rolePermissionService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.RolePermissionRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *rolePermissionService) Create(t *models.RolePermission) error {
|
||||
return repositories.RolePermissionRepository.Create(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *rolePermissionService) Update(t *models.RolePermission) error {
|
||||
return repositories.RolePermissionRepository.Update(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *rolePermissionService) Updates(id int64, columns map[string]interface{}) error {
|
||||
return repositories.RolePermissionRepository.Updates(sqls.DB(), id, columns)
|
||||
}
|
||||
|
||||
func (s *rolePermissionService) UpdateColumn(id int64, name string, value interface{}) error {
|
||||
return repositories.RolePermissionRepository.UpdateColumn(sqls.DB(), id, name, value)
|
||||
}
|
||||
|
||||
func (s *rolePermissionService) Delete(id int64) {
|
||||
repositories.RolePermissionRepository.Delete(sqls.DB(), id)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
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"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"github.com/mlogclub/simple/web/params"
|
||||
)
|
||||
|
||||
var RoleService = newRoleService()
|
||||
|
||||
func newRoleService() *roleService {
|
||||
return &roleService{}
|
||||
}
|
||||
|
||||
type roleService struct {
|
||||
}
|
||||
|
||||
func (s *roleService) Get(id int64) *models.Role {
|
||||
return repositories.RoleRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *roleService) Take(where ...interface{}) *models.Role {
|
||||
return repositories.RoleRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *roleService) Find(cnd *sqls.Cnd) []models.Role {
|
||||
return repositories.RoleRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *roleService) FindOne(cnd *sqls.Cnd) *models.Role {
|
||||
return repositories.RoleRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *roleService) FindPageByParams(params *params.QueryParams) (list []models.Role, paging *sqls.Paging) {
|
||||
return repositories.RoleRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *roleService) FindPageByCnd(cnd *sqls.Cnd) (list []models.Role, paging *sqls.Paging) {
|
||||
return repositories.RoleRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *roleService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.RoleRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *roleService) Create(t *models.Role) error {
|
||||
return repositories.RoleRepository.Create(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *roleService) Update(t *models.Role) error {
|
||||
return repositories.RoleRepository.Update(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *roleService) Updates(id int64, columns map[string]interface{}) error {
|
||||
return repositories.RoleRepository.Updates(sqls.DB(), id, columns)
|
||||
}
|
||||
|
||||
func (s *roleService) UpdateColumn(id int64, name string, value interface{}) error {
|
||||
return repositories.RoleRepository.UpdateColumn(sqls.DB(), id, name, value)
|
||||
}
|
||||
|
||||
func (s *roleService) Delete(id int64) {
|
||||
repositories.RoleRepository.Delete(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *roleService) CreateRole(req request.CreateRoleRequest, operator *dto.AuthPrincipal) (*models.Role, error) {
|
||||
name := strings.TrimSpace(req.Name)
|
||||
code := strings.TrimSpace(req.Code)
|
||||
if name == "" || code == "" {
|
||||
return nil, errorsx.InvalidParam("角色名称和编码不能为空")
|
||||
}
|
||||
if s.Take("code = ?", code) != nil {
|
||||
return nil, errorsx.InvalidParam("角色编码已存在")
|
||||
}
|
||||
|
||||
role := &models.Role{
|
||||
Name: name,
|
||||
Code: code,
|
||||
Status: enums.StatusOk,
|
||||
IsSystem: false,
|
||||
SortNo: req.SortNo,
|
||||
Remark: strings.TrimSpace(req.Remark),
|
||||
AuditFields: utils.BuildAuditFields(operator),
|
||||
}
|
||||
if err := s.Create(role); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return role, nil
|
||||
}
|
||||
|
||||
func (s *roleService) UpdateRole(req request.UpdateRoleRequest, operator *dto.AuthPrincipal) error {
|
||||
role := s.Get(req.ID)
|
||||
if role == nil {
|
||||
return errorsx.InvalidParam("角色不存在")
|
||||
}
|
||||
now := time.Now()
|
||||
return s.Updates(req.ID, map[string]any{
|
||||
"name": strings.TrimSpace(req.Name),
|
||||
"sort_no": req.SortNo,
|
||||
"remark": strings.TrimSpace(req.Remark),
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": now,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *roleService) NextSortNo() int {
|
||||
if latest := s.FindOne(sqls.NewCnd().Asc("sort_no").Desc("id")); latest != nil {
|
||||
return latest.SortNo + 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (s *roleService) UpdateSort(ids []int64) error {
|
||||
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
for i, id := range ids {
|
||||
if err := repositories.RoleRepository.UpdateColumn(ctx.Tx, id, "sort_no", i); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *roleService) DeleteRole(id int64) error {
|
||||
role := s.Get(id)
|
||||
if role == nil {
|
||||
return errorsx.InvalidParam("角色不存在")
|
||||
}
|
||||
if role.IsSystem {
|
||||
return errorsx.Forbidden("系统内置角色不允许删除")
|
||||
}
|
||||
if UserRoleService.Take("role_id = ?", id) != nil {
|
||||
return errorsx.Forbidden("角色已被用户使用,无法删除")
|
||||
}
|
||||
s.Delete(id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *roleService) UpdateStatus(id int64, status enums.Status, operator *dto.AuthPrincipal) error {
|
||||
role := s.Get(id)
|
||||
if role == nil {
|
||||
return errorsx.InvalidParam("角色不存在")
|
||||
}
|
||||
if !slices.Contains(enums.StatusValues, status) {
|
||||
return errorsx.InvalidParam("状态值不合法")
|
||||
}
|
||||
if err := s.Updates(id, map[string]any{
|
||||
"status": status,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *roleService) AssignPermissions(roleID int64, permissionIDs []int64, operator *dto.AuthPrincipal) error {
|
||||
role := s.Get(roleID)
|
||||
if role == nil {
|
||||
return errorsx.InvalidParam("角色不存在")
|
||||
}
|
||||
|
||||
return s.replaceRolePermissions(roleID, permissionIDs, operator)
|
||||
}
|
||||
|
||||
func (s *roleService) replaceRolePermissions(roleID int64, permissionIDs []int64, operator *dto.AuthPrincipal) error {
|
||||
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
if err := ctx.Tx.Where("role_id = ?", roleID).Delete(&models.RolePermission{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, permissionID := range permissionIDs {
|
||||
permission := PermissionService.Get(permissionID)
|
||||
if permission == nil {
|
||||
return errorsx.InvalidParam("权限不存在")
|
||||
}
|
||||
relation := &models.RolePermission{
|
||||
RoleID: roleID,
|
||||
PermissionID: permissionID,
|
||||
AuditFields: utils.BuildAuditFields(operator),
|
||||
}
|
||||
if err := ctx.Tx.Create(relation).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/repositories"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"github.com/mlogclub/simple/web/params"
|
||||
)
|
||||
|
||||
var SkillDefinitionService = newSkillDefinitionService()
|
||||
|
||||
func newSkillDefinitionService() *skillDefinitionService {
|
||||
return &skillDefinitionService{}
|
||||
}
|
||||
|
||||
type skillDefinitionService struct {
|
||||
}
|
||||
|
||||
func (s *skillDefinitionService) Get(id int64) *models.SkillDefinition {
|
||||
return repositories.SkillDefinitionRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *skillDefinitionService) Take(where ...interface{}) *models.SkillDefinition {
|
||||
return repositories.SkillDefinitionRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *skillDefinitionService) Find(cnd *sqls.Cnd) []models.SkillDefinition {
|
||||
return repositories.SkillDefinitionRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *skillDefinitionService) FindOne(cnd *sqls.Cnd) *models.SkillDefinition {
|
||||
return repositories.SkillDefinitionRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *skillDefinitionService) FindPageByParams(params *params.QueryParams) (list []models.SkillDefinition, paging *sqls.Paging) {
|
||||
return repositories.SkillDefinitionRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *skillDefinitionService) FindPageByCnd(cnd *sqls.Cnd) (list []models.SkillDefinition, paging *sqls.Paging) {
|
||||
return repositories.SkillDefinitionRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *skillDefinitionService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.SkillDefinitionRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *skillDefinitionService) Create(t *models.SkillDefinition) error {
|
||||
return repositories.SkillDefinitionRepository.Create(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *skillDefinitionService) Update(t *models.SkillDefinition) error {
|
||||
return repositories.SkillDefinitionRepository.Update(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *skillDefinitionService) Updates(id int64, columns map[string]interface{}) error {
|
||||
return repositories.SkillDefinitionRepository.Updates(sqls.DB(), id, columns)
|
||||
}
|
||||
|
||||
func (s *skillDefinitionService) UpdateColumn(id int64, name string, value interface{}) error {
|
||||
return repositories.SkillDefinitionRepository.UpdateColumn(sqls.DB(), id, name, value)
|
||||
}
|
||||
|
||||
func (s *skillDefinitionService) Delete(id int64) {
|
||||
repositories.SkillDefinitionRepository.Delete(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *skillDefinitionService) NextPriority() int {
|
||||
if max := repositories.SkillDefinitionRepository.FindOne(sqls.DB(), sqls.NewCnd().Desc("priority").Desc("id")); max != nil {
|
||||
return max.Priority + 1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func (s *skillDefinitionService) UpdatePriority(ids []int64) error {
|
||||
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
for i, id := range ids {
|
||||
if err := repositories.SkillDefinitionRepository.UpdateColumn(ctx.Tx, id, "priority", i+1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *skillDefinitionService) GetByCode(code string) *models.SkillDefinition {
|
||||
return repositories.SkillDefinitionRepository.GetByCode(sqls.DB(), code)
|
||||
}
|
||||
@@ -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 SkillRunLogService = newSkillRunLogService()
|
||||
|
||||
func newSkillRunLogService() *skillRunLogService {
|
||||
return &skillRunLogService{}
|
||||
}
|
||||
|
||||
type skillRunLogService struct {
|
||||
}
|
||||
|
||||
func (s *skillRunLogService) Get(id int64) *models.SkillRunLog {
|
||||
return repositories.SkillRunLogRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *skillRunLogService) Take(where ...interface{}) *models.SkillRunLog {
|
||||
return repositories.SkillRunLogRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *skillRunLogService) Find(cnd *sqls.Cnd) []models.SkillRunLog {
|
||||
return repositories.SkillRunLogRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *skillRunLogService) FindOne(cnd *sqls.Cnd) *models.SkillRunLog {
|
||||
return repositories.SkillRunLogRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *skillRunLogService) FindPageByParams(params *params.QueryParams) (list []models.SkillRunLog, paging *sqls.Paging) {
|
||||
return repositories.SkillRunLogRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *skillRunLogService) FindPageByCnd(cnd *sqls.Cnd) (list []models.SkillRunLog, paging *sqls.Paging) {
|
||||
return repositories.SkillRunLogRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *skillRunLogService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.SkillRunLogRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *skillRunLogService) Create(t *models.SkillRunLog) error {
|
||||
return repositories.SkillRunLogRepository.Create(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *skillRunLogService) Update(t *models.SkillRunLog) error {
|
||||
return repositories.SkillRunLogRepository.Update(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *skillRunLogService) Updates(id int64, columns map[string]interface{}) error {
|
||||
return repositories.SkillRunLogRepository.Updates(sqls.DB(), id, columns)
|
||||
}
|
||||
|
||||
func (s *skillRunLogService) UpdateColumn(id int64, name string, value interface{}) error {
|
||||
return repositories.SkillRunLogRepository.UpdateColumn(sqls.DB(), id, name, value)
|
||||
}
|
||||
|
||||
func (s *skillRunLogService) Delete(id int64) {
|
||||
repositories.SkillRunLogRepository.Delete(sqls.DB(), id)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"cs-agent/internal/ai/skills"
|
||||
"cs-agent/internal/pkg/dto/request"
|
||||
"cs-agent/internal/pkg/dto/response"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"cs-agent/internal/pkg/errorsx"
|
||||
)
|
||||
|
||||
var SkillRuntimeService = newSkillRuntimeService()
|
||||
|
||||
func newSkillRuntimeService() *skillRuntimeService {
|
||||
return &skillRuntimeService{}
|
||||
}
|
||||
|
||||
type skillRuntimeService struct{}
|
||||
|
||||
func (s *skillRuntimeService) DebugRun(ctx context.Context, req request.SkillDebugRunRequest) (*response.SkillDebugRunResponse, error) {
|
||||
if req.AIAgentID <= 0 {
|
||||
return nil, errorsx.InvalidParam("aiAgentId不能为空")
|
||||
}
|
||||
if strings.TrimSpace(req.SkillCode) == "" {
|
||||
return nil, errorsx.InvalidParam("skillCode不能为空")
|
||||
}
|
||||
if strings.TrimSpace(req.UserMessage) == "" {
|
||||
return nil, errorsx.InvalidParam("userMessage不能为空")
|
||||
}
|
||||
|
||||
aiAgent := AIAgentService.Get(req.AIAgentID)
|
||||
if aiAgent == nil || aiAgent.Status != enums.StatusOk {
|
||||
return nil, errorsx.InvalidParam("AI Agent不存在或未启用")
|
||||
}
|
||||
if AIConfigService.Get(aiAgent.AIConfigID) == nil {
|
||||
return nil, errorsx.InvalidParam("AI Agent关联的AI配置不存在")
|
||||
}
|
||||
if req.ConversationID > 0 {
|
||||
conversation := ConversationService.Get(req.ConversationID)
|
||||
if conversation == nil {
|
||||
return nil, errorsx.InvalidParam("会话不存在")
|
||||
}
|
||||
}
|
||||
|
||||
result, err := skills.Execute(ctx, skills.RuntimeContext{
|
||||
AIAgentID: req.AIAgentID,
|
||||
UserMessage: strings.TrimSpace(req.UserMessage),
|
||||
ConversationID: req.ConversationID,
|
||||
ManualSkillCode: strings.TrimSpace(req.SkillCode),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result == nil || result.Plan == nil || result.Plan.Skill == nil {
|
||||
return nil, errorsx.InvalidParam("Skill 未命中")
|
||||
}
|
||||
return buildSkillDebugRunResponse(req, result), nil
|
||||
}
|
||||
|
||||
func buildSkillDebugRunResponse(req request.SkillDebugRunRequest, result *skills.ExecutionResult) *response.SkillDebugRunResponse {
|
||||
resp := &response.SkillDebugRunResponse{
|
||||
ConversationID: req.ConversationID,
|
||||
AIAgentID: req.AIAgentID,
|
||||
ReplyText: result.ReplyText,
|
||||
}
|
||||
if result.Plan != nil && result.Plan.Skill != nil {
|
||||
resp.SkillCode = result.Plan.Skill.Code
|
||||
resp.SkillName = result.Plan.Skill.Name
|
||||
}
|
||||
if result.RunLog != nil {
|
||||
resp.RunLogID = result.RunLog.ID
|
||||
resp.TraceData = result.RunLog.TraceData
|
||||
}
|
||||
return resp
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"cs-agent/internal/pkg/dto"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
)
|
||||
|
||||
type UploadInfo struct {
|
||||
Prefix string
|
||||
Filename string
|
||||
FileSize int64
|
||||
MimeType string
|
||||
Principal *dto.AuthPrincipal
|
||||
}
|
||||
|
||||
type StoredFile struct {
|
||||
Provider enums.AssetProvider
|
||||
StorageKey string
|
||||
URL string
|
||||
Filename string
|
||||
FileSize int64
|
||||
MimeType string
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"cs-agent/internal/pkg/config"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type LocalStorage struct {
|
||||
cfg config.LocalStorageConfig
|
||||
}
|
||||
|
||||
func NewLocalStorage(cfg config.LocalStorageConfig) *LocalStorage {
|
||||
return &LocalStorage{cfg: cfg}
|
||||
}
|
||||
|
||||
func (s *LocalStorage) ProviderType() enums.AssetProvider {
|
||||
return enums.AssetProviderLocal
|
||||
}
|
||||
|
||||
func (s *LocalStorage) Upload(reader io.Reader, key string, info UploadInfo) (*StoredFile, error) {
|
||||
fullPath := filepath.Join(s.cfg.Root, filepath.FromSlash(key))
|
||||
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dst, err := os.Create(fullPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
if _, err := io.Copy(dst, reader); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &StoredFile{
|
||||
Provider: enums.AssetProviderLocal,
|
||||
StorageKey: key,
|
||||
URL: strings.TrimRight(s.cfg.BaseURL, "/") + "/" + strings.TrimLeft(key, "/"),
|
||||
Filename: info.Filename,
|
||||
FileSize: info.FileSize,
|
||||
MimeType: info.MimeType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *LocalStorage) GetURL(key string) string {
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(s.cfg.BaseURL), "/")
|
||||
return strings.TrimRight(baseURL, "/") + "/" + strings.TrimLeft(key, "/")
|
||||
}
|
||||
|
||||
func (s *LocalStorage) GetSignedURL(key string) string {
|
||||
return s.GetURL(key)
|
||||
}
|
||||
|
||||
func (s *LocalStorage) Delete(key string) error {
|
||||
fullPath := filepath.Join(s.cfg.Root, filepath.FromSlash(key))
|
||||
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return os.Remove(fullPath)
|
||||
}
|
||||
|
||||
func (s *LocalStorage) Read(key string) (io.ReadCloser, error) {
|
||||
fullPath := filepath.Join(s.cfg.Root, filepath.FromSlash(strings.TrimSpace(key)))
|
||||
return os.Open(fullPath)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"cs-agent/internal/pkg/config"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"cs-agent/internal/pkg/errorsx"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/aliyun/aliyun-oss-go-sdk/oss"
|
||||
"github.com/mlogclub/simple/common/strs"
|
||||
)
|
||||
|
||||
type OSSStorage struct {
|
||||
cfg config.OSSStorageConfig
|
||||
}
|
||||
|
||||
func NewOSSStorage(cfg config.OSSStorageConfig) *OSSStorage {
|
||||
return &OSSStorage{cfg: cfg}
|
||||
}
|
||||
|
||||
func (s *OSSStorage) ProviderType() enums.AssetProvider {
|
||||
return enums.AssetProviderOSS
|
||||
}
|
||||
|
||||
func (s *OSSStorage) Upload(reader io.Reader, key string, info UploadInfo) (*StoredFile, error) {
|
||||
bucket, err := s.getBucket()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
key = normalizeOSSKey(key)
|
||||
options := []oss.Option{}
|
||||
if mimeType := strings.TrimSpace(info.MimeType); mimeType != "" {
|
||||
options = append(options, oss.ContentType(mimeType))
|
||||
}
|
||||
if info.FileSize > 0 {
|
||||
options = append(options, oss.ContentLength(info.FileSize))
|
||||
}
|
||||
|
||||
if err := bucket.PutObject(key, reader, options...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &StoredFile{
|
||||
Provider: enums.AssetProviderOSS,
|
||||
StorageKey: key,
|
||||
URL: s.GetURL(key),
|
||||
Filename: info.Filename,
|
||||
FileSize: info.FileSize,
|
||||
MimeType: info.MimeType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *OSSStorage) GetURL(key string) string {
|
||||
key = normalizeOSSKey(key)
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if baseURL := strings.TrimRight(strings.TrimSpace(s.cfg.BaseURL), "/"); baseURL != "" {
|
||||
return baseURL + "/" + key
|
||||
}
|
||||
|
||||
if s.cfg.Private {
|
||||
bucket, err := s.getBucket()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
signedURL, err := bucket.SignURL(key, oss.HTTPGet, s.signedURLExpire())
|
||||
if err == nil {
|
||||
return signedURL
|
||||
}
|
||||
}
|
||||
|
||||
return s.objectURL(key)
|
||||
}
|
||||
|
||||
func (s *OSSStorage) GetSignedURL(key string) string {
|
||||
key = normalizeOSSKey(key)
|
||||
if strs.IsBlank(key) {
|
||||
return ""
|
||||
}
|
||||
if !s.cfg.Private {
|
||||
return s.GetURL(key)
|
||||
}
|
||||
|
||||
bucket, err := s.getBucket()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
signedURL, err := bucket.SignURL(key, oss.HTTPGet, s.signedURLExpire())
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return signedURL
|
||||
}
|
||||
|
||||
func (s *OSSStorage) Delete(key string) error {
|
||||
bucket, err := s.getBucket()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return bucket.DeleteObject(normalizeOSSKey(key))
|
||||
}
|
||||
|
||||
func (s *OSSStorage) Read(key string) (io.ReadCloser, error) {
|
||||
bucket, err := s.getBucket()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bucket.GetObject(normalizeOSSKey(key))
|
||||
}
|
||||
|
||||
func (s *OSSStorage) getBucket() (*oss.Bucket, error) {
|
||||
if err := s.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client, err := oss.New(
|
||||
s.endpoint(),
|
||||
strings.TrimSpace(s.cfg.AccessKeyID),
|
||||
strings.TrimSpace(s.cfg.AccessKeySecret),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client.Bucket(strings.TrimSpace(s.cfg.Bucket))
|
||||
}
|
||||
|
||||
func (s *OSSStorage) validate() error {
|
||||
if strings.TrimSpace(s.cfg.Endpoint) == "" {
|
||||
return errorsx.InvalidParam("OSS endpoint 未配置")
|
||||
}
|
||||
if strings.TrimSpace(s.cfg.Bucket) == "" {
|
||||
return errorsx.InvalidParam("OSS bucket 未配置")
|
||||
}
|
||||
if strings.TrimSpace(s.cfg.AccessKeyID) == "" {
|
||||
return errorsx.InvalidParam("OSS accessKeyId 未配置")
|
||||
}
|
||||
if strings.TrimSpace(s.cfg.AccessKeySecret) == "" {
|
||||
return errorsx.InvalidParam("OSS accessKeySecret 未配置")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *OSSStorage) endpoint() string {
|
||||
endpoint := strings.TrimSpace(s.cfg.Endpoint)
|
||||
if endpoint == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(endpoint, "://") {
|
||||
return endpoint
|
||||
}
|
||||
return "https://" + endpoint
|
||||
}
|
||||
|
||||
func (s *OSSStorage) objectURL(key string) string {
|
||||
u, err := url.Parse(s.endpoint())
|
||||
if err != nil || u.Host == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s://%s.%s/%s", u.Scheme, strings.TrimSpace(s.cfg.Bucket), u.Host, key)
|
||||
}
|
||||
|
||||
func (s *OSSStorage) signedURLExpire() int64 {
|
||||
if s.cfg.SignedURLExpire > 0 {
|
||||
return int64(s.cfg.SignedURLExpire)
|
||||
}
|
||||
return 600
|
||||
}
|
||||
|
||||
func normalizeOSSKey(key string) string {
|
||||
return strings.TrimLeft(strings.TrimSpace(key), "/")
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"cs-agent/internal/pkg/config"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"cs-agent/internal/pkg/errorsx"
|
||||
"io"
|
||||
)
|
||||
|
||||
type FileStorageProvider interface {
|
||||
ProviderType() enums.AssetProvider
|
||||
Upload(reader io.Reader, key string, info UploadInfo) (*StoredFile, error)
|
||||
GetURL(key string) string
|
||||
GetSignedURL(key string) string
|
||||
Delete(key string) error
|
||||
Read(key string) (io.ReadCloser, error)
|
||||
}
|
||||
|
||||
func GetDefault() (FileStorageProvider, error) {
|
||||
return NewProvider(config.Current().Storage.Default)
|
||||
}
|
||||
|
||||
func NewProvider(provider enums.AssetProvider) (FileStorageProvider, error) {
|
||||
cfg := config.Current().Storage
|
||||
|
||||
switch provider {
|
||||
case "", enums.AssetProviderLocal:
|
||||
return NewLocalStorage(cfg.Local), nil
|
||||
case enums.AssetProviderOSS:
|
||||
return NewOSSStorage(cfg.OSS), nil
|
||||
default:
|
||||
return nil, errorsx.InvalidParam("不支持的文件存储类型")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"mime"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mlogclub/simple/common/strs"
|
||||
)
|
||||
|
||||
func GenerateStorageKey(info UploadInfo) (assetID string, storageKey string) {
|
||||
assetID = strs.UUID()
|
||||
var (
|
||||
env = currentAssetEnv()
|
||||
prefix = normalizeAssetPrefix(info.Prefix)
|
||||
datePath = time.Now().Format("2006/01/02")
|
||||
ext = getExt(info)
|
||||
)
|
||||
|
||||
storageKey = filepath.Join(
|
||||
env,
|
||||
prefix,
|
||||
datePath,
|
||||
assetID+ext,
|
||||
)
|
||||
storageKey = strings.TrimLeft(filepath.ToSlash(storageKey), "/")
|
||||
return
|
||||
}
|
||||
|
||||
func getExt(info UploadInfo) string {
|
||||
ext := strings.ToLower(filepath.Ext(strings.TrimSpace(info.Filename)))
|
||||
if ext == "" {
|
||||
ext = getExtByMimeType(info.MimeType)
|
||||
}
|
||||
return ext
|
||||
}
|
||||
|
||||
func getExtByMimeType(mimeType string) string {
|
||||
if strs.IsBlank(mimeType) {
|
||||
return ""
|
||||
}
|
||||
|
||||
mediaType, _, _ := mime.ParseMediaType(mimeType)
|
||||
if mediaType == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 处理一些非标准的 MIME 类型
|
||||
switch mediaType {
|
||||
case "image/jfif":
|
||||
return ".jpg"
|
||||
case "image/pjpeg":
|
||||
return ".jpg"
|
||||
case "image/jpeg":
|
||||
return ".jpg"
|
||||
default:
|
||||
exts, _ := mime.ExtensionsByType(mediaType)
|
||||
if len(exts) > 0 {
|
||||
return exts[0]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeAssetPrefix(prefix string) string {
|
||||
prefix = strings.TrimSpace(prefix)
|
||||
prefix = strings.Trim(prefix, "/")
|
||||
prefix = strings.ReplaceAll(prefix, "..", "")
|
||||
prefix = filepath.ToSlash(prefix)
|
||||
for strings.Contains(prefix, "//") {
|
||||
prefix = strings.ReplaceAll(prefix, "//", "/")
|
||||
}
|
||||
return strings.Trim(prefix, "/")
|
||||
}
|
||||
|
||||
func currentAssetEnv() string {
|
||||
for _, key := range []string{"APP_ENV", "GO_ENV"} {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/repositories"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"github.com/mlogclub/simple/web/params"
|
||||
)
|
||||
|
||||
var SystemConfigService = newSystemConfigService()
|
||||
|
||||
func newSystemConfigService() *systemConfigService {
|
||||
return &systemConfigService{}
|
||||
}
|
||||
|
||||
type systemConfigService struct {
|
||||
}
|
||||
|
||||
func (s *systemConfigService) Get(id int64) *models.SystemConfig {
|
||||
return repositories.SystemConfigRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *systemConfigService) Take(where ...interface{}) *models.SystemConfig {
|
||||
return repositories.SystemConfigRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *systemConfigService) Find(cnd *sqls.Cnd) []models.SystemConfig {
|
||||
return repositories.SystemConfigRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *systemConfigService) FindOne(cnd *sqls.Cnd) *models.SystemConfig {
|
||||
return repositories.SystemConfigRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *systemConfigService) FindPageByParams(params *params.QueryParams) (list []models.SystemConfig, paging *sqls.Paging) {
|
||||
return repositories.SystemConfigRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *systemConfigService) FindPageByCnd(cnd *sqls.Cnd) (list []models.SystemConfig, paging *sqls.Paging) {
|
||||
return repositories.SystemConfigRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *systemConfigService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.SystemConfigRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *systemConfigService) Create(t *models.SystemConfig) error {
|
||||
return repositories.SystemConfigRepository.Create(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *systemConfigService) Update(t *models.SystemConfig) error {
|
||||
return repositories.SystemConfigRepository.Update(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *systemConfigService) Updates(id int64, columns map[string]interface{}) error {
|
||||
return repositories.SystemConfigRepository.Updates(sqls.DB(), id, columns)
|
||||
}
|
||||
|
||||
func (s *systemConfigService) UpdateColumn(id int64, name string, value interface{}) error {
|
||||
return repositories.SystemConfigRepository.UpdateColumn(sqls.DB(), id, name, value)
|
||||
}
|
||||
|
||||
func (s *systemConfigService) Delete(id int64) {
|
||||
repositories.SystemConfigRepository.Delete(sqls.DB(), id)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
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 TagService = newTagService()
|
||||
|
||||
func newTagService() *tagService {
|
||||
return &tagService{}
|
||||
}
|
||||
|
||||
type tagService struct {
|
||||
}
|
||||
|
||||
func (s *tagService) Get(id int64) *models.Tag {
|
||||
return repositories.TagRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *tagService) Take(where ...interface{}) *models.Tag {
|
||||
return repositories.TagRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *tagService) Find(cnd *sqls.Cnd) []models.Tag {
|
||||
return repositories.TagRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *tagService) FindOne(cnd *sqls.Cnd) *models.Tag {
|
||||
return repositories.TagRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *tagService) FindPageByParams(params *params.QueryParams) (list []models.Tag, paging *sqls.Paging) {
|
||||
return repositories.TagRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *tagService) FindPageByCnd(cnd *sqls.Cnd) (list []models.Tag, paging *sqls.Paging) {
|
||||
return repositories.TagRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *tagService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.TagRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *tagService) Create(t *models.Tag) error {
|
||||
return repositories.TagRepository.Create(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *tagService) Update(t *models.Tag) error {
|
||||
return repositories.TagRepository.Update(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *tagService) Updates(id int64, columns map[string]interface{}) error {
|
||||
return repositories.TagRepository.Updates(sqls.DB(), id, columns)
|
||||
}
|
||||
|
||||
func (s *tagService) UpdateColumn(id int64, name string, value interface{}) error {
|
||||
return repositories.TagRepository.UpdateColumn(sqls.DB(), id, name, value)
|
||||
}
|
||||
|
||||
func (s *tagService) Delete(id int64) {
|
||||
repositories.TagRepository.Delete(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *tagService) GetChildren(parentID int64) []models.Tag {
|
||||
return s.Find(sqls.NewCnd().Eq("parent_id", parentID).Asc("sort_no").Asc("id"))
|
||||
}
|
||||
|
||||
func (s *tagService) HasChildren(parentID int64) bool {
|
||||
return s.Count(sqls.NewCnd().Eq("parent_id", parentID)) > 0
|
||||
}
|
||||
|
||||
func (s *tagService) FindByNameAndParentID(name string, parentID int64) *models.Tag {
|
||||
return s.FindOne(sqls.NewCnd().Eq("name", name).Eq("parent_id", parentID))
|
||||
}
|
||||
|
||||
func (s *tagService) CreateTag(req request.CreateTagRequest, operator *dto.AuthPrincipal) (*models.Tag, error) {
|
||||
if operator == nil {
|
||||
return nil, errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
return nil, errorsx.InvalidParam("标签名称不能为空")
|
||||
}
|
||||
|
||||
if req.ParentID > 0 {
|
||||
parent := s.Get(req.ParentID)
|
||||
if parent == nil {
|
||||
return nil, errorsx.InvalidParam("父标签不存在")
|
||||
}
|
||||
}
|
||||
|
||||
existing := s.FindByNameAndParentID(name, req.ParentID)
|
||||
if existing != nil {
|
||||
return nil, errorsx.InvalidParam("同级下已存在相同名称的标签")
|
||||
}
|
||||
|
||||
item := &models.Tag{
|
||||
ParentID: req.ParentID,
|
||||
Name: name,
|
||||
Remark: strings.TrimSpace(req.Remark),
|
||||
Status: enums.StatusOk,
|
||||
AuditFields: utils.BuildAuditFields(operator),
|
||||
}
|
||||
|
||||
item.SortNo = s.NextSortNo(req.ParentID)
|
||||
if err := s.Create(item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *tagService) NextSortNo(parentID int64) int {
|
||||
if temp := s.FindOne(sqls.NewCnd().Eq("parent_id", parentID).Desc("sort_no").Desc("id")); temp != nil {
|
||||
return temp.SortNo + 1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func (s *tagService) UpdateTag(req request.UpdateTagRequest, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
|
||||
item := s.Get(req.ID)
|
||||
if item == nil {
|
||||
return errorsx.InvalidParam("标签不存在")
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
return errorsx.InvalidParam("标签名称不能为空")
|
||||
}
|
||||
|
||||
if req.ParentID > 0 {
|
||||
if req.ParentID == req.ID {
|
||||
return errorsx.InvalidParam("不能将标签设为自己的子标签")
|
||||
}
|
||||
parent := s.Get(req.ParentID)
|
||||
if parent == nil {
|
||||
return errorsx.InvalidParam("父标签不存在")
|
||||
}
|
||||
}
|
||||
|
||||
existing := s.FindByNameAndParentID(name, req.ParentID)
|
||||
if existing != nil && existing.ID != req.ID {
|
||||
return errorsx.InvalidParam("同级下已存在相同名称的标签")
|
||||
}
|
||||
|
||||
return s.Updates(req.ID, map[string]any{
|
||||
"parent_id": req.ParentID,
|
||||
"name": name,
|
||||
"remark": strings.TrimSpace(req.Remark),
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *tagService) UpdateSort(ids []int64) error {
|
||||
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
for i, id := range ids {
|
||||
if err := repositories.TagRepository.UpdateColumn(ctx.Tx, id, "sort_no", i+1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *tagService) DeleteTag(id int64) error {
|
||||
item := s.Get(id)
|
||||
if item == nil {
|
||||
return errorsx.InvalidParam("标签不存在")
|
||||
}
|
||||
|
||||
if s.HasChildren(id) {
|
||||
return errorsx.InvalidParam("该标签下存在子标签,无法删除")
|
||||
}
|
||||
if ConversationTagService.Take("tag_id = ?", id) != nil {
|
||||
return errorsx.InvalidParam("该标签已关联会话,无法删除")
|
||||
}
|
||||
if TicketTagService.Take("tag_id = ?", id) != nil {
|
||||
return errorsx.InvalidParam("该标签已关联工单,无法删除")
|
||||
}
|
||||
|
||||
s.Delete(id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *tagService) FindAll() []models.Tag {
|
||||
return s.Find(sqls.NewCnd().Asc("sort_no").Asc("id"))
|
||||
}
|
||||
|
||||
func (s *tagService) GetSelfAndDescendantIDs(tagID int64) []int64 {
|
||||
if tagID <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
allTags := s.FindAll()
|
||||
if len(allTags) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
exists := false
|
||||
childrenMap := make(map[int64][]int64, len(allTags))
|
||||
for _, item := range allTags {
|
||||
if item.ID == tagID {
|
||||
exists = true
|
||||
}
|
||||
childrenMap[item.ParentID] = append(childrenMap[item.ParentID], item.ID)
|
||||
}
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make([]int64, 0, 8)
|
||||
visited := make(map[int64]bool, len(allTags))
|
||||
var walk func(id int64)
|
||||
walk = func(id int64) {
|
||||
if visited[id] {
|
||||
return
|
||||
}
|
||||
visited[id] = true
|
||||
result = append(result, id)
|
||||
for _, childID := range childrenMap[id] {
|
||||
walk(childID)
|
||||
}
|
||||
}
|
||||
walk(tagID)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *tagService) UpdateStatus(id int64, status int, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
|
||||
item := s.Get(id)
|
||||
if item == nil {
|
||||
return errorsx.InvalidParam("标签不存在")
|
||||
}
|
||||
|
||||
if status != int(enums.StatusOk) && status != int(enums.StatusDisabled) {
|
||||
return errorsx.InvalidParam("状态值不合法")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
return s.Updates(id, map[string]any{
|
||||
"status": status,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": now,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/repositories"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var TicketNoService = newTicketNoService()
|
||||
|
||||
func newTicketNoService() *ticketNoService {
|
||||
return &ticketNoService{}
|
||||
}
|
||||
|
||||
type ticketNoService struct{}
|
||||
|
||||
func (s *ticketNoService) Next(tx *gorm.DB, now time.Time) (string, error) {
|
||||
if tx == nil {
|
||||
return "", fmt.Errorf("ticket number transaction is required")
|
||||
}
|
||||
dateKey := now.Format("20060102")
|
||||
for attempt := 0; attempt < 20; attempt++ {
|
||||
current := repositories.TicketNoSequenceRepository.GetByDateKey(tx, dateKey)
|
||||
if current == nil {
|
||||
item := &models.TicketNoSequence{
|
||||
DateKey: dateKey,
|
||||
NextSeq: 2,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
err := repositories.TicketNoSequenceRepository.Create(tx, item)
|
||||
if err == nil {
|
||||
return formatTicketNo(dateKey, 1), nil
|
||||
}
|
||||
if !isRetriableTicketNoError(err) {
|
||||
return "", err
|
||||
}
|
||||
time.Sleep(time.Duration(attempt+1) * 10 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
allocated := current.NextSeq
|
||||
ok, err := repositories.TicketNoSequenceRepository.UpdateNextSeq(tx, current.ID, current.NextSeq, current.NextSeq+1, now)
|
||||
if err != nil {
|
||||
if isRetriableTicketNoError(err) {
|
||||
time.Sleep(time.Duration(attempt+1) * 10 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if ok {
|
||||
return formatTicketNo(dateKey, allocated), nil
|
||||
}
|
||||
time.Sleep(time.Duration(attempt+1) * 10 * time.Millisecond)
|
||||
}
|
||||
return "", fmt.Errorf("failed to allocate ticket number")
|
||||
}
|
||||
|
||||
func formatTicketNo(dateKey string, seq int64) string {
|
||||
return fmt.Sprintf("TK%s%05d", dateKey, seq)
|
||||
}
|
||||
|
||||
func isDuplicateKeyError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
message := strings.ToLower(err.Error())
|
||||
return strings.Contains(message, "duplicate") || strings.Contains(message, "unique") || strings.Contains(message, "constraint failed")
|
||||
}
|
||||
|
||||
func isRetriableTicketNoError(err error) bool {
|
||||
return isDuplicateKeyError(err) || isDatabaseLockedError(err)
|
||||
}
|
||||
|
||||
func isDatabaseLockedError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
message := strings.ToLower(err.Error())
|
||||
return strings.Contains(message, "database is locked") || strings.Contains(message, "database table is locked")
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,851 @@
|
||||
package services_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cs-agent/internal/bootstrap"
|
||||
"cs-agent/internal/builders"
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/config"
|
||||
"cs-agent/internal/pkg/dto"
|
||||
"cs-agent/internal/pkg/dto/request"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"cs-agent/internal/repositories"
|
||||
"cs-agent/internal/services"
|
||||
|
||||
"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)
|
||||
}
|
||||
second, err := services.TicketService.CreateTicket(createTestTicketRequest("ticket-2"), operator)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTicket() second error = %v", err)
|
||||
}
|
||||
|
||||
if first.TicketNo == "" || second.TicketNo == "" {
|
||||
t.Fatalf("expected ticket numbers to be generated, got %q and %q", first.TicketNo, second.TicketNo)
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddInternalNoteAllowsMentionSameUserAcrossTickets(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 TestBatchChangeStatusRollsBackOnFailure(t *testing.T) {
|
||||
setupTicketTestDB(t)
|
||||
operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"}
|
||||
|
||||
first, err := services.TicketService.CreateTicket(createTestTicketRequest("batch-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{
|
||||
TicketID: ticket.ID,
|
||||
ToTeamID: teamID,
|
||||
ToUserID: assigneeID,
|
||||
Reason: "manual assign",
|
||||
}, operator); err != nil {
|
||||
t.Fatalf("AssignTicket() error = %v", err)
|
||||
}
|
||||
|
||||
current := services.TicketService.Get(ticket.ID)
|
||||
if current == 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 current.CurrentTeamID != teamID {
|
||||
t.Fatalf("expected current team id %d, got %d", teamID, current.CurrentTeamID)
|
||||
}
|
||||
if current.CurrentAssigneeID != assigneeID {
|
||||
t.Fatalf("expected current assignee id %d, got %d", assigneeID, current.CurrentAssigneeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindPageAggregateByCndBuildsWatcherAndLookupMaps(t *testing.T) {
|
||||
setupTicketTestDB(t)
|
||||
operator := &dto.AuthPrincipal{UserID: 1, Username: "admin"}
|
||||
teamID, assigneeID := createTestAgentProfile(t, "aggregate-agent")
|
||||
customerID := createTestCustomer(t, "aggregate-customer")
|
||||
tagID := createTestTag(t, "aggregate-tag")
|
||||
|
||||
ticket, err := services.TicketService.CreateTicket(request.CreateTicketRequest{
|
||||
Title: "aggregate-ticket",
|
||||
CustomerID: customerID,
|
||||
TagIDs: []int64{tagID},
|
||||
Priority: 3,
|
||||
Severity: int(enums.TicketSeverityMajor),
|
||||
CurrentTeamID: teamID,
|
||||
CurrentAssigneeID: assigneeID,
|
||||
}, 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,
|
||||
)
|
||||
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 {
|
||||
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) {
|
||||
setupTicketTestDB(t)
|
||||
|
||||
const count = 20
|
||||
results := make(chan string, count)
|
||||
errs := make(chan error, count)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < count; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
ticketNo, err := services.TicketNoService.Next(ctx.Tx, time.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
results <- ticketNo
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
errs <- err
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(results)
|
||||
close(errs)
|
||||
|
||||
for err := range errs {
|
||||
if err != nil {
|
||||
t.Fatalf("TicketNoService.Next() concurrent error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, count)
|
||||
for ticketNo := range results {
|
||||
if _, ok := seen[ticketNo]; ok {
|
||||
t.Fatalf("duplicate ticket number generated: %s", ticketNo)
|
||||
}
|
||||
seen[ticketNo] = struct{}{}
|
||||
}
|
||||
if len(seen) != count {
|
||||
t.Fatalf("expected %d unique ticket numbers, got %d", count, len(seen))
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "ticket-test.db")
|
||||
db, err := bootstrap.InitDB(config.DBConfig{
|
||||
Type: "sqlite",
|
||||
DSN: "file:" + dbPath + "?_busy_timeout=5000",
|
||||
MaxIdleConns: 1,
|
||||
MaxOpenConns: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("InitDB() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
sqlDB, err := db.DB()
|
||||
if err == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
if err := bootstrap.InitMigrations(); err != nil {
|
||||
t.Fatalf("InitMigrations() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func createTestTicketRequest(title string) request.CreateTicketRequest {
|
||||
return request.CreateTicketRequest{
|
||||
Title: title,
|
||||
Priority: 1,
|
||||
Severity: int(enums.TicketSeverityMinor),
|
||||
}
|
||||
}
|
||||
|
||||
func requestInternalNote(ticketID int64, payload string) request.InternalNoteRequest {
|
||||
return request.InternalNoteRequest{
|
||||
TicketID: ticketID,
|
||||
ContentType: "text",
|
||||
Content: "note",
|
||||
Payload: payload,
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
AuditFields: models.AuditFields{
|
||||
CreatedAt: now,
|
||||
CreateUserID: 1,
|
||||
CreateUserName: "admin",
|
||||
UpdatedAt: now,
|
||||
UpdateUserID: 1,
|
||||
UpdateUserName: "admin",
|
||||
},
|
||||
}
|
||||
if err := repositories.UserRepository.Create(sqls.DB(), user); err != nil {
|
||||
t.Fatalf("create user error = %v", err)
|
||||
}
|
||||
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()
|
||||
|
||||
now := time.Now()
|
||||
item := &models.Customer{
|
||||
Name: fmt.Sprintf("%s-%d", prefix, now.UnixNano()),
|
||||
Status: enums.StatusOk,
|
||||
AuditFields: models.AuditFields{
|
||||
CreatedAt: now,
|
||||
CreateUserID: 1,
|
||||
CreateUserName: "admin",
|
||||
UpdatedAt: now,
|
||||
UpdateUserID: 1,
|
||||
UpdateUserName: "admin",
|
||||
},
|
||||
}
|
||||
if err := repositories.CustomerRepository.Create(sqls.DB(), item); err != nil {
|
||||
t.Fatalf("create customer error = %v", err)
|
||||
}
|
||||
return item.ID
|
||||
}
|
||||
|
||||
func createTestTag(t *testing.T, prefix string) int64 {
|
||||
t.Helper()
|
||||
|
||||
now := time.Now()
|
||||
item := &models.Tag{
|
||||
Name: fmt.Sprintf("%s-%d", prefix, now.UnixNano()),
|
||||
Status: enums.StatusOk,
|
||||
SortNo: 1,
|
||||
AuditFields: models.AuditFields{
|
||||
CreatedAt: now,
|
||||
CreateUserID: 1,
|
||||
CreateUserName: "admin",
|
||||
UpdatedAt: now,
|
||||
UpdateUserID: 1,
|
||||
UpdateUserName: "admin",
|
||||
},
|
||||
}
|
||||
if err := repositories.TagRepository.Create(sqls.DB(), item); err != nil {
|
||||
t.Fatalf("create tag error = %v", err)
|
||||
}
|
||||
return item.ID
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
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"
|
||||
"time"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var TicketTagService = newTicketTagService()
|
||||
|
||||
func newTicketTagService() *ticketTagService {
|
||||
return &ticketTagService{}
|
||||
}
|
||||
|
||||
type ticketTagService struct{}
|
||||
|
||||
func (s *ticketTagService) Get(id int64) *models.TicketTag {
|
||||
return repositories.TicketTagRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *ticketTagService) Take(where ...interface{}) *models.TicketTag {
|
||||
return repositories.TicketTagRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *ticketTagService) Find(cnd *sqls.Cnd) []models.TicketTag {
|
||||
return repositories.TicketTagRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *ticketTagService) Create(db *gorm.DB, item *models.TicketTag) error {
|
||||
return repositories.TicketTagRepository.Create(db, item)
|
||||
}
|
||||
|
||||
func (s *ticketTagService) DeleteByTicketID(db *gorm.DB, ticketID int64) error {
|
||||
return repositories.TicketTagRepository.DeleteByTicketID(db, ticketID)
|
||||
}
|
||||
|
||||
func (s *ticketTagService) NormalizeTagIDs(tagIDs []int64) []int64 {
|
||||
if len(tagIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[int64]struct{}, len(tagIDs))
|
||||
result := make([]int64, 0, len(tagIDs))
|
||||
for _, tagID := range tagIDs {
|
||||
if tagID <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[tagID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[tagID] = struct{}{}
|
||||
result = append(result, tagID)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *ticketTagService) ValidateTagIDs(tagIDs []int64) ([]int64, error) {
|
||||
normalized := s.NormalizeTagIDs(tagIDs)
|
||||
if len(normalized) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
tags := repositories.TagRepository.Find(sqls.DB(), sqls.NewCnd().In("id", normalized))
|
||||
if len(tags) != len(normalized) {
|
||||
return nil, errorsx.InvalidParam("存在无效工单标签")
|
||||
}
|
||||
for i := range tags {
|
||||
if tags[i].Status != enums.StatusOk {
|
||||
return nil, errorsx.InvalidParam("存在未启用的工单标签")
|
||||
}
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func (s *ticketTagService) ReplaceTicketTags(db *gorm.DB, ticketID int64, tagIDs []int64, operator *dto.AuthPrincipal) error {
|
||||
if err := s.DeleteByTicketID(db, ticketID); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(tagIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
for _, tagID := range tagIDs {
|
||||
if err := s.Create(db, &models.TicketTag{
|
||||
TicketID: ticketID,
|
||||
TagID: tagID,
|
||||
AuditFields: models.AuditFields{
|
||||
CreatedAt: now,
|
||||
CreateUserID: operator.UserID,
|
||||
CreateUserName: operator.Username,
|
||||
UpdatedAt: now,
|
||||
UpdateUserID: operator.UserID,
|
||||
UpdateUserName: operator.Username,
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/dto"
|
||||
"cs-agent/internal/pkg/dto/request"
|
||||
"cs-agent/internal/pkg/errorsx"
|
||||
"cs-agent/internal/pkg/utils"
|
||||
"cs-agent/internal/repositories"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
var TicketViewService = newTicketViewService()
|
||||
|
||||
func newTicketViewService() *ticketViewService {
|
||||
return &ticketViewService{}
|
||||
}
|
||||
|
||||
type ticketViewService struct {
|
||||
}
|
||||
|
||||
func (s *ticketViewService) Get(id int64) *models.TicketView {
|
||||
return repositories.TicketViewRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *ticketViewService) Find(cnd *sqls.Cnd) []models.TicketView {
|
||||
return repositories.TicketViewRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *ticketViewService) ListByUser(userID int64) []models.TicketView {
|
||||
if userID <= 0 {
|
||||
return nil
|
||||
}
|
||||
return s.Find(sqls.NewCnd().Eq("user_id", userID).Asc("sort_no").Desc("id"))
|
||||
}
|
||||
|
||||
func (s *ticketViewService) Save(req request.SaveTicketViewRequest, operator *dto.AuthPrincipal) (*models.TicketView, error) {
|
||||
if operator == nil {
|
||||
return nil, errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
return nil, errorsx.InvalidParam("视图名称不能为空")
|
||||
}
|
||||
filtersJSON, err := json.Marshal(req.Filters)
|
||||
if err != nil {
|
||||
return nil, errorsx.InvalidParam("视图筛选条件格式不正确")
|
||||
}
|
||||
now := time.Now()
|
||||
if req.ID > 0 {
|
||||
item := repositories.TicketViewRepository.Get(sqls.DB(), req.ID)
|
||||
if item == nil || item.UserID != operator.UserID {
|
||||
return nil, errorsx.InvalidParam("视图不存在")
|
||||
}
|
||||
if err := repositories.TicketViewRepository.Updates(sqls.DB(), req.ID, map[string]interface{}{
|
||||
"name": name,
|
||||
"filters_json": string(filtersJSON),
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": now,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return repositories.TicketViewRepository.Get(sqls.DB(), req.ID), nil
|
||||
}
|
||||
item := &models.TicketView{
|
||||
UserID: operator.UserID,
|
||||
Name: name,
|
||||
FiltersJSON: string(filtersJSON),
|
||||
AuditFields: utils.BuildAuditFields(operator),
|
||||
}
|
||||
if err := repositories.TicketViewRepository.Create(sqls.DB(), item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *ticketViewService) Delete(id int64, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
item := repositories.TicketViewRepository.Get(sqls.DB(), id)
|
||||
if item == nil || item.UserID != operator.UserID {
|
||||
return errorsx.InvalidParam("视图不存在")
|
||||
}
|
||||
repositories.TicketViewRepository.Delete(sqls.DB(), id)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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 UserIdentityService = newUserIdentityService()
|
||||
|
||||
func newUserIdentityService() *userIdentityService {
|
||||
return &userIdentityService{}
|
||||
}
|
||||
|
||||
type userIdentityService struct {
|
||||
}
|
||||
|
||||
func (s *userIdentityService) Get(id int64) *models.UserIdentity {
|
||||
return repositories.UserIdentityRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *userIdentityService) Take(where ...interface{}) *models.UserIdentity {
|
||||
return repositories.UserIdentityRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *userIdentityService) Find(cnd *sqls.Cnd) []models.UserIdentity {
|
||||
return repositories.UserIdentityRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *userIdentityService) FindOne(cnd *sqls.Cnd) *models.UserIdentity {
|
||||
return repositories.UserIdentityRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *userIdentityService) FindPageByParams(params *params.QueryParams) (list []models.UserIdentity, paging *sqls.Paging) {
|
||||
return repositories.UserIdentityRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *userIdentityService) FindPageByCnd(cnd *sqls.Cnd) (list []models.UserIdentity, paging *sqls.Paging) {
|
||||
return repositories.UserIdentityRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *userIdentityService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.UserIdentityRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *userIdentityService) Create(t *models.UserIdentity) error {
|
||||
return repositories.UserIdentityRepository.Create(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *userIdentityService) Update(t *models.UserIdentity) error {
|
||||
return repositories.UserIdentityRepository.Update(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *userIdentityService) Updates(id int64, columns map[string]interface{}) error {
|
||||
return repositories.UserIdentityRepository.Updates(sqls.DB(), id, columns)
|
||||
}
|
||||
|
||||
func (s *userIdentityService) UpdateColumn(id int64, name string, value interface{}) error {
|
||||
return repositories.UserIdentityRepository.UpdateColumn(sqls.DB(), id, name, value)
|
||||
}
|
||||
|
||||
func (s *userIdentityService) Delete(id int64) {
|
||||
repositories.UserIdentityRepository.Delete(sqls.DB(), id)
|
||||
}
|
||||
@@ -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 UserPermissionService = newUserPermissionService()
|
||||
|
||||
func newUserPermissionService() *userPermissionService {
|
||||
return &userPermissionService{}
|
||||
}
|
||||
|
||||
type userPermissionService struct {
|
||||
}
|
||||
|
||||
func (s *userPermissionService) Get(id int64) *models.UserPermission {
|
||||
return repositories.UserPermissionRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *userPermissionService) Take(where ...interface{}) *models.UserPermission {
|
||||
return repositories.UserPermissionRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *userPermissionService) Find(cnd *sqls.Cnd) []models.UserPermission {
|
||||
return repositories.UserPermissionRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *userPermissionService) FindOne(cnd *sqls.Cnd) *models.UserPermission {
|
||||
return repositories.UserPermissionRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *userPermissionService) FindPageByParams(params *params.QueryParams) (list []models.UserPermission, paging *sqls.Paging) {
|
||||
return repositories.UserPermissionRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *userPermissionService) FindPageByCnd(cnd *sqls.Cnd) (list []models.UserPermission, paging *sqls.Paging) {
|
||||
return repositories.UserPermissionRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *userPermissionService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.UserPermissionRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *userPermissionService) Create(t *models.UserPermission) error {
|
||||
return repositories.UserPermissionRepository.Create(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *userPermissionService) Update(t *models.UserPermission) error {
|
||||
return repositories.UserPermissionRepository.Update(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *userPermissionService) Updates(id int64, columns map[string]interface{}) error {
|
||||
return repositories.UserPermissionRepository.Updates(sqls.DB(), id, columns)
|
||||
}
|
||||
|
||||
func (s *userPermissionService) UpdateColumn(id int64, name string, value interface{}) error {
|
||||
return repositories.UserPermissionRepository.UpdateColumn(sqls.DB(), id, name, value)
|
||||
}
|
||||
|
||||
func (s *userPermissionService) Delete(id int64) {
|
||||
repositories.UserPermissionRepository.Delete(sqls.DB(), id)
|
||||
}
|
||||
@@ -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 UserRoleService = newUserRoleService()
|
||||
|
||||
func newUserRoleService() *userRoleService {
|
||||
return &userRoleService{}
|
||||
}
|
||||
|
||||
type userRoleService struct {
|
||||
}
|
||||
|
||||
func (s *userRoleService) Get(id int64) *models.UserRole {
|
||||
return repositories.UserRoleRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *userRoleService) Take(where ...interface{}) *models.UserRole {
|
||||
return repositories.UserRoleRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *userRoleService) Find(cnd *sqls.Cnd) []models.UserRole {
|
||||
return repositories.UserRoleRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *userRoleService) FindOne(cnd *sqls.Cnd) *models.UserRole {
|
||||
return repositories.UserRoleRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *userRoleService) FindPageByParams(params *params.QueryParams) (list []models.UserRole, paging *sqls.Paging) {
|
||||
return repositories.UserRoleRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *userRoleService) FindPageByCnd(cnd *sqls.Cnd) (list []models.UserRole, paging *sqls.Paging) {
|
||||
return repositories.UserRoleRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *userRoleService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.UserRoleRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *userRoleService) Create(t *models.UserRole) error {
|
||||
return repositories.UserRoleRepository.Create(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *userRoleService) Update(t *models.UserRole) error {
|
||||
return repositories.UserRoleRepository.Update(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *userRoleService) Updates(id int64, columns map[string]interface{}) error {
|
||||
return repositories.UserRoleRepository.Updates(sqls.DB(), id, columns)
|
||||
}
|
||||
|
||||
func (s *userRoleService) UpdateColumn(id int64, name string, value interface{}) error {
|
||||
return repositories.UserRoleRepository.UpdateColumn(sqls.DB(), id, name, value)
|
||||
}
|
||||
|
||||
func (s *userRoleService) Delete(id int64) {
|
||||
repositories.UserRoleRepository.Delete(sqls.DB(), id)
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
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"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"github.com/mlogclub/simple/web/params"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var UserService = newUserService()
|
||||
|
||||
func newUserService() *userService {
|
||||
return &userService{}
|
||||
}
|
||||
|
||||
type userService struct {
|
||||
}
|
||||
|
||||
func (s *userService) Get(id int64) *models.User {
|
||||
return repositories.UserRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *userService) Take(where ...interface{}) *models.User {
|
||||
return repositories.UserRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *userService) Find(cnd *sqls.Cnd) []models.User {
|
||||
return repositories.UserRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *userService) FindOne(cnd *sqls.Cnd) *models.User {
|
||||
return repositories.UserRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *userService) FindPageByParams(params *params.QueryParams) (list []models.User, paging *sqls.Paging) {
|
||||
return repositories.UserRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *userService) FindPageByCnd(cnd *sqls.Cnd) (list []models.User, paging *sqls.Paging) {
|
||||
return repositories.UserRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *userService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.UserRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *userService) FindByIds(ids []int64) []models.User {
|
||||
return repositories.UserRepository.FindByIds(sqls.DB(), ids)
|
||||
}
|
||||
|
||||
func (s *userService) Create(t *models.User) error {
|
||||
return repositories.UserRepository.Create(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *userService) Update(t *models.User) error {
|
||||
return repositories.UserRepository.Update(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *userService) Updates(id int64, columns map[string]interface{}) error {
|
||||
return repositories.UserRepository.Updates(sqls.DB(), id, columns)
|
||||
}
|
||||
|
||||
func (s *userService) UpdateColumn(id int64, name string, value interface{}) error {
|
||||
return repositories.UserRepository.UpdateColumn(sqls.DB(), id, name, value)
|
||||
}
|
||||
|
||||
func (s *userService) GetByUsername(username string) *models.User {
|
||||
return repositories.UserRepository.GetByUsername(sqls.DB(), username)
|
||||
}
|
||||
|
||||
func (s *userService) GetByMobile(mobile string) *models.User {
|
||||
return repositories.UserRepository.GetByMobile(sqls.DB(), mobile)
|
||||
}
|
||||
|
||||
func (s *userService) GetByEmail(email string) *models.User {
|
||||
return repositories.UserRepository.GetByEmail(sqls.DB(), email)
|
||||
}
|
||||
|
||||
func (s *userService) CreateUser(req request.CreateUserRequest, operator *dto.AuthPrincipal) (*models.User, string, error) {
|
||||
username := strings.TrimSpace(req.Username)
|
||||
if username == "" {
|
||||
return nil, "", errorsx.InvalidParam("用户名不能为空")
|
||||
}
|
||||
if s.GetByUsername(username) != nil {
|
||||
return nil, "", errorsx.InvalidParam("用户名已存在")
|
||||
}
|
||||
|
||||
mobile := utils.NormalizeNullableString(req.Mobile)
|
||||
email := utils.NormalizeNullableString(req.Email)
|
||||
if mobile != nil && s.GetByMobile(*mobile) != nil {
|
||||
return nil, "", errorsx.InvalidParam("手机号已存在")
|
||||
}
|
||||
if email != nil && s.GetByEmail(*email) != nil {
|
||||
return nil, "", errorsx.InvalidParam("邮箱已存在")
|
||||
}
|
||||
|
||||
plain, err := utils.GenerateRandomPassword(12)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
user := &models.User{
|
||||
Username: username,
|
||||
Nickname: strings.TrimSpace(req.Nickname),
|
||||
Password: string(passwordHash),
|
||||
Avatar: strings.TrimSpace(req.Avatar),
|
||||
Mobile: mobile,
|
||||
Email: email,
|
||||
Status: enums.StatusOk,
|
||||
Remark: strings.TrimSpace(req.Remark),
|
||||
PasswordSalt: "",
|
||||
AuditFields: utils.BuildAuditFields(operator),
|
||||
}
|
||||
if user.Nickname == "" {
|
||||
user.Nickname = username
|
||||
}
|
||||
|
||||
err = sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
if err := repositories.UserRepository.Create(ctx.Tx, user); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.replaceUserRolesDB(ctx.Tx, user.ID, req.RoleIDs, operator)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return user, plain, nil
|
||||
}
|
||||
|
||||
func (s *userService) UpdateUser(req request.UpdateUserRequest, operator *dto.AuthPrincipal) error {
|
||||
user := s.Get(req.ID)
|
||||
if user == nil || user.DeletedAt != nil {
|
||||
return errorsx.InvalidParam("用户不存在")
|
||||
}
|
||||
|
||||
mobile := utils.NormalizeNullableString(req.Mobile)
|
||||
email := utils.NormalizeNullableString(req.Email)
|
||||
if mobile != nil {
|
||||
if existed := s.GetByMobile(*mobile); existed != nil && existed.ID != req.ID {
|
||||
return errorsx.InvalidParam("手机号已存在")
|
||||
}
|
||||
}
|
||||
if email != nil {
|
||||
if existed := s.GetByEmail(*email); existed != nil && existed.ID != req.ID {
|
||||
return errorsx.InvalidParam("邮箱已存在")
|
||||
}
|
||||
}
|
||||
|
||||
return s.Updates(req.ID, map[string]any{
|
||||
"nickname": strings.TrimSpace(req.Nickname),
|
||||
"avatar": strings.TrimSpace(req.Avatar),
|
||||
"mobile": mobile,
|
||||
"email": email,
|
||||
"remark": strings.TrimSpace(req.Remark),
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *userService) DeleteUser(id int64, operator *dto.AuthPrincipal) error {
|
||||
user := s.Get(id)
|
||||
if user == nil {
|
||||
return errorsx.InvalidParam("用户不存在")
|
||||
}
|
||||
|
||||
if err := s.Updates(id, map[string]any{
|
||||
"status": enums.StatusDisabled,
|
||||
"deleted_at": time.Now(),
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return LoginSessionService.RevokeByUser(id, operator.UserID, operator.Username)
|
||||
}
|
||||
|
||||
func (s *userService) UpdateStatus(id int64, status int, operator *dto.AuthPrincipal) error {
|
||||
user := s.Get(id)
|
||||
if user == nil {
|
||||
return errorsx.InvalidParam("用户不存在")
|
||||
}
|
||||
if !slices.Contains(enums.StatusValues, enums.Status(status)) {
|
||||
return errorsx.InvalidParam("状态值不合法")
|
||||
}
|
||||
if err := s.Updates(id, map[string]any{
|
||||
"status": status,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if status == int(enums.StatusDisabled) || status == int(enums.StatusDeleted) {
|
||||
return LoginSessionService.RevokeByUser(id, operator.UserID, operator.Username)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *userService) ResetPassword(userID int64, operator *dto.AuthPrincipal) (string, error) {
|
||||
password, err := utils.GenerateRandomPassword(12)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err = s.changePassword(userID, password, operator); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return password, nil
|
||||
}
|
||||
|
||||
func (s *userService) ChangeOwnPassword(password string, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil || operator.UserID <= 0 {
|
||||
return errorsx.Unauthorized("未登录或登录已过期")
|
||||
}
|
||||
return s.changePassword(operator.UserID, password, operator)
|
||||
}
|
||||
|
||||
func (s *userService) AssignRoles(userID int64, roleIDs []int64, operator *dto.AuthPrincipal) error {
|
||||
user := s.Get(userID)
|
||||
if user == nil || user.DeletedAt != nil {
|
||||
return errorsx.InvalidParam("用户不存在")
|
||||
}
|
||||
if err := s.replaceUserRoles(userID, roleIDs, operator); err != nil {
|
||||
return err
|
||||
}
|
||||
return LoginSessionService.RevokeByUser(userID, operator.UserID, operator.Username)
|
||||
}
|
||||
|
||||
func (s *userService) replaceUserRoles(userID int64, roleIDs []int64, operator *dto.AuthPrincipal) error {
|
||||
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
return s.replaceUserRolesDB(ctx.Tx, userID, roleIDs, operator)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *userService) replaceUserRolesDB(db *gorm.DB, userID int64, roleIDs []int64, operator *dto.AuthPrincipal) error {
|
||||
if err := db.Where("user_id = ?", userID).Delete(&models.UserRole{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, roleID := range roleIDs {
|
||||
role := RoleService.Get(roleID)
|
||||
if role == nil {
|
||||
return errorsx.InvalidParam("角色不存在")
|
||||
}
|
||||
if role.Status != enums.StatusOk {
|
||||
return errorsx.InvalidParam("禁用角色不允许分配")
|
||||
}
|
||||
relation := &models.UserRole{
|
||||
UserID: userID,
|
||||
RoleID: roleID,
|
||||
AuditFields: utils.BuildAuditFields(operator),
|
||||
}
|
||||
if err := db.Create(relation).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *userService) changePassword(userID int64, password string, operator *dto.AuthPrincipal) error {
|
||||
user := s.Get(userID)
|
||||
if user == nil || user.DeletedAt != nil {
|
||||
return errorsx.InvalidParam("用户不存在")
|
||||
}
|
||||
if strings.TrimSpace(password) == "" {
|
||||
return errorsx.InvalidParam("新密码不能为空")
|
||||
}
|
||||
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
if err = s.Updates(userID, map[string]any{
|
||||
"password": string(passwordHash),
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": now,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return LoginSessionService.RevokeByUser(userID, operator.UserID, operator.Username)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package services
|
||||
|
||||
import "sync"
|
||||
|
||||
type WsConnectionManager struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*ClientSession
|
||||
topics map[string]map[string]*ClientSession
|
||||
}
|
||||
|
||||
func newWsConnectionManager() *WsConnectionManager {
|
||||
return &WsConnectionManager{
|
||||
sessions: make(map[string]*ClientSession),
|
||||
topics: make(map[string]map[string]*ClientSession),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *WsConnectionManager) Register(session *ClientSession, defaultTopics []string) int {
|
||||
if session == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.sessions[session.ID] = session
|
||||
for _, topic := range defaultTopics {
|
||||
m.subscribeLocked(session, topic)
|
||||
}
|
||||
return len(m.sessions)
|
||||
}
|
||||
|
||||
func (m *WsConnectionManager) Unregister(session *ClientSession) int {
|
||||
if session == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
delete(m.sessions, session.ID)
|
||||
for topic := range session.Topics {
|
||||
m.unsubscribeLocked(session, topic)
|
||||
}
|
||||
return len(m.sessions)
|
||||
}
|
||||
|
||||
func (m *WsConnectionManager) Subscribe(session *ClientSession, topics []string) []string {
|
||||
if session == nil || len(topics) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
ret := make([]string, 0, len(topics))
|
||||
for _, topic := range topics {
|
||||
if _, exists := session.Topics[topic]; exists {
|
||||
continue
|
||||
}
|
||||
m.subscribeLocked(session, topic)
|
||||
ret = append(ret, topic)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func (m *WsConnectionManager) Unsubscribe(session *ClientSession, topics []string, keep map[string]struct{}) []string {
|
||||
if session == nil || len(topics) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
ret := make([]string, 0, len(topics))
|
||||
for _, topic := range topics {
|
||||
if _, isDefault := keep[topic]; isDefault {
|
||||
continue
|
||||
}
|
||||
if _, exists := session.Topics[topic]; !exists {
|
||||
continue
|
||||
}
|
||||
m.unsubscribeLocked(session, topic)
|
||||
ret = append(ret, topic)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func (m *WsConnectionManager) FindByTopics(topics []string) []*ClientSession {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
uniq := make(map[string]*ClientSession)
|
||||
for _, topic := range topics {
|
||||
for connID, session := range m.topics[topic] {
|
||||
uniq[connID] = session
|
||||
}
|
||||
}
|
||||
|
||||
ret := make([]*ClientSession, 0, len(uniq))
|
||||
for _, session := range uniq {
|
||||
ret = append(ret, session)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func (m *WsConnectionManager) subscribeLocked(session *ClientSession, topic string) {
|
||||
if _, exists := session.Topics[topic]; exists {
|
||||
return
|
||||
}
|
||||
if m.topics[topic] == nil {
|
||||
m.topics[topic] = make(map[string]*ClientSession)
|
||||
}
|
||||
m.topics[topic][session.ID] = session
|
||||
session.Topics[topic] = struct{}{}
|
||||
}
|
||||
|
||||
func (m *WsConnectionManager) unsubscribeLocked(session *ClientSession, topic string) {
|
||||
if sessions, ok := m.topics[topic]; ok {
|
||||
delete(sessions, session.ID)
|
||||
if len(sessions) == 0 {
|
||||
delete(m.topics, topic)
|
||||
}
|
||||
}
|
||||
delete(session.Topics, topic)
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"cs-agent/internal/pkg/dto"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"cs-agent/internal/pkg/openidentity"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
const (
|
||||
realtimeWriteWait = 10 * time.Second
|
||||
realtimePongWait = 60 * time.Second
|
||||
realtimePingPeriod = 30 * time.Second
|
||||
realtimeMaxMessageSize = 8 << 10
|
||||
realtimeSendBufferSize = 64
|
||||
)
|
||||
|
||||
const (
|
||||
realtimeRoleUser = "user"
|
||||
realtimeRoleAdmin = "admin"
|
||||
)
|
||||
|
||||
const (
|
||||
realtimeTopicUserPrefix = "user:"
|
||||
realtimeTopicVisitorPrefix = "visitor:"
|
||||
realtimeTopicAdminPrefix = "admin:"
|
||||
realtimeTopicConversationPrefix = "conversation:"
|
||||
realtimeTopicAdminAll = "admin:all"
|
||||
)
|
||||
|
||||
type RealtimeEvent struct {
|
||||
EventID string `json:"eventId"`
|
||||
Type string `json:"type"`
|
||||
Topic string `json:"topic,omitempty"`
|
||||
Data RealtimeEventPayload `json:"data,omitempty"`
|
||||
At string `json:"at"`
|
||||
}
|
||||
|
||||
type RealtimeDomainEvent interface {
|
||||
EventType() string
|
||||
EventPayload() RealtimeEventPayload
|
||||
}
|
||||
|
||||
type RealtimeEventPayload interface {
|
||||
realtimeEventPayload()
|
||||
}
|
||||
|
||||
type RealtimeConnectedPayload struct {
|
||||
ConnID string `json:"connId,omitempty"`
|
||||
UserID int64 `json:"userId,omitempty"`
|
||||
VisitorID string `json:"visitorId,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
TerminalType string `json:"terminalType,omitempty"`
|
||||
Topics []string `json:"topics,omitempty"`
|
||||
}
|
||||
|
||||
func (RealtimeConnectedPayload) realtimeEventPayload() {}
|
||||
|
||||
type RealtimeConnectedEvent struct {
|
||||
Payload RealtimeConnectedPayload
|
||||
}
|
||||
|
||||
func (e RealtimeConnectedEvent) EventType() string {
|
||||
return enums.IMRealtimeEventConnected
|
||||
}
|
||||
|
||||
func (e RealtimeConnectedEvent) EventPayload() RealtimeEventPayload {
|
||||
return e.Payload
|
||||
}
|
||||
|
||||
type RealtimeTopicsPayload struct {
|
||||
Topics []string `json:"topics,omitempty"`
|
||||
}
|
||||
|
||||
func (RealtimeTopicsPayload) realtimeEventPayload() {}
|
||||
|
||||
type RealtimeSubscribedEvent struct {
|
||||
Payload RealtimeTopicsPayload
|
||||
}
|
||||
|
||||
func (e RealtimeSubscribedEvent) EventType() string {
|
||||
return enums.IMRealtimeEventSubscribed
|
||||
}
|
||||
|
||||
func (e RealtimeSubscribedEvent) EventPayload() RealtimeEventPayload {
|
||||
return e.Payload
|
||||
}
|
||||
|
||||
type RealtimeUnsubscribedEvent struct {
|
||||
Payload RealtimeTopicsPayload
|
||||
}
|
||||
|
||||
func (e RealtimeUnsubscribedEvent) EventType() string {
|
||||
return enums.IMRealtimeEventUnsubscribed
|
||||
}
|
||||
|
||||
func (e RealtimeUnsubscribedEvent) EventPayload() RealtimeEventPayload {
|
||||
return e.Payload
|
||||
}
|
||||
|
||||
type RealtimePongEvent struct{}
|
||||
|
||||
func (RealtimePongEvent) EventType() string {
|
||||
return enums.IMRealtimeEventPong
|
||||
}
|
||||
|
||||
func (RealtimePongEvent) EventPayload() RealtimeEventPayload {
|
||||
return nil
|
||||
}
|
||||
|
||||
type RealtimeResyncRequiredPayload struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
func (RealtimeResyncRequiredPayload) realtimeEventPayload() {}
|
||||
|
||||
type RealtimeResyncRequiredEvent struct {
|
||||
Payload RealtimeResyncRequiredPayload
|
||||
}
|
||||
|
||||
func (e RealtimeResyncRequiredEvent) EventType() string {
|
||||
return enums.IMRealtimeEventResyncRequired
|
||||
}
|
||||
|
||||
func (e RealtimeResyncRequiredEvent) EventPayload() RealtimeEventPayload {
|
||||
return e.Payload
|
||||
}
|
||||
|
||||
type RealtimeMessageCreatedPayload struct {
|
||||
ConversationID int64 `json:"conversationId,omitempty"`
|
||||
MessageID int64 `json:"messageId,omitempty"`
|
||||
Status enums.IMConversationStatus `json:"status,omitempty"`
|
||||
CurrentAssigneeID int64 `json:"currentAssigneeId,omitempty"`
|
||||
SenderType enums.IMSenderType `json:"senderType,omitempty"`
|
||||
SenderID int64 `json:"senderId,omitempty"`
|
||||
MessageType enums.IMMessageType `json:"messageType,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Payload string `json:"payload,omitempty"`
|
||||
SeqNo int64 `json:"seqNo,omitempty"`
|
||||
SendStatus int `json:"sendStatus,omitempty"`
|
||||
SentAt string `json:"sentAt,omitempty"`
|
||||
}
|
||||
|
||||
func (RealtimeMessageCreatedPayload) realtimeEventPayload() {}
|
||||
|
||||
type RealtimeMessageCreatedEvent struct {
|
||||
Payload RealtimeMessageCreatedPayload
|
||||
}
|
||||
|
||||
func (e RealtimeMessageCreatedEvent) EventType() string {
|
||||
return enums.IMRealtimeEventMessageCreated
|
||||
}
|
||||
|
||||
func (e RealtimeMessageCreatedEvent) EventPayload() RealtimeEventPayload {
|
||||
return e.Payload
|
||||
}
|
||||
|
||||
type RealtimeMessageRecalledPayload struct {
|
||||
ConversationID int64 `json:"conversationId,omitempty"`
|
||||
MessageID int64 `json:"messageId,omitempty"`
|
||||
SenderType enums.IMSenderType `json:"senderType,omitempty"`
|
||||
SenderID int64 `json:"senderId,omitempty"`
|
||||
SendStatus int `json:"sendStatus,omitempty"`
|
||||
RecalledAt string `json:"recalledAt,omitempty"`
|
||||
}
|
||||
|
||||
func (RealtimeMessageRecalledPayload) realtimeEventPayload() {}
|
||||
|
||||
type RealtimeMessageRecalledEvent struct {
|
||||
Payload RealtimeMessageRecalledPayload
|
||||
}
|
||||
|
||||
func (e RealtimeMessageRecalledEvent) EventType() string {
|
||||
return enums.IMRealtimeEventMessageRecalled
|
||||
}
|
||||
|
||||
func (e RealtimeMessageRecalledEvent) EventPayload() RealtimeEventPayload {
|
||||
return e.Payload
|
||||
}
|
||||
|
||||
type RealtimeConversationChangedPayload struct {
|
||||
ConversationID int64 `json:"conversationId,omitempty"`
|
||||
Status enums.IMConversationStatus `json:"status,omitempty"`
|
||||
ServiceMode enums.IMConversationServiceMode `json:"serviceMode,omitempty"`
|
||||
CurrentAssigneeID int64 `json:"currentAssigneeId,omitempty"`
|
||||
LastMessageID int64 `json:"lastMessageId,omitempty"`
|
||||
LastMessageAt string `json:"lastMessageAt,omitempty"`
|
||||
LastActiveAt string `json:"lastActiveAt,omitempty"`
|
||||
LastMessageSummary string `json:"lastMessageSummary,omitempty"`
|
||||
CustomerUnreadCount int `json:"customerUnreadCount,omitempty"`
|
||||
AgentUnreadCount int `json:"agentUnreadCount,omitempty"`
|
||||
CustomerLastReadMessageID int64 `json:"customerLastReadMessageId,omitempty"`
|
||||
CustomerLastReadSeqNo int64 `json:"customerLastReadSeqNo,omitempty"`
|
||||
CustomerLastReadAt string `json:"customerLastReadAt,omitempty"`
|
||||
AgentLastReadMessageID int64 `json:"agentLastReadMessageId,omitempty"`
|
||||
AgentLastReadSeqNo int64 `json:"agentLastReadSeqNo,omitempty"`
|
||||
AgentLastReadAt string `json:"agentLastReadAt,omitempty"`
|
||||
}
|
||||
|
||||
func (RealtimeConversationChangedPayload) realtimeEventPayload() {}
|
||||
|
||||
type RealtimeConversationChangedEvent struct {
|
||||
Type string
|
||||
Payload RealtimeConversationChangedPayload
|
||||
}
|
||||
|
||||
func (e RealtimeConversationChangedEvent) EventType() string {
|
||||
return e.Type
|
||||
}
|
||||
|
||||
func (e RealtimeConversationChangedEvent) EventPayload() RealtimeEventPayload {
|
||||
return e.Payload
|
||||
}
|
||||
|
||||
type realtimeClientMessage struct {
|
||||
Type string `json:"type"`
|
||||
Topics []string `json:"topics,omitempty"`
|
||||
EventID string `json:"eventId,omitempty"`
|
||||
}
|
||||
|
||||
type ClientSession struct {
|
||||
ID string
|
||||
Conn *websocket.Conn
|
||||
Principal *dto.AuthPrincipal
|
||||
External *openidentity.ExternalInfo
|
||||
Role string
|
||||
TerminalType string
|
||||
Topics map[string]struct{}
|
||||
Send chan []byte
|
||||
Closed atomic.Bool
|
||||
LastActiveAt atomic.Int64
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
func (s *ClientSession) enqueue(payload []byte) bool {
|
||||
if s == nil || s.Closed.Load() {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case s.Send <- payload:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ClientSession) enqueueEvent(event RealtimeEvent) bool {
|
||||
payload, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return s.enqueue(payload)
|
||||
}
|
||||
|
||||
func (s *ClientSession) touch() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.LastActiveAt.Store(time.Now().Unix())
|
||||
}
|
||||
|
||||
func (s *ClientSession) topicList() []string {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
ret := make([]string, 0, len(s.Topics))
|
||||
for topic := range s.Topics {
|
||||
ret = append(ret, topic)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/dto"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"cs-agent/internal/pkg/openidentity"
|
||||
"cs-agent/internal/pkg/utils"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/kataras/iris/v12"
|
||||
)
|
||||
|
||||
var WsService = newWsService()
|
||||
|
||||
type wsService struct {
|
||||
upgrader websocket.Upgrader
|
||||
seq atomic.Uint64
|
||||
manager *WsConnectionManager
|
||||
}
|
||||
|
||||
func newWsService() *wsService {
|
||||
return &wsService{
|
||||
upgrader: websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
},
|
||||
manager: newWsConnectionManager(),
|
||||
}
|
||||
}
|
||||
|
||||
// UpgradeUserConnection 开放 IM 用户侧 WebSocket。
|
||||
// principal 非空时表示站内用户;否则应传入 external(IM 访客)。二者勿混用为同一业务身份。
|
||||
func (s *wsService) UpgradeUserConnection(ctx iris.Context, principal *dto.AuthPrincipal, external *openidentity.ExternalInfo) error {
|
||||
return s.upgradeConnection(ctx, principal, external, realtimeRoleUser)
|
||||
}
|
||||
|
||||
func (s *wsService) UpgradeAdminConnection(ctx iris.Context, principal *dto.AuthPrincipal) error {
|
||||
return s.upgradeConnection(ctx, principal, nil, realtimeRoleAdmin)
|
||||
}
|
||||
|
||||
func (s *wsService) upgradeConnection(ctx iris.Context, principal *dto.AuthPrincipal, external *openidentity.ExternalInfo, role string) error {
|
||||
conn, err := s.upgrader.Upgrade(ctx.ResponseWriter().Naive(), ctx.Request(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
session := &ClientSession{
|
||||
ID: s.nextID("conn"),
|
||||
Conn: conn,
|
||||
Principal: principal,
|
||||
External: external,
|
||||
Role: role,
|
||||
TerminalType: s.resolveTerminalType(ctx, role),
|
||||
Topics: make(map[string]struct{}),
|
||||
Send: make(chan []byte, realtimeSendBufferSize),
|
||||
}
|
||||
session.touch()
|
||||
|
||||
conn.SetReadLimit(realtimeMaxMessageSize)
|
||||
_ = conn.SetReadDeadline(time.Now().Add(realtimePongWait))
|
||||
conn.SetPongHandler(func(string) error {
|
||||
session.touch()
|
||||
return conn.SetReadDeadline(time.Now().Add(realtimePongWait))
|
||||
})
|
||||
|
||||
var logUserID int64
|
||||
var logExternalID string
|
||||
if principal != nil {
|
||||
logUserID = principal.UserID
|
||||
}
|
||||
if external != nil {
|
||||
logExternalID = strings.TrimSpace(external.ExternalID)
|
||||
}
|
||||
|
||||
sessionCount := s.manager.Register(session, s.defaultTopics(session))
|
||||
slog.Info("realtime client connected",
|
||||
"connId", session.ID,
|
||||
"role", session.Role,
|
||||
"userId", logUserID,
|
||||
"externalId", logExternalID,
|
||||
"terminalType", session.TerminalType,
|
||||
"topicCount", len(session.Topics),
|
||||
"sessionCount", sessionCount,
|
||||
)
|
||||
|
||||
go s.writePump(session)
|
||||
go s.readPump(session)
|
||||
|
||||
session.enqueueEvent(s.newEvent("", RealtimeConnectedEvent{
|
||||
Payload: RealtimeConnectedPayload{
|
||||
ConnID: session.ID,
|
||||
UserID: logUserID,
|
||||
VisitorID: logExternalID,
|
||||
Role: role,
|
||||
TerminalType: session.TerminalType,
|
||||
Topics: session.topicList(),
|
||||
},
|
||||
}))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *wsService) readPump(session *ClientSession) {
|
||||
defer s.closeSession(session)
|
||||
|
||||
for {
|
||||
_, body, err := session.Conn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
session.touch()
|
||||
|
||||
input := realtimeClientMessage{}
|
||||
if err := json.Unmarshal(body, &input); err != nil {
|
||||
session.enqueueEvent(s.newEvent("", RealtimeResyncRequiredEvent{
|
||||
Payload: RealtimeResyncRequiredPayload{
|
||||
Reason: enums.IMRealtimeResyncReasonInvalidPayload,
|
||||
},
|
||||
}))
|
||||
continue
|
||||
}
|
||||
|
||||
switch strings.TrimSpace(input.Type) {
|
||||
case enums.IMRealtimeClientTypePing:
|
||||
session.enqueueEvent(s.newEvent("", RealtimePongEvent{}))
|
||||
case enums.IMRealtimeClientTypeSubscribe:
|
||||
topics := s.subscribeTopics(session, input.Topics)
|
||||
if len(topics) > 0 {
|
||||
session.enqueueEvent(s.newEvent("", RealtimeSubscribedEvent{
|
||||
Payload: RealtimeTopicsPayload{Topics: topics},
|
||||
}))
|
||||
}
|
||||
case enums.IMRealtimeClientTypeUnsubscribe:
|
||||
topics := s.unsubscribeTopics(session, input.Topics)
|
||||
if len(topics) > 0 {
|
||||
session.enqueueEvent(s.newEvent("", RealtimeUnsubscribedEvent{
|
||||
Payload: RealtimeTopicsPayload{Topics: topics},
|
||||
}))
|
||||
}
|
||||
case enums.IMRealtimeClientTypeAck:
|
||||
slog.Debug("realtime event ack",
|
||||
"connId", session.ID,
|
||||
"eventId", strings.TrimSpace(input.EventID),
|
||||
)
|
||||
default:
|
||||
session.enqueueEvent(s.newEvent("", RealtimeResyncRequiredEvent{
|
||||
Payload: RealtimeResyncRequiredPayload{
|
||||
Reason: enums.IMRealtimeResyncReasonUnsupportedMessageType,
|
||||
},
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *wsService) writePump(session *ClientSession) {
|
||||
ticker := time.NewTicker(realtimePingPeriod)
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
s.closeSession(session)
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case payload, ok := <-session.Send:
|
||||
_ = session.Conn.SetWriteDeadline(time.Now().Add(realtimeWriteWait))
|
||||
if !ok {
|
||||
_ = session.Conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||
return
|
||||
}
|
||||
if err := session.Conn.WriteMessage(websocket.TextMessage, payload); err != nil {
|
||||
return
|
||||
}
|
||||
case <-ticker.C:
|
||||
_ = session.Conn.SetWriteDeadline(time.Now().Add(realtimeWriteWait))
|
||||
if err := session.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *wsService) closeSession(session *ClientSession) {
|
||||
if session == nil {
|
||||
return
|
||||
}
|
||||
session.closeOnce.Do(func() {
|
||||
session.Closed.Store(true)
|
||||
remaining := s.manager.Unregister(session)
|
||||
|
||||
close(session.Send)
|
||||
_ = session.Conn.Close()
|
||||
|
||||
var discUserID int64
|
||||
var discExternalID string
|
||||
if session.Principal != nil {
|
||||
discUserID = session.Principal.UserID
|
||||
}
|
||||
if session.External != nil {
|
||||
discExternalID = strings.TrimSpace(session.External.ExternalID)
|
||||
}
|
||||
slog.Info("realtime client disconnected",
|
||||
"connId", session.ID,
|
||||
"role", session.Role,
|
||||
"userId", discUserID,
|
||||
"externalId", discExternalID,
|
||||
"terminalType", session.TerminalType,
|
||||
"sessionCount", remaining,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *wsService) subscribeTopics(session *ClientSession, topics []string) []string {
|
||||
allowed := s.filterAllowedTopics(session, topics)
|
||||
if len(allowed) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.manager.Subscribe(session, allowed)
|
||||
}
|
||||
|
||||
func (s *wsService) unsubscribeTopics(session *ClientSession, topics []string) []string {
|
||||
allowed := s.filterAllowedTopics(session, topics)
|
||||
if len(allowed) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.manager.Unsubscribe(session, allowed, sliceToSet(s.defaultTopics(session)))
|
||||
}
|
||||
|
||||
func (s *wsService) PublishMessageCreated(conversation *models.Conversation, message *models.Message) {
|
||||
if conversation == nil || message == nil {
|
||||
return
|
||||
}
|
||||
|
||||
event := s.newEvent(s.conversationTopic(conversation.ID), RealtimeMessageCreatedEvent{
|
||||
Payload: RealtimeMessageCreatedPayload{
|
||||
ConversationID: conversation.ID,
|
||||
MessageID: message.ID,
|
||||
Status: conversation.Status,
|
||||
CurrentAssigneeID: conversation.CurrentAssigneeID,
|
||||
SenderType: message.SenderType,
|
||||
SenderID: message.SenderID,
|
||||
MessageType: message.MessageType,
|
||||
Content: message.Content,
|
||||
Payload: message.Payload,
|
||||
SeqNo: message.SeqNo,
|
||||
SendStatus: message.SendStatus,
|
||||
SentAt: formatWsTime(message.SentAt),
|
||||
},
|
||||
})
|
||||
s.PublishToTopics(s.routeConversationTopics(conversation), event)
|
||||
}
|
||||
|
||||
func (s *wsService) PublishMessageRecalled(conversation *models.Conversation, message *models.Message) {
|
||||
if conversation == nil || message == nil {
|
||||
return
|
||||
}
|
||||
|
||||
event := s.newEvent(s.conversationTopic(conversation.ID), RealtimeMessageRecalledEvent{
|
||||
Payload: RealtimeMessageRecalledPayload{
|
||||
ConversationID: conversation.ID,
|
||||
MessageID: message.ID,
|
||||
SenderType: message.SenderType,
|
||||
SenderID: message.SenderID,
|
||||
SendStatus: message.SendStatus,
|
||||
RecalledAt: formatWsTime(message.RecalledAt),
|
||||
},
|
||||
})
|
||||
s.PublishToTopics(s.routeConversationTopics(conversation), event)
|
||||
}
|
||||
|
||||
func (s *wsService) PublishConversationChanged(conversation *models.Conversation, eventType string) {
|
||||
if conversation == nil {
|
||||
return
|
||||
}
|
||||
agentReadState, customerReadState := ConversationReadStateService.GetConversationReadStates(conversation.ID)
|
||||
|
||||
event := s.newEvent(s.conversationTopic(conversation.ID), RealtimeConversationChangedEvent{
|
||||
Type: eventType,
|
||||
Payload: RealtimeConversationChangedPayload{
|
||||
ConversationID: conversation.ID,
|
||||
Status: conversation.Status,
|
||||
ServiceMode: conversation.ServiceMode,
|
||||
CurrentAssigneeID: conversation.CurrentAssigneeID,
|
||||
LastMessageID: conversation.LastMessageID,
|
||||
LastMessageAt: formatWsTime(&conversation.LastMessageAt),
|
||||
LastActiveAt: formatWsTime(&conversation.LastActiveAt),
|
||||
LastMessageSummary: conversation.LastMessageSummary,
|
||||
CustomerUnreadCount: conversation.CustomerUnreadCount,
|
||||
AgentUnreadCount: conversation.AgentUnreadCount,
|
||||
CustomerLastReadMessageID: readStateMessageID(customerReadState),
|
||||
CustomerLastReadSeqNo: readStateSeqNo(customerReadState),
|
||||
CustomerLastReadAt: readStateAt(customerReadState),
|
||||
AgentLastReadMessageID: readStateMessageID(agentReadState),
|
||||
AgentLastReadSeqNo: readStateSeqNo(agentReadState),
|
||||
AgentLastReadAt: readStateAt(agentReadState),
|
||||
},
|
||||
})
|
||||
s.PublishToTopics(s.routeConversationTopics(conversation), event)
|
||||
}
|
||||
|
||||
func (s *wsService) PublishResyncRequired(topics []string, reason string) {
|
||||
reason = strings.TrimSpace(reason)
|
||||
if reason == "" {
|
||||
reason = enums.IMRealtimeResyncReasonManual
|
||||
}
|
||||
s.PublishToTopics(topics, s.newEvent("", RealtimeResyncRequiredEvent{
|
||||
Payload: RealtimeResyncRequiredPayload{Reason: reason},
|
||||
}))
|
||||
}
|
||||
|
||||
func readStateMessageID(state *models.ConversationReadState) int64 {
|
||||
if state == nil {
|
||||
return 0
|
||||
}
|
||||
return state.LastReadMessageID
|
||||
}
|
||||
|
||||
func readStateSeqNo(state *models.ConversationReadState) int64 {
|
||||
if state == nil {
|
||||
return 0
|
||||
}
|
||||
return state.LastReadSeqNo
|
||||
}
|
||||
|
||||
func readStateAt(state *models.ConversationReadState) string {
|
||||
if state == nil {
|
||||
return ""
|
||||
}
|
||||
return utils.FormatTimePtr(state.LastReadAt)
|
||||
}
|
||||
|
||||
func (s *wsService) Publish(event RealtimeEvent) {
|
||||
if strings.TrimSpace(event.Topic) == "" {
|
||||
return
|
||||
}
|
||||
s.PublishToTopic(event.Topic, event)
|
||||
}
|
||||
|
||||
func (s *wsService) PublishToTopic(topic string, event RealtimeEvent) {
|
||||
topic = strings.TrimSpace(topic)
|
||||
if topic == "" {
|
||||
return
|
||||
}
|
||||
if event.Topic == "" {
|
||||
event.Topic = topic
|
||||
}
|
||||
s.PublishToTopics([]string{topic}, event)
|
||||
}
|
||||
|
||||
func (s *wsService) PublishToTopics(topics []string, event RealtimeEvent) {
|
||||
normalized := normalizeRealtimeTopics(topics)
|
||||
if len(normalized) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
targets := s.manager.FindByTopics(normalized)
|
||||
if len(targets) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
slog.Error("marshal realtime event failed", "error", err, "type", event.Type)
|
||||
return
|
||||
}
|
||||
|
||||
for _, session := range targets {
|
||||
if session.enqueue(payload) {
|
||||
continue
|
||||
}
|
||||
slog.Warn("drop slow realtime client",
|
||||
"connId", session.ID,
|
||||
"role", session.Role,
|
||||
"type", event.Type,
|
||||
"topic", event.Topic,
|
||||
)
|
||||
go s.closeSession(session)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *wsService) routeConversationTopics(conversation *models.Conversation) []string {
|
||||
if conversation == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
topics := []string{s.conversationTopic(conversation.ID)}
|
||||
if strings.TrimSpace(conversation.ExternalID) != "" {
|
||||
topics = append(topics, s.visitorTopic(conversation.ExternalID))
|
||||
}
|
||||
if conversation.CurrentAssigneeID > 0 {
|
||||
topics = append(topics, s.adminTopic(conversation.CurrentAssigneeID))
|
||||
} else {
|
||||
topics = append(topics, realtimeTopicAdminAll)
|
||||
}
|
||||
return normalizeRealtimeTopics(topics)
|
||||
}
|
||||
|
||||
func (s *wsService) defaultTopics(session *ClientSession) []string {
|
||||
if session == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch session.Role {
|
||||
case realtimeRoleAdmin:
|
||||
if session.Principal == nil || session.Principal.UserID <= 0 {
|
||||
return []string{realtimeTopicAdminAll}
|
||||
}
|
||||
return []string{s.adminTopic(session.Principal.UserID), realtimeTopicAdminAll}
|
||||
default:
|
||||
// 开放 IM:仅 External、无 AuthPrincipal 的访客连接必须仍能订阅 visitor:{externalId},否则收不到推送。
|
||||
if session.External != nil && strings.TrimSpace(session.External.ExternalID) != "" {
|
||||
return []string{s.visitorTopic(session.External.ExternalID)}
|
||||
}
|
||||
if session.Principal != nil && session.Principal.UserID > 0 {
|
||||
return []string{s.userTopic(session.Principal.UserID)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *wsService) filterAllowedTopics(session *ClientSession, topics []string) []string {
|
||||
normalized := normalizeRealtimeTopics(topics)
|
||||
if len(normalized) == 0 || session == nil {
|
||||
return nil
|
||||
}
|
||||
switch session.Role {
|
||||
case realtimeRoleAdmin:
|
||||
if session.Principal == nil {
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
hasUser := session.Principal != nil && session.Principal.UserID > 0
|
||||
hasExternal := session.External != nil && strings.TrimSpace(session.External.ExternalID) != ""
|
||||
if !hasUser && !hasExternal {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
defaultTopics := sliceToSet(s.defaultTopics(session))
|
||||
ret := make([]string, 0, len(normalized))
|
||||
for _, topic := range normalized {
|
||||
if _, ok := defaultTopics[topic]; ok {
|
||||
ret = append(ret, topic)
|
||||
continue
|
||||
}
|
||||
if conversationID, ok := parseConversationTopic(topic); ok && s.canSubscribeConversation(session, conversationID) {
|
||||
ret = append(ret, topic)
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func (s *wsService) canSubscribeConversation(session *ClientSession, conversationID int64) bool {
|
||||
if session == nil || conversationID <= 0 {
|
||||
return false
|
||||
}
|
||||
if session.Role == realtimeRoleAdmin {
|
||||
return true
|
||||
}
|
||||
conversation := ConversationService.Get(conversationID)
|
||||
if conversation == nil {
|
||||
return false
|
||||
}
|
||||
if session.External != nil {
|
||||
return ConversationService.IsCustomerConversationOwner(conversation, *session.External)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *wsService) resolveTerminalType(ctx iris.Context, role string) string {
|
||||
if ctx == nil {
|
||||
return "web"
|
||||
}
|
||||
terminalType := strings.TrimSpace(ctx.URLParam("terminalType"))
|
||||
if terminalType != "" {
|
||||
return terminalType
|
||||
}
|
||||
if role == realtimeRoleAdmin {
|
||||
return "console_web"
|
||||
}
|
||||
return "web"
|
||||
}
|
||||
|
||||
func (s *wsService) newEvent(topic string, event RealtimeDomainEvent) RealtimeEvent {
|
||||
if event == nil {
|
||||
return RealtimeEvent{
|
||||
EventID: s.nextID("evt"),
|
||||
Topic: topic,
|
||||
At: time.Now().Format(time.DateTime),
|
||||
}
|
||||
}
|
||||
return RealtimeEvent{
|
||||
EventID: s.nextID("evt"),
|
||||
Type: event.EventType(),
|
||||
Topic: topic,
|
||||
Data: event.EventPayload(),
|
||||
At: time.Now().Format(time.DateTime),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *wsService) nextID(prefix string) string {
|
||||
seq := s.seq.Add(1)
|
||||
return fmt.Sprintf("%s_%d_%d", prefix, time.Now().UnixNano(), seq)
|
||||
}
|
||||
|
||||
func (s *wsService) userTopic(userID int64) string {
|
||||
return realtimeTopicUserPrefix + strconv.FormatInt(userID, 10)
|
||||
}
|
||||
|
||||
func (s *wsService) visitorTopic(visitorID string) string {
|
||||
return realtimeTopicVisitorPrefix + strings.TrimSpace(visitorID)
|
||||
}
|
||||
|
||||
func (s *wsService) adminTopic(userID int64) string {
|
||||
return realtimeTopicAdminPrefix + strconv.FormatInt(userID, 10)
|
||||
}
|
||||
|
||||
func (s *wsService) conversationTopic(conversationID int64) string {
|
||||
return realtimeTopicConversationPrefix + strconv.FormatInt(conversationID, 10)
|
||||
}
|
||||
|
||||
func normalizeRealtimeTopics(topics []string) []string {
|
||||
if len(topics) == 0 {
|
||||
return nil
|
||||
}
|
||||
ret := make([]string, 0, len(topics))
|
||||
seen := make(map[string]struct{}, len(topics))
|
||||
for _, topic := range topics {
|
||||
item := strings.TrimSpace(topic)
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[item]; ok {
|
||||
continue
|
||||
}
|
||||
seen[item] = struct{}{}
|
||||
ret = append(ret, item)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func parseConversationTopic(topic string) (int64, bool) {
|
||||
if !strings.HasPrefix(topic, realtimeTopicConversationPrefix) {
|
||||
return 0, false
|
||||
}
|
||||
value := strings.TrimPrefix(topic, realtimeTopicConversationPrefix)
|
||||
id, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func sliceToSet(items []string) map[string]struct{} {
|
||||
ret := make(map[string]struct{}, len(items))
|
||||
for _, item := range items {
|
||||
ret[item] = struct{}{}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func formatWsTime(t *time.Time) string {
|
||||
if t == nil || t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.Format(time.DateTime)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package wx_callback_handlers
|
||||
|
||||
import (
|
||||
"cs-agent/internal/services"
|
||||
"cs-agent/internal/wxwork"
|
||||
"log/slog"
|
||||
|
||||
"github.com/silenceper/wechat/v2/work/kf"
|
||||
)
|
||||
|
||||
func init() {
|
||||
wxwork.RegHandler("event", "kf_msg_or_event", kf_msg_or_event_handler)
|
||||
}
|
||||
|
||||
func kf_msg_or_event_handler(message kf.CallbackMessage) {
|
||||
slog.Info("received wxwork callback event",
|
||||
"open_kfid", message.OpenKfID,
|
||||
"event", message.Event,
|
||||
"token", message.Token,
|
||||
)
|
||||
if err := services.WxWorkKFInboundService.SyncCallbackMessages(message); err != nil {
|
||||
slog.Error("sync wxwork callback messages failed",
|
||||
"open_kfid", message.OpenKfID,
|
||||
"event", message.Event,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/repositories"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"github.com/mlogclub/simple/web/params"
|
||||
)
|
||||
|
||||
var WxWorkKFConversationService = newWxWorkKFConversationService()
|
||||
|
||||
func newWxWorkKFConversationService() *wxWorkKFConversationService {
|
||||
return &wxWorkKFConversationService{}
|
||||
}
|
||||
|
||||
type wxWorkKFConversationService struct {
|
||||
}
|
||||
|
||||
func (s *wxWorkKFConversationService) Get(id int64) *models.WxWorkKFConversation {
|
||||
return repositories.WxWorkKFConversationRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *wxWorkKFConversationService) Take(where ...interface{}) *models.WxWorkKFConversation {
|
||||
return repositories.WxWorkKFConversationRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *wxWorkKFConversationService) Find(cnd *sqls.Cnd) []models.WxWorkKFConversation {
|
||||
return repositories.WxWorkKFConversationRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *wxWorkKFConversationService) FindOne(cnd *sqls.Cnd) *models.WxWorkKFConversation {
|
||||
return repositories.WxWorkKFConversationRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *wxWorkKFConversationService) FindPageByParams(params *params.QueryParams) (list []models.WxWorkKFConversation, paging *sqls.Paging) {
|
||||
return repositories.WxWorkKFConversationRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *wxWorkKFConversationService) FindPageByCnd(cnd *sqls.Cnd) (list []models.WxWorkKFConversation, paging *sqls.Paging) {
|
||||
return repositories.WxWorkKFConversationRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *wxWorkKFConversationService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.WxWorkKFConversationRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *wxWorkKFConversationService) Create(t *models.WxWorkKFConversation) error {
|
||||
return repositories.WxWorkKFConversationRepository.Create(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *wxWorkKFConversationService) Update(t *models.WxWorkKFConversation) error {
|
||||
return repositories.WxWorkKFConversationRepository.Update(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *wxWorkKFConversationService) Updates(id int64, columns map[string]interface{}) error {
|
||||
return repositories.WxWorkKFConversationRepository.Updates(sqls.DB(), id, columns)
|
||||
}
|
||||
|
||||
func (s *wxWorkKFConversationService) UpdateColumn(id int64, name string, value interface{}) error {
|
||||
return repositories.WxWorkKFConversationRepository.UpdateColumn(sqls.DB(), id, name, value)
|
||||
}
|
||||
|
||||
func (s *wxWorkKFConversationService) Delete(id int64) {
|
||||
repositories.WxWorkKFConversationRepository.Delete(sqls.DB(), id)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/repositories"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"github.com/mlogclub/simple/web/params"
|
||||
)
|
||||
|
||||
var WxWorkKFMessageRefService = newWxWorkKFMessageRefService()
|
||||
|
||||
func newWxWorkKFMessageRefService() *wxWorkKFMessageRefService {
|
||||
return &wxWorkKFMessageRefService{}
|
||||
}
|
||||
|
||||
type wxWorkKFMessageRefService struct {
|
||||
}
|
||||
|
||||
func (s *wxWorkKFMessageRefService) Get(id int64) *models.WxWorkKFMessageRef {
|
||||
return repositories.WxWorkKFMessageRefRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *wxWorkKFMessageRefService) Take(where ...interface{}) *models.WxWorkKFMessageRef {
|
||||
return repositories.WxWorkKFMessageRefRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *wxWorkKFMessageRefService) Find(cnd *sqls.Cnd) []models.WxWorkKFMessageRef {
|
||||
return repositories.WxWorkKFMessageRefRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *wxWorkKFMessageRefService) FindOne(cnd *sqls.Cnd) *models.WxWorkKFMessageRef {
|
||||
return repositories.WxWorkKFMessageRefRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *wxWorkKFMessageRefService) FindPageByParams(params *params.QueryParams) (list []models.WxWorkKFMessageRef, paging *sqls.Paging) {
|
||||
return repositories.WxWorkKFMessageRefRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *wxWorkKFMessageRefService) FindPageByCnd(cnd *sqls.Cnd) (list []models.WxWorkKFMessageRef, paging *sqls.Paging) {
|
||||
return repositories.WxWorkKFMessageRefRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *wxWorkKFMessageRefService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.WxWorkKFMessageRefRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *wxWorkKFMessageRefService) Create(t *models.WxWorkKFMessageRef) error {
|
||||
return repositories.WxWorkKFMessageRefRepository.Create(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *wxWorkKFMessageRefService) Update(t *models.WxWorkKFMessageRef) error {
|
||||
return repositories.WxWorkKFMessageRefRepository.Update(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *wxWorkKFMessageRefService) Updates(id int64, columns map[string]interface{}) error {
|
||||
return repositories.WxWorkKFMessageRefRepository.Updates(sqls.DB(), id, columns)
|
||||
}
|
||||
|
||||
func (s *wxWorkKFMessageRefService) UpdateColumn(id int64, name string, value interface{}) error {
|
||||
return repositories.WxWorkKFMessageRefRepository.UpdateColumn(sqls.DB(), id, name, value)
|
||||
}
|
||||
|
||||
func (s *wxWorkKFMessageRefService) Delete(id int64) {
|
||||
repositories.WxWorkKFMessageRefRepository.Delete(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *wxWorkKFMessageRefService) GetByWxMsgID(wxMsgID string) *models.WxWorkKFMessageRef {
|
||||
return repositories.WxWorkKFMessageRefRepository.Take(sqls.DB(), "wx_msg_id = ?", wxMsgID)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/repositories"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"github.com/mlogclub/simple/web/params"
|
||||
)
|
||||
|
||||
var WxWorkKFSyncStateService = newWxWorkKFSyncStateService()
|
||||
|
||||
func newWxWorkKFSyncStateService() *wxWorkKFSyncStateService {
|
||||
return &wxWorkKFSyncStateService{}
|
||||
}
|
||||
|
||||
type wxWorkKFSyncStateService struct {
|
||||
}
|
||||
|
||||
func (s *wxWorkKFSyncStateService) Get(id int64) *models.WxWorkKFSyncState {
|
||||
return repositories.WxWorkKFSyncStateRepository.Get(sqls.DB(), id)
|
||||
}
|
||||
|
||||
func (s *wxWorkKFSyncStateService) Take(where ...interface{}) *models.WxWorkKFSyncState {
|
||||
return repositories.WxWorkKFSyncStateRepository.Take(sqls.DB(), where...)
|
||||
}
|
||||
|
||||
func (s *wxWorkKFSyncStateService) Find(cnd *sqls.Cnd) []models.WxWorkKFSyncState {
|
||||
return repositories.WxWorkKFSyncStateRepository.Find(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *wxWorkKFSyncStateService) FindOne(cnd *sqls.Cnd) *models.WxWorkKFSyncState {
|
||||
return repositories.WxWorkKFSyncStateRepository.FindOne(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *wxWorkKFSyncStateService) FindPageByParams(params *params.QueryParams) (list []models.WxWorkKFSyncState, paging *sqls.Paging) {
|
||||
return repositories.WxWorkKFSyncStateRepository.FindPageByParams(sqls.DB(), params)
|
||||
}
|
||||
|
||||
func (s *wxWorkKFSyncStateService) FindPageByCnd(cnd *sqls.Cnd) (list []models.WxWorkKFSyncState, paging *sqls.Paging) {
|
||||
return repositories.WxWorkKFSyncStateRepository.FindPageByCnd(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *wxWorkKFSyncStateService) Count(cnd *sqls.Cnd) int64 {
|
||||
return repositories.WxWorkKFSyncStateRepository.Count(sqls.DB(), cnd)
|
||||
}
|
||||
|
||||
func (s *wxWorkKFSyncStateService) Create(t *models.WxWorkKFSyncState) error {
|
||||
return repositories.WxWorkKFSyncStateRepository.Create(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *wxWorkKFSyncStateService) Update(t *models.WxWorkKFSyncState) error {
|
||||
return repositories.WxWorkKFSyncStateRepository.Update(sqls.DB(), t)
|
||||
}
|
||||
|
||||
func (s *wxWorkKFSyncStateService) Updates(id int64, columns map[string]interface{}) error {
|
||||
return repositories.WxWorkKFSyncStateRepository.Updates(sqls.DB(), id, columns)
|
||||
}
|
||||
|
||||
func (s *wxWorkKFSyncStateService) UpdateColumn(id int64, name string, value interface{}) error {
|
||||
return repositories.WxWorkKFSyncStateRepository.UpdateColumn(sqls.DB(), id, name, value)
|
||||
}
|
||||
|
||||
func (s *wxWorkKFSyncStateService) Delete(id int64) {
|
||||
repositories.WxWorkKFSyncStateRepository.Delete(sqls.DB(), id)
|
||||
}
|
||||
|
||||
// GetByOpenKfID 根据open_kf_id获取同步状态
|
||||
func (s *wxWorkKFSyncStateService) GetByOpenKfID(openKfID string) *models.WxWorkKFSyncState {
|
||||
return repositories.WxWorkKFSyncStateRepository.Take(sqls.DB(), "open_kf_id = ?", openKfID)
|
||||
}
|
||||
@@ -0,0 +1,633 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"cs-agent/internal/pkg/errorsx"
|
||||
"cs-agent/internal/pkg/openidentity"
|
||||
"cs-agent/internal/wxwork"
|
||||
|
||||
"github.com/mlogclub/simple/common/strs"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"github.com/silenceper/wechat/v2/work/kf"
|
||||
"github.com/silenceper/wechat/v2/work/kf/syncmsg"
|
||||
)
|
||||
|
||||
const (
|
||||
wxWorkKFSystemOperatorName = "wxwork_kf"
|
||||
wxWorkKFSyncMsgLimit = 1000
|
||||
)
|
||||
|
||||
var WxWorkKFInboundService = newWxWorkKFInboundService()
|
||||
|
||||
func newWxWorkKFInboundService() *wxWorkKFInboundService {
|
||||
return &wxWorkKFInboundService{}
|
||||
}
|
||||
|
||||
type wxWorkKFInboundService struct {
|
||||
}
|
||||
|
||||
func (s *wxWorkKFInboundService) SyncCallbackMessages(message kf.CallbackMessage) error {
|
||||
cli, err := wxwork.GetWorkCli().GetKF()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cursor := ""
|
||||
if state := WxWorkKFSyncStateService.GetByOpenKfID(message.OpenKfID); state != nil {
|
||||
cursor = strings.TrimSpace(state.NextCursor)
|
||||
}
|
||||
|
||||
for {
|
||||
result, syncErr := cli.SyncMsg(kf.SyncMsgOptions{
|
||||
Cursor: cursor,
|
||||
Token: message.Token,
|
||||
Limit: wxWorkKFSyncMsgLimit,
|
||||
OpenKfID: message.OpenKfID,
|
||||
})
|
||||
if syncErr != nil {
|
||||
return syncErr
|
||||
}
|
||||
|
||||
for _, item := range result.MsgList {
|
||||
if err := s.consumeSyncMessage(item); err != nil {
|
||||
slog.Error("consume wxwork kf sync message failed",
|
||||
"open_kfid", item.OpenKFID,
|
||||
"external_userid", item.ExternalUserID,
|
||||
"msg_id", item.MsgID,
|
||||
"msg_type", item.MsgType,
|
||||
"event", item.EventType,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.saveNextCursor(message.OpenKfID, result.NextCursor); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result.HasMore != 1 || strings.TrimSpace(result.NextCursor) == "" {
|
||||
return nil
|
||||
}
|
||||
cursor = result.NextCursor
|
||||
}
|
||||
}
|
||||
|
||||
func (s *wxWorkKFInboundService) consumeSyncMessage(item syncmsg.Message) error {
|
||||
msgID := strings.TrimSpace(item.MsgID)
|
||||
if msgID == "" {
|
||||
return errorsx.InvalidParam("企业微信消息ID不能为空")
|
||||
}
|
||||
if WxWorkKFMessageRefService.Take("wx_msg_id = ?", msgID) != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch strings.TrimSpace(item.MsgType) {
|
||||
case "text":
|
||||
return s.handleTextMessage(item)
|
||||
case "image":
|
||||
return s.handleImageMessage(item)
|
||||
case "file":
|
||||
return s.handleFileMessage(item)
|
||||
case "event":
|
||||
return s.handleEventMessage(item)
|
||||
default:
|
||||
return s.handleUnsupportedMessage(item)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *wxWorkKFInboundService) handleTextMessage(item syncmsg.Message) error {
|
||||
payload := syncmsg.Text{}
|
||||
if err := json.Unmarshal(item.OriginData, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
conversation, err := s.ensureConversation(payload.BaseMessage, map[string]any{
|
||||
"msgType": payload.MsgType,
|
||||
"menuId": payload.Text.MenuID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
message, err := MessageService.SendCustomerMessage(
|
||||
conversation.ID,
|
||||
s.buildInboundClientMsgID(item.MsgID),
|
||||
enums.IMMessageTypeText,
|
||||
strings.TrimSpace(payload.Text.Content),
|
||||
"",
|
||||
s.buildExternalInfo(payload.ExternalUserID),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.createMessageRef(conversation.ID, message.ID, item, enums.WxWorkKFMessageDirectionIn, enums.WxWorkKFMessageSendStatusReceived)
|
||||
}
|
||||
|
||||
func (s *wxWorkKFInboundService) handleImageMessage(item syncmsg.Message) error {
|
||||
payload := syncmsg.Image{}
|
||||
if err := json.Unmarshal(item.OriginData, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
conversation, err := s.ensureConversation(payload.BaseMessage, map[string]any{
|
||||
"msgType": payload.MsgType,
|
||||
"mediaId": payload.Image.MediaID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
canonicalPayload, content, err := s.buildInboundAssetPayload(conversation.ID, strings.TrimSpace(payload.Image.MediaID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
message, err := MessageService.SendCustomerMessage(
|
||||
conversation.ID,
|
||||
s.buildInboundClientMsgID(item.MsgID),
|
||||
enums.IMMessageTypeImage,
|
||||
content,
|
||||
canonicalPayload,
|
||||
s.buildExternalInfo(payload.ExternalUserID),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.createMessageRef(conversation.ID, message.ID, item, enums.WxWorkKFMessageDirectionIn, enums.WxWorkKFMessageSendStatusReceived)
|
||||
}
|
||||
|
||||
func (s *wxWorkKFInboundService) handleFileMessage(item syncmsg.Message) error {
|
||||
payload := syncmsg.File{}
|
||||
if err := json.Unmarshal(item.OriginData, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
conversation, err := s.ensureConversation(payload.BaseMessage, map[string]any{
|
||||
"msgType": payload.MsgType,
|
||||
"mediaId": payload.File.MediaID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
canonicalPayload, content, err := s.buildInboundAssetPayload(conversation.ID, strings.TrimSpace(payload.File.MediaID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
message, err := MessageService.SendCustomerMessage(
|
||||
conversation.ID,
|
||||
s.buildInboundClientMsgID(item.MsgID),
|
||||
enums.IMMessageTypeAttachment,
|
||||
content,
|
||||
canonicalPayload,
|
||||
s.buildExternalInfo(payload.ExternalUserID),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.createMessageRef(conversation.ID, message.ID, item, enums.WxWorkKFMessageDirectionIn, enums.WxWorkKFMessageSendStatusReceived)
|
||||
}
|
||||
|
||||
func (s *wxWorkKFInboundService) handleUnsupportedMessage(item syncmsg.Message) error {
|
||||
base, err := s.parseBaseMessage(item.OriginData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
conversation, convErr := s.ensureConversation(base, map[string]any{"msgType": item.MsgType})
|
||||
if convErr != nil {
|
||||
return convErr
|
||||
}
|
||||
content := s.buildUnsupportedContent(item.MsgType)
|
||||
message, err := MessageService.SendCustomerMessage(
|
||||
conversation.ID,
|
||||
s.buildInboundClientMsgID(item.MsgID),
|
||||
enums.IMMessageTypeText,
|
||||
content,
|
||||
string(item.OriginData),
|
||||
s.buildExternalInfo(base.ExternalUserID),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.createMessageRef(conversation.ID, message.ID, item, enums.WxWorkKFMessageDirectionIn, enums.WxWorkKFMessageSendStatusReceived)
|
||||
}
|
||||
|
||||
func (s *wxWorkKFInboundService) handleEventMessage(item syncmsg.Message) error {
|
||||
switch strings.TrimSpace(item.EventType) {
|
||||
case enums.WxWorkKFEventTypeEnterSession:
|
||||
return s.handleEnterSessionEvent(item)
|
||||
case enums.WxWorkKFEventTypeSessionStatusChange:
|
||||
return s.handleSessionStatusChangeEvent(item)
|
||||
case enums.WxWorkKFEventTypeMsgSendFail:
|
||||
return s.handleMsgSendFailEvent(item)
|
||||
default:
|
||||
return s.recordOrphanEvent(item, "收到未处理的企业微信事件")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *wxWorkKFInboundService) handleEnterSessionEvent(item syncmsg.Message) error {
|
||||
payload := syncmsg.EnterSessionEvent{}
|
||||
if err := json.Unmarshal(item.OriginData, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
base := s.normalizeEventBaseMessage(payload.BaseMessage, payload.Event.OpenKFID, payload.Event.ExternalUserID)
|
||||
conversation, err := s.ensureConversation(base, map[string]any{
|
||||
"scene": payload.Event.Scene,
|
||||
"sceneParam": payload.Event.SceneParam,
|
||||
"welcomeCode": payload.Event.WelcomeCode,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.createMessageRef(conversation.ID, 0, item, enums.WxWorkKFMessageDirectionIn, enums.WxWorkKFMessageSendStatusReceived); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.appendConversationEvent(conversation.ID, "微信客户进入会话", string(item.OriginData))
|
||||
}
|
||||
|
||||
func (s *wxWorkKFInboundService) handleSessionStatusChangeEvent(item syncmsg.Message) error {
|
||||
payload := syncmsg.SessionStatusChangeEvent{}
|
||||
if err := json.Unmarshal(item.OriginData, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
base := s.normalizeEventBaseMessage(payload.BaseMessage, payload.Event.OpenKFID, payload.Event.ExternalUserID)
|
||||
base.ReceptionistUserID = payload.Event.NewReceptionistUserID
|
||||
conversation, err := s.ensureConversation(base, map[string]any{
|
||||
"changeType": payload.Event.ChangeType,
|
||||
"msgCode": payload.Event.MsgCode,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sessionStatus := enums.WxWorkKFSessionStatusActive
|
||||
switch payload.Event.ChangeType {
|
||||
case 2:
|
||||
sessionStatus = enums.WxWorkKFSessionStatusTransfer
|
||||
case 3:
|
||||
sessionStatus = enums.WxWorkKFSessionStatusClosed
|
||||
}
|
||||
channel, channelErr := s.getChannelByOpenKfID(payload.Event.OpenKFID)
|
||||
if channelErr != nil {
|
||||
return channelErr
|
||||
}
|
||||
if err := s.upsertConversationMapping(conversation.ID, channel.ID, payload.Event.OpenKFID, payload.Event.ExternalUserID, payload.Event.NewReceptionistUserID, sessionStatus, payload.SendTime, payload.MsgID, string(item.OriginData)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.createMessageRef(conversation.ID, 0, item, enums.WxWorkKFMessageDirectionIn, enums.WxWorkKFMessageSendStatusReceived); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.appendConversationEvent(conversation.ID, "微信会话状态变更", string(item.OriginData))
|
||||
}
|
||||
|
||||
func (s *wxWorkKFInboundService) handleMsgSendFailEvent(item syncmsg.Message) error {
|
||||
payload := syncmsg.MsgSendFailEvent{}
|
||||
if err := json.Unmarshal(item.OriginData, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Warn("received wxwork msg_send_fail event",
|
||||
slog.String("msg", string(item.OriginData)),
|
||||
)
|
||||
base := s.normalizeEventBaseMessage(payload.BaseMessage, payload.Event.OpenKFID, payload.Event.ExternalUserID)
|
||||
conversation, err := s.ensureConversation(base, map[string]any{
|
||||
"failMsgId": payload.Event.FailMsgID,
|
||||
"failType": payload.Event.FailType,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ref := WxWorkKFMessageRefService.GetByWxMsgID(payload.Event.FailMsgID); ref != nil {
|
||||
targetMessageID := ref.MessageID
|
||||
slog.Warn("mark wxwork message ref failed",
|
||||
"conversation_id", ref.ConversationID,
|
||||
"message_id", ref.MessageID,
|
||||
"wx_msg_id", ref.WxMsgID,
|
||||
"fail_type", payload.Event.FailType,
|
||||
"open_kfid", payload.Event.OpenKFID,
|
||||
"external_userid", payload.Event.ExternalUserID,
|
||||
)
|
||||
_ = WxWorkKFMessageRefService.Updates(ref.ID, map[string]any{
|
||||
"send_status": enums.WxWorkKFMessageSendStatusFailed,
|
||||
"fail_reason": string(item.OriginData),
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
|
||||
if outbox := ChannelMessageOutboxService.GetByMessageID(enums.ChannelTypeWxWorkKF, targetMessageID); outbox != nil {
|
||||
if err := WxWorkKFOutboundService.markOutboxFailed(outbox, string(item.OriginData)); err != nil {
|
||||
slog.Warn("mark wxwork outbox failed from callback event failed",
|
||||
"outbox_id", outbox.ID,
|
||||
"conversation_id", outbox.ConversationID,
|
||||
"message_id", outbox.MessageID,
|
||||
"fail_msg_id", payload.Event.FailMsgID,
|
||||
"fail_type", payload.Event.FailType,
|
||||
"error", err,
|
||||
)
|
||||
} else {
|
||||
slog.Warn("mark wxwork outbox failed from callback event",
|
||||
"outbox_id", outbox.ID,
|
||||
"conversation_id", outbox.ConversationID,
|
||||
"message_id", outbox.MessageID,
|
||||
"fail_msg_id", payload.Event.FailMsgID,
|
||||
"fail_type", payload.Event.FailType,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
slog.Warn("wxwork msg_send_fail event missing outbox",
|
||||
"message_id", targetMessageID,
|
||||
"fail_msg_id", payload.Event.FailMsgID,
|
||||
"fail_type", payload.Event.FailType,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
slog.Warn("wxwork msg_send_fail event missing message ref",
|
||||
"fail_msg_id", payload.Event.FailMsgID,
|
||||
"fail_type", payload.Event.FailType,
|
||||
"open_kfid", payload.Event.OpenKFID,
|
||||
"external_userid", payload.Event.ExternalUserID,
|
||||
)
|
||||
}
|
||||
|
||||
if err := s.createMessageRef(conversation.ID, 0, item, enums.WxWorkKFMessageDirectionIn, enums.WxWorkKFMessageSendStatusReceived); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.appendConversationEvent(conversation.ID, "微信消息发送失败事件", string(item.OriginData))
|
||||
}
|
||||
|
||||
func (s *wxWorkKFInboundService) recordOrphanEvent(item syncmsg.Message, content string) error {
|
||||
base, err := s.parseBaseMessage(item.OriginData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
conversation, convErr := s.ensureConversation(base, map[string]any{"eventType": item.EventType})
|
||||
if convErr != nil {
|
||||
return convErr
|
||||
}
|
||||
if err := s.createMessageRef(conversation.ID, 0, item, enums.WxWorkKFMessageDirectionIn, enums.WxWorkKFMessageSendStatusReceived); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.appendConversationEvent(conversation.ID, content, string(item.OriginData))
|
||||
}
|
||||
|
||||
func (s *wxWorkKFInboundService) ensureConversation(base syncmsg.BaseMessage, profile map[string]any) (*models.Conversation, error) {
|
||||
externalID := strings.TrimSpace(base.ExternalUserID)
|
||||
if externalID == "" {
|
||||
return nil, errorsx.InvalidParam("企业微信客户ID不能为空")
|
||||
}
|
||||
channel, err := s.getChannelByOpenKfID(base.OpenKFID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
external := s.buildExternalInfo(externalID)
|
||||
conversation := ConversationService.FindOne(sqls.NewCnd().
|
||||
Eq("external_source", external.ExternalSource).
|
||||
Eq("external_id", external.ExternalID).
|
||||
In("status", []enums.IMConversationStatus{
|
||||
enums.IMConversationStatusPending,
|
||||
enums.IMConversationStatusActive,
|
||||
}).
|
||||
Desc("id"))
|
||||
|
||||
if conversation == nil {
|
||||
conversation, err = ConversationService.Create(external, channel.AIAgentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.upsertConversationMapping(
|
||||
conversation.ID,
|
||||
channel.ID,
|
||||
base.OpenKFID,
|
||||
base.ExternalUserID,
|
||||
base.ReceptionistUserID,
|
||||
enums.WxWorkKFSessionStatusActive,
|
||||
base.SendTime,
|
||||
base.MsgID,
|
||||
s.mustMarshal(profile),
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return conversation, nil
|
||||
}
|
||||
|
||||
func (s *wxWorkKFInboundService) upsertConversationMapping(conversationID, channelID int64, openKfID, externalUserID, servicerUserID string, sessionStatus enums.WxWorkKFSessionStatus, sendTime uint64, lastMsgID, rawProfile string) error {
|
||||
now := time.Now()
|
||||
lastMsgTime := s.parseSendTime(sendTime)
|
||||
existing := WxWorkKFConversationService.Take("conversation_id = ?", conversationID)
|
||||
if existing != nil {
|
||||
updates := map[string]any{
|
||||
"channel_id": channelID,
|
||||
"open_kf_id": strings.TrimSpace(openKfID),
|
||||
"external_user_id": strings.TrimSpace(externalUserID),
|
||||
"servicer_user_id": strings.TrimSpace(servicerUserID),
|
||||
"session_status": string(sessionStatus),
|
||||
"last_wx_msg_id": strings.TrimSpace(lastMsgID),
|
||||
"updated_at": now,
|
||||
"status": enums.StatusOk,
|
||||
}
|
||||
if lastMsgTime != nil {
|
||||
updates["last_wx_msg_time"] = *lastMsgTime
|
||||
}
|
||||
if strings.TrimSpace(rawProfile) != "" {
|
||||
updates["raw_profile"] = rawProfile
|
||||
}
|
||||
return WxWorkKFConversationService.Updates(existing.ID, updates)
|
||||
}
|
||||
|
||||
return WxWorkKFConversationService.Create(&models.WxWorkKFConversation{
|
||||
ConversationID: conversationID,
|
||||
ChannelID: channelID,
|
||||
OpenKfID: strings.TrimSpace(openKfID),
|
||||
ExternalUserID: strings.TrimSpace(externalUserID),
|
||||
ServicerUserID: strings.TrimSpace(servicerUserID),
|
||||
SessionStatus: string(sessionStatus),
|
||||
LastWxMsgID: strings.TrimSpace(lastMsgID),
|
||||
LastWxMsgTime: lastMsgTime,
|
||||
RawProfile: strings.TrimSpace(rawProfile),
|
||||
Status: enums.StatusOk,
|
||||
AuditFields: models.AuditFields{
|
||||
CreatedAt: now,
|
||||
CreateUserID: 0,
|
||||
CreateUserName: wxWorkKFSystemOperatorName,
|
||||
UpdatedAt: now,
|
||||
UpdateUserID: 0,
|
||||
UpdateUserName: wxWorkKFSystemOperatorName,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *wxWorkKFInboundService) createMessageRef(conversationID, messageID int64, item syncmsg.Message, direction enums.WxWorkKFMessageDirection, sendStatus enums.WxWorkKFMessageSendStatus) error {
|
||||
if WxWorkKFMessageRefService.Take("wx_msg_id = ?", item.MsgID) != nil {
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
return WxWorkKFMessageRefService.Create(&models.WxWorkKFMessageRef{
|
||||
ConversationID: conversationID,
|
||||
MessageID: messageID,
|
||||
WxMsgID: strings.TrimSpace(item.MsgID),
|
||||
Direction: string(direction),
|
||||
Origin: int(item.Origin),
|
||||
OpenKfID: strings.TrimSpace(item.OpenKFID),
|
||||
ExternalUserID: strings.TrimSpace(item.ExternalUserID),
|
||||
SendStatus: string(sendStatus),
|
||||
RawPayload: string(item.OriginData),
|
||||
Status: enums.StatusOk,
|
||||
AuditFields: models.AuditFields{
|
||||
CreatedAt: now,
|
||||
CreateUserID: 0,
|
||||
CreateUserName: wxWorkKFSystemOperatorName,
|
||||
UpdatedAt: now,
|
||||
UpdateUserID: 0,
|
||||
UpdateUserName: wxWorkKFSystemOperatorName,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *wxWorkKFInboundService) saveNextCursor(openKfID, nextCursor string) error {
|
||||
openKfID = strings.TrimSpace(openKfID)
|
||||
if openKfID == "" {
|
||||
return errorsx.InvalidParam("openKfID不能为空")
|
||||
}
|
||||
now := time.Now()
|
||||
state := WxWorkKFSyncStateService.Take("open_kf_id = ?", openKfID)
|
||||
if state != nil {
|
||||
return WxWorkKFSyncStateService.Updates(state.ID, map[string]any{
|
||||
"next_cursor": strings.TrimSpace(nextCursor),
|
||||
"last_sync_at": now,
|
||||
"updated_at": now,
|
||||
"status": enums.StatusOk,
|
||||
})
|
||||
}
|
||||
return WxWorkKFSyncStateService.Create(&models.WxWorkKFSyncState{
|
||||
OpenKfID: openKfID,
|
||||
NextCursor: strings.TrimSpace(nextCursor),
|
||||
LastSyncAt: &now,
|
||||
Status: enums.StatusOk,
|
||||
AuditFields: models.AuditFields{
|
||||
CreatedAt: now,
|
||||
CreateUserID: 0,
|
||||
CreateUserName: wxWorkKFSystemOperatorName,
|
||||
UpdatedAt: now,
|
||||
UpdateUserID: 0,
|
||||
UpdateUserName: wxWorkKFSystemOperatorName,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *wxWorkKFInboundService) appendConversationEvent(conversationID int64, content, payload string) error {
|
||||
return ConversationEventLogService.Create(&models.ConversationEventLog{
|
||||
ConversationID: conversationID,
|
||||
EventType: enums.IMEventTypeWxWorkKFEvent,
|
||||
OperatorType: enums.IMSenderTypeSystem,
|
||||
OperatorID: 0,
|
||||
Content: strings.TrimSpace(content),
|
||||
Payload: strings.TrimSpace(payload),
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *wxWorkKFInboundService) buildInboundAssetPayload(conversationID int64, mediaID string) (string, string, error) {
|
||||
mediaID = strings.TrimSpace(mediaID)
|
||||
if mediaID == "" {
|
||||
return "", "", errorsx.InvalidParam("企业微信媒体ID不能为空")
|
||||
}
|
||||
materialCli := wxwork.GetWorkCli().GetMaterial()
|
||||
data, err := materialCli.GetTempFile(mediaID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
asset, err := AssetService.UploadBytes(data, "", "", nil)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
canonicalPayload, err := buildIMMessageAssetPayload(asset)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
content := strings.TrimSpace(asset.Filename)
|
||||
if strs.IsBlank(content) {
|
||||
content = "[文件]"
|
||||
}
|
||||
return canonicalPayload, content, nil
|
||||
}
|
||||
|
||||
func (s *wxWorkKFInboundService) getChannelByOpenKfID(openKfID string) (*models.Channel, error) {
|
||||
openKfID = strings.TrimSpace(openKfID)
|
||||
if openKfID == "" {
|
||||
return nil, errorsx.InvalidParam("企业微信 openKfID 不能为空")
|
||||
}
|
||||
channel := ChannelService.GetEnabledWxWorkKFChannelByOpenKfID(openKfID)
|
||||
if channel == nil {
|
||||
return nil, errorsx.InvalidParam("未找到匹配的企业微信接入渠道")
|
||||
}
|
||||
if channel.AIAgentID <= 0 {
|
||||
return nil, errorsx.InvalidParam("企业微信接入渠道未绑定AI Agent")
|
||||
}
|
||||
agent := AIAgentService.Get(channel.AIAgentID)
|
||||
if agent == nil || agent.Status != enums.StatusOk {
|
||||
return nil, errorsx.InvalidParam("企业微信接入渠道绑定的AI Agent不存在或已禁用")
|
||||
}
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
func (s *wxWorkKFInboundService) buildExternalInfo(externalUserID string) openidentity.ExternalInfo {
|
||||
return openidentity.ExternalInfo{
|
||||
ExternalSource: enums.ExternalSourceWxWorkKF,
|
||||
ExternalID: strings.TrimSpace(externalUserID),
|
||||
ExternalName: strings.TrimSpace(externalUserID),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *wxWorkKFInboundService) buildInboundClientMsgID(wxMsgID string) string {
|
||||
return "wxwork_kf:" + strings.TrimSpace(wxMsgID)
|
||||
}
|
||||
|
||||
func (s *wxWorkKFInboundService) buildUnsupportedContent(msgType string) string {
|
||||
switch strings.TrimSpace(msgType) {
|
||||
case "voice":
|
||||
return "[语音]"
|
||||
case "video":
|
||||
return "[视频]"
|
||||
case "location":
|
||||
return "[位置]"
|
||||
case "link":
|
||||
return "[链接]"
|
||||
case "business_card":
|
||||
return "[名片]"
|
||||
case "miniprogram":
|
||||
return "[小程序]"
|
||||
default:
|
||||
return "[" + strings.TrimSpace(msgType) + "]"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *wxWorkKFInboundService) parseSendTime(sendTime uint64) *time.Time {
|
||||
if sendTime == 0 {
|
||||
return nil
|
||||
}
|
||||
t := time.Unix(int64(sendTime), 0)
|
||||
return &t
|
||||
}
|
||||
|
||||
func (s *wxWorkKFInboundService) mustMarshal(value any) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func (s *wxWorkKFInboundService) parseBaseMessage(raw []byte) (syncmsg.BaseMessage, error) {
|
||||
base := syncmsg.BaseMessage{}
|
||||
err := json.Unmarshal(raw, &base)
|
||||
return base, err
|
||||
}
|
||||
|
||||
func (s *wxWorkKFInboundService) normalizeEventBaseMessage(base syncmsg.BaseMessage, openKfID, externalUserID string) syncmsg.BaseMessage {
|
||||
base.OpenKFID = strings.TrimSpace(openKfID)
|
||||
base.ExternalUserID = strings.TrimSpace(externalUserID)
|
||||
return base
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/config"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"cs-agent/internal/pkg/utils"
|
||||
"cs-agent/internal/repositories"
|
||||
"cs-agent/internal/wxwork"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"github.com/silenceper/wechat/v2/work/kf/sendmsg"
|
||||
)
|
||||
|
||||
const (
|
||||
wxWorkKFOutboxBatchSize = 20
|
||||
wxWorkKFOutboxMaxRetry = 6
|
||||
)
|
||||
|
||||
var WxWorkKFOutboundService = newWxWorkKFOutboundService()
|
||||
|
||||
func newWxWorkKFOutboundService() *wxWorkKFOutboundService {
|
||||
return &wxWorkKFOutboundService{}
|
||||
}
|
||||
|
||||
type wxWorkKFOutboundService struct {
|
||||
}
|
||||
|
||||
type wxWorkKFOutboundChunk struct {
|
||||
MessageType enums.IMMessageType
|
||||
Content string
|
||||
AssetID string
|
||||
}
|
||||
|
||||
func (s *wxWorkKFOutboundService) DispatchPendingOutbox() int {
|
||||
if !wxwork.Enabled() {
|
||||
return 0
|
||||
}
|
||||
|
||||
var totalCount int = 0
|
||||
for {
|
||||
count := s.doDispatchPendingOutbox(wxWorkKFOutboxBatchSize)
|
||||
|
||||
totalCount += count
|
||||
slog.Info("wxwork kf outbound dispatch loop",
|
||||
"batch_count", count,
|
||||
"total_count", totalCount,
|
||||
)
|
||||
|
||||
if count == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return totalCount
|
||||
}
|
||||
|
||||
func (s *wxWorkKFOutboundService) doDispatchPendingOutbox(limit int) int {
|
||||
if !wxwork.Enabled() {
|
||||
return 0
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = wxWorkKFOutboxBatchSize
|
||||
}
|
||||
|
||||
items := ChannelMessageOutboxService.ListPending(enums.ChannelTypeWxWorkKF, limit)
|
||||
if len(items) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
successCount := 0
|
||||
for i := range items {
|
||||
if err := s.processOutbox(items[i].ID); err != nil {
|
||||
slog.Warn("process wxwork kf outbox failed",
|
||||
"outbox_id", items[i].ID,
|
||||
"conversation_id", items[i].ConversationID,
|
||||
"message_id", items[i].MessageID,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
successCount++
|
||||
}
|
||||
return successCount
|
||||
}
|
||||
|
||||
func (s *wxWorkKFOutboundService) processOutbox(outboxID int64) error {
|
||||
outbox := ChannelMessageOutboxService.Get(outboxID)
|
||||
if outbox == nil {
|
||||
return nil
|
||||
}
|
||||
if outbox.ChannelType != enums.ChannelTypeWxWorkKF {
|
||||
return nil
|
||||
}
|
||||
if outbox.SendStatus == string(enums.ChannelMessageOutboxStatusSent) {
|
||||
return nil
|
||||
}
|
||||
if outbox.NextRetryAt != nil && outbox.NextRetryAt.After(time.Now()) {
|
||||
return nil
|
||||
}
|
||||
|
||||
slog.Info("processing wxwork kf outbox",
|
||||
"outbox_id", outbox.ID,
|
||||
"conversation_id", outbox.ConversationID,
|
||||
"message_id", outbox.MessageID,
|
||||
"send_status", outbox.SendStatus,
|
||||
"retry_count", outbox.RetryCount,
|
||||
)
|
||||
|
||||
now := time.Now()
|
||||
if err := ChannelMessageOutboxService.Updates(outbox.ID, map[string]any{
|
||||
"send_status": string(enums.ChannelMessageOutboxStatusSending),
|
||||
"updated_at": now,
|
||||
"update_user_id": outbox.UpdateUserID,
|
||||
"update_user_name": outbox.UpdateUserName,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
message := MessageService.Get(outbox.MessageID)
|
||||
if message == nil {
|
||||
return s.markOutboxFailed(outbox, "平台消息不存在")
|
||||
}
|
||||
conversation := ConversationService.Get(outbox.ConversationID)
|
||||
if conversation == nil {
|
||||
return s.markOutboxFailed(outbox, "平台会话不存在")
|
||||
}
|
||||
mapping := WxWorkKFConversationService.Take("conversation_id = ?", conversation.ID)
|
||||
if mapping == nil {
|
||||
return s.markOutboxFailed(outbox, "企业微信会话映射不存在")
|
||||
}
|
||||
if mapping.ChannelID <= 0 {
|
||||
return s.markOutboxFailed(outbox, "企业微信会话映射缺少渠道ID")
|
||||
}
|
||||
channel := ChannelService.Get(mapping.ChannelID)
|
||||
if channel == nil || channel.Status != enums.StatusOk || channel.ChannelType != enums.ChannelTypeWxWorkKF {
|
||||
return s.markOutboxFailed(outbox, "企业微信接入渠道不存在、未启用或类型不匹配")
|
||||
}
|
||||
if strings.TrimSpace(mapping.OpenKfID) == "" || strings.TrimSpace(mapping.ExternalUserID) == "" {
|
||||
return s.markOutboxFailed(outbox, "企业微信会话映射缺少发送必要参数")
|
||||
}
|
||||
chunks, buildErr := s.buildOutboundChunks(message)
|
||||
if buildErr != nil {
|
||||
return s.markOutboxFailed(outbox, buildErr.Error())
|
||||
}
|
||||
if len(chunks) == 0 {
|
||||
return s.markOutboxFailed(outbox, "当前消息无法转换为企业微信下行消息")
|
||||
}
|
||||
|
||||
slog.Info("built wxwork outbound chunks",
|
||||
"outbox_id", outbox.ID,
|
||||
"conversation_id", conversation.ID,
|
||||
"message_id", message.ID,
|
||||
"sender_type", message.SenderType,
|
||||
"message_type", message.MessageType,
|
||||
"chunk_count", len(chunks),
|
||||
"open_kfid", mapping.OpenKfID,
|
||||
"external_userid", mapping.ExternalUserID,
|
||||
)
|
||||
|
||||
wxMsgIDs := make([]string, 0, len(chunks))
|
||||
for i := range chunks {
|
||||
wxMsgID, sendErr := s.sendOutboundChunk(mapping, message, chunks[i], i)
|
||||
if sendErr != nil {
|
||||
return s.markOutboxFailed(outbox, sendErr.Error())
|
||||
}
|
||||
wxMsgIDs = append(wxMsgIDs, wxMsgID)
|
||||
}
|
||||
|
||||
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
now = time.Now()
|
||||
if err := repositories.ChannelMessageOutboxRepository.Updates(ctx.Tx, outbox.ID, map[string]any{
|
||||
"send_status": string(enums.ChannelMessageOutboxStatusSent),
|
||||
"sent_at": now,
|
||||
"last_error": "",
|
||||
"updated_at": now,
|
||||
"update_user_id": outbox.UpdateUserID,
|
||||
"update_user_name": outbox.UpdateUserName,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if existing := repositories.WxWorkKFMessageRefRepository.Take(ctx.Tx, "message_id = ? AND direction = ?", message.ID, string(enums.WxWorkKFMessageDirectionOut)); existing == nil {
|
||||
for i := range wxMsgIDs {
|
||||
rawPayload := strings.TrimSpace(outbox.Payload)
|
||||
if len(chunks) > i {
|
||||
if payload, err := json.Marshal(map[string]any{
|
||||
"messageId": message.ID,
|
||||
"chunkIndex": i,
|
||||
"chunkType": chunks[i].MessageType,
|
||||
"chunkText": strings.TrimSpace(chunks[i].Content),
|
||||
"chunkAssetId": strings.TrimSpace(chunks[i].AssetID),
|
||||
}); err == nil {
|
||||
rawPayload = string(payload)
|
||||
}
|
||||
}
|
||||
if err := repositories.WxWorkKFMessageRefRepository.Create(ctx.Tx, &models.WxWorkKFMessageRef{
|
||||
ConversationID: conversation.ID,
|
||||
MessageID: message.ID,
|
||||
WxMsgID: strings.TrimSpace(wxMsgIDs[i]),
|
||||
Direction: string(enums.WxWorkKFMessageDirectionOut),
|
||||
Origin: 0,
|
||||
OpenKfID: mapping.OpenKfID,
|
||||
ExternalUserID: mapping.ExternalUserID,
|
||||
SendStatus: string(enums.WxWorkKFMessageSendStatusSent),
|
||||
RawPayload: rawPayload,
|
||||
Status: enums.StatusOk,
|
||||
AuditFields: models.AuditFields{
|
||||
CreatedAt: now,
|
||||
CreateUserID: outbox.UpdateUserID,
|
||||
CreateUserName: outbox.UpdateUserName,
|
||||
UpdatedAt: now,
|
||||
UpdateUserID: outbox.UpdateUserID,
|
||||
UpdateUserName: outbox.UpdateUserName,
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return ConversationEventLogService.CreateEvent(ctx, conversation.ID, enums.IMEventTypeWxWorkKFOutbound, message.SenderType, message.SenderID, fmt.Sprintf("企业微信消息发送成功,共%d条", len(wxMsgIDs)), "")
|
||||
})
|
||||
}
|
||||
|
||||
func (s *wxWorkKFOutboundService) sendOutboundChunk(mapping *models.WxWorkKFConversation, message *models.Message, chunk wxWorkKFOutboundChunk, chunkIndex int) (string, error) {
|
||||
switch chunk.MessageType {
|
||||
case enums.IMMessageTypeText:
|
||||
return s.sendTextMessage(mapping, message, chunk.Content, chunkIndex)
|
||||
case enums.IMMessageTypeImage:
|
||||
return s.sendImageMessage(mapping, message, chunk, chunkIndex)
|
||||
default:
|
||||
return "", fmt.Errorf("不支持的企业微信下行消息类型: %s", chunk.MessageType)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *wxWorkKFOutboundService) sendTextMessage(mapping *models.WxWorkKFConversation, message *models.Message, content string, chunkIndex int) (string, error) {
|
||||
cli, err := wxwork.GetWorkCli().GetKF()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req := sendmsg.Text{}
|
||||
req.Message.ToUser = strings.TrimSpace(mapping.ExternalUserID)
|
||||
req.Message.OpenKFID = strings.TrimSpace(mapping.OpenKfID)
|
||||
req.Message.MsgID = s.buildOutboundClientMsgID(message.ID, chunkIndex)
|
||||
req.MsgType = "text"
|
||||
req.Text.Content = strings.TrimSpace(content)
|
||||
|
||||
slog.Info("sending wxwork text message",
|
||||
"conversation_id", message.ConversationID,
|
||||
"message_id", message.ID,
|
||||
"chunk_index", chunkIndex,
|
||||
"client_msg_id", req.Message.MsgID,
|
||||
"open_kfid", req.Message.OpenKFID,
|
||||
"external_userid", req.Message.ToUser,
|
||||
"content_length", len([]rune(req.Text.Content)),
|
||||
)
|
||||
|
||||
resp, err := cli.SendMsg(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(resp.MsgID) == "" {
|
||||
return "", fmt.Errorf("企业微信返回的消息ID为空")
|
||||
}
|
||||
slog.Info("wxwork text message accepted",
|
||||
"conversation_id", message.ConversationID,
|
||||
"message_id", message.ID,
|
||||
"chunk_index", chunkIndex,
|
||||
"client_msg_id", req.Message.MsgID,
|
||||
"wx_msg_id", strings.TrimSpace(resp.MsgID),
|
||||
"open_kfid", req.Message.OpenKFID,
|
||||
"external_userid", req.Message.ToUser,
|
||||
)
|
||||
return strings.TrimSpace(resp.MsgID), nil
|
||||
}
|
||||
|
||||
func (s *wxWorkKFOutboundService) sendImageMessage(mapping *models.WxWorkKFConversation, message *models.Message, chunk wxWorkKFOutboundChunk, chunkIndex int) (string, error) {
|
||||
if strings.TrimSpace(chunk.AssetID) == "" {
|
||||
return "", fmt.Errorf("图片消息缺少 assetId")
|
||||
}
|
||||
|
||||
asset := AssetService.GetByAssetID(chunk.AssetID)
|
||||
if asset == nil {
|
||||
return "", fmt.Errorf("图片资源不存在")
|
||||
}
|
||||
fileReader, err := AssetService.OpenReader(asset)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() {
|
||||
if fileReader != nil {
|
||||
_ = fileReader.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
slog.Info("sending wxwork image message",
|
||||
"conversation_id", message.ConversationID,
|
||||
"message_id", message.ID,
|
||||
"chunk_index", chunkIndex,
|
||||
"asset_id", asset.AssetID,
|
||||
"filename", asset.Filename,
|
||||
"storage_key", asset.StorageKey,
|
||||
"open_kfid", mapping.OpenKfID,
|
||||
"external_userid", mapping.ExternalUserID,
|
||||
)
|
||||
|
||||
materialCli := wxwork.GetWorkCli().GetMaterial()
|
||||
uploadResp, err := materialCli.UploadTempFileFromReader(asset.Filename, "image", fileReader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(uploadResp.MediaID) == "" {
|
||||
return "", fmt.Errorf("企业微信返回的图片 media_id 为空")
|
||||
}
|
||||
|
||||
kfCli, err := wxwork.GetWorkCli().GetKF()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req := sendmsg.Image{
|
||||
Message: sendmsg.Message{
|
||||
ToUser: strings.TrimSpace(mapping.ExternalUserID),
|
||||
OpenKFID: strings.TrimSpace(mapping.OpenKfID),
|
||||
MsgID: s.buildOutboundClientMsgID(message.ID, chunkIndex),
|
||||
},
|
||||
MsgType: "image",
|
||||
}
|
||||
req.Image.MediaID = strings.TrimSpace(uploadResp.MediaID)
|
||||
|
||||
resp, err := kfCli.SendMsg(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(resp.MsgID) == "" {
|
||||
return "", fmt.Errorf("企业微信返回的消息ID为空")
|
||||
}
|
||||
slog.Info("wxwork image message accepted",
|
||||
"conversation_id", message.ConversationID,
|
||||
"message_id", message.ID,
|
||||
"chunk_index", chunkIndex,
|
||||
"client_msg_id", req.Message.MsgID,
|
||||
"wx_msg_id", strings.TrimSpace(resp.MsgID),
|
||||
"media_id", req.Image.MediaID,
|
||||
"asset_id", asset.AssetID,
|
||||
"open_kfid", req.Message.OpenKFID,
|
||||
"external_userid", req.Message.ToUser,
|
||||
)
|
||||
return strings.TrimSpace(resp.MsgID), nil
|
||||
}
|
||||
|
||||
func (s *wxWorkKFOutboundService) markOutboxFailed(outbox *models.ChannelMessageOutbox, errMsg string) error {
|
||||
if outbox == nil {
|
||||
return nil
|
||||
}
|
||||
slog.Warn("mark wxwork kf outbox failed",
|
||||
"outbox_id", outbox.ID,
|
||||
"conversation_id", outbox.ConversationID,
|
||||
"message_id", outbox.MessageID,
|
||||
"retry_count", outbox.RetryCount+1,
|
||||
"error", strings.TrimSpace(errMsg),
|
||||
)
|
||||
now := time.Now()
|
||||
retryCount := outbox.RetryCount + 1
|
||||
nextRetryAt := s.nextRetryAt(retryCount)
|
||||
status := string(enums.ChannelMessageOutboxStatusFailed)
|
||||
if retryCount >= wxWorkKFOutboxMaxRetry {
|
||||
nextRetryAt = nil
|
||||
}
|
||||
return ChannelMessageOutboxService.Updates(outbox.ID, map[string]any{
|
||||
"send_status": status,
|
||||
"retry_count": retryCount,
|
||||
"next_retry_at": nextRetryAt,
|
||||
"last_error": strings.TrimSpace(errMsg),
|
||||
"updated_at": now,
|
||||
"update_user_id": outbox.UpdateUserID,
|
||||
"update_user_name": outbox.UpdateUserName,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *wxWorkKFOutboundService) nextRetryAt(retryCount int) *time.Time {
|
||||
delay := time.Minute
|
||||
switch {
|
||||
case retryCount <= 1:
|
||||
delay = 30 * time.Second
|
||||
case retryCount == 2:
|
||||
delay = time.Minute
|
||||
case retryCount == 3:
|
||||
delay = 2 * time.Minute
|
||||
default:
|
||||
delay = 5 * time.Minute
|
||||
}
|
||||
t := time.Now().Add(delay)
|
||||
return &t
|
||||
}
|
||||
|
||||
func (s *wxWorkKFOutboundService) buildOutboundClientMsgID(messageID int64, chunkIndex int) string {
|
||||
return fmt.Sprintf("outbox_wxwork_kf_%d_%d", messageID, chunkIndex)
|
||||
}
|
||||
|
||||
type wxWorkKFOutboundPayload struct {
|
||||
ConversationID int64 `json:"conversationId"`
|
||||
MessageID int64 `json:"messageId"`
|
||||
MessageType enums.IMMessageType `json:"messageType"`
|
||||
Content string `json:"content"`
|
||||
Payload string `json:"payload"`
|
||||
SenderID int64 `json:"senderId"`
|
||||
}
|
||||
|
||||
func (s *wxWorkKFOutboundService) parseOutboxPayload(raw string) (*wxWorkKFOutboundPayload, error) {
|
||||
payload := &wxWorkKFOutboundPayload{}
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func (s *wxWorkKFOutboundService) buildOutboundChunks(message *models.Message) ([]wxWorkKFOutboundChunk, error) {
|
||||
if message == nil {
|
||||
return nil, fmt.Errorf("平台消息不存在")
|
||||
}
|
||||
switch message.MessageType {
|
||||
case enums.IMMessageTypeText:
|
||||
content := strings.TrimSpace(message.Content)
|
||||
if content == "" {
|
||||
return nil, fmt.Errorf("文本消息内容为空")
|
||||
}
|
||||
return []wxWorkKFOutboundChunk{{MessageType: enums.IMMessageTypeText, Content: content}}, nil
|
||||
case enums.IMMessageTypeHTML:
|
||||
return s.buildHTMLChunks(message.Content)
|
||||
default:
|
||||
return nil, fmt.Errorf("当前暂不支持企业微信下行消息类型: %s", message.MessageType)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *wxWorkKFOutboundService) buildHTMLChunks(content string) ([]wxWorkKFOutboundChunk, error) {
|
||||
contentChunks, err := utils.SplitHTMLContentChunks(content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
chunks := make([]wxWorkKFOutboundChunk, 0, len(contentChunks))
|
||||
for _, chunk := range contentChunks {
|
||||
switch chunk.Type {
|
||||
case utils.ContentChunkTypeText:
|
||||
text := strings.TrimSpace(chunk.Content)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
chunks = append(chunks, wxWorkKFOutboundChunk{
|
||||
MessageType: enums.IMMessageTypeText,
|
||||
Content: text,
|
||||
})
|
||||
case utils.ContentChunkTypeImage:
|
||||
assetID, resolveErr := s.resolveAssetIDFromImageSrc(chunk.Content)
|
||||
if resolveErr != nil {
|
||||
chunks = append(chunks, wxWorkKFOutboundChunk{
|
||||
MessageType: enums.IMMessageTypeText,
|
||||
Content: "[图片]",
|
||||
})
|
||||
continue
|
||||
}
|
||||
chunks = append(chunks, wxWorkKFOutboundChunk{
|
||||
MessageType: enums.IMMessageTypeImage,
|
||||
AssetID: assetID,
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(chunks) == 0 {
|
||||
return nil, fmt.Errorf("HTML 消息内容为空")
|
||||
}
|
||||
return chunks, nil
|
||||
}
|
||||
|
||||
func (s *wxWorkKFOutboundService) resolveAssetIDFromImageSrc(src string) (string, error) {
|
||||
cfg := config.Current()
|
||||
storageKey, err := resolveStorageKeyFromAssetURL(strings.TrimSpace(cfg.Storage.Local.BaseURL), src)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
asset := AssetService.GetByStorageKey(storageKey)
|
||||
if asset == nil {
|
||||
return "", fmt.Errorf("未找到图片资源")
|
||||
}
|
||||
return strings.TrimSpace(asset.AssetID), nil
|
||||
}
|
||||
|
||||
func resolveStorageKeyFromAssetURL(baseURL, rawURL string) (string, error) {
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
if baseURL == "" || rawURL == "" {
|
||||
return "", fmt.Errorf("图片URL不合法")
|
||||
}
|
||||
if strings.HasPrefix(rawURL, baseURL+"/") {
|
||||
return strings.TrimLeft(strings.TrimPrefix(rawURL, baseURL), "/"), nil
|
||||
}
|
||||
|
||||
baseParsed, baseErr := url.Parse(baseURL)
|
||||
rawParsed, rawErr := url.Parse(rawURL)
|
||||
if baseErr != nil || rawErr != nil {
|
||||
return "", fmt.Errorf("图片URL不合法")
|
||||
}
|
||||
if !strings.EqualFold(baseParsed.Host, rawParsed.Host) {
|
||||
return "", fmt.Errorf("图片URL不属于当前存储域名")
|
||||
}
|
||||
basePath := strings.TrimRight(baseParsed.Path, "/")
|
||||
rawPath := strings.TrimLeft(rawParsed.Path, "/")
|
||||
if basePath == "" {
|
||||
return rawPath, nil
|
||||
}
|
||||
basePath = strings.TrimLeft(basePath, "/")
|
||||
if !strings.HasPrefix(rawPath, basePath+"/") {
|
||||
return "", fmt.Errorf("图片URL不属于当前存储目录")
|
||||
}
|
||||
return strings.TrimLeft(strings.TrimPrefix(rawPath, basePath), "/"), nil
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"cs-agent/internal/models"
|
||||
"cs-agent/internal/pkg/config"
|
||||
"cs-agent/internal/pkg/dto/response"
|
||||
"cs-agent/internal/pkg/enums"
|
||||
"cs-agent/internal/pkg/errorsx"
|
||||
"cs-agent/internal/repositories"
|
||||
"cs-agent/internal/wxwork"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mlogclub/simple/common/jsons"
|
||||
"github.com/mlogclub/simple/common/strs"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var WxWorkLoginService = &wxWorkLoginService{}
|
||||
|
||||
type wxWorkLoginService struct {
|
||||
}
|
||||
|
||||
func (s *wxWorkLoginService) BuildWxWorkLoginURL(next string) (string, error) {
|
||||
if !wxwork.Enabled() {
|
||||
return "", errorsx.BusinessError(1, "企业微信登录未启用")
|
||||
}
|
||||
state, err := wxwork.CreateState(next)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return wxwork.BuildLoginURL(state)
|
||||
}
|
||||
|
||||
func (s *wxWorkLoginService) BuildWxWorkQRCodeLoginURL(next string) (string, error) {
|
||||
if !wxwork.Enabled() {
|
||||
return "", errorsx.BusinessError(1, "企业微信登录未启用")
|
||||
}
|
||||
state, err := wxwork.CreateState(next)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return wxwork.BuildQRCodeLoginURL(state)
|
||||
}
|
||||
|
||||
func (s *wxWorkLoginService) LoginByWxWork(code, state string, authCfg config.AuthConfig, clientIP, userAgent string) (string, string, error) {
|
||||
next, err := wxwork.ParseState(state)
|
||||
if err != nil {
|
||||
return "", "", errorsx.Unauthorized("企业微信登录状态无效或已过期")
|
||||
}
|
||||
profile, err := wxwork.GetUserDetail(code)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
loginResp, err := s.loginWithWxWorkProfile(profile, authCfg, clientIP, userAgent)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
ticket, err := wxwork.IssueLoginTicket(loginResp)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return ticket, next, nil
|
||||
}
|
||||
|
||||
func (s *wxWorkLoginService) ExchangeWxWorkLoginTicket(ticket string) (*response.LoginResponse, error) {
|
||||
return wxwork.ConsumeLoginTicket(ticket)
|
||||
}
|
||||
|
||||
func (s *wxWorkLoginService) loginWithWxWorkProfile(profile *wxwork.LoginUser, authCfg config.AuthConfig, clientIP, userAgent string) (*response.LoginResponse, error) {
|
||||
if profile == nil || strings.TrimSpace(profile.UserID) == "" {
|
||||
return nil, errorsx.BusinessError(2, "企业微信用户信息不存在")
|
||||
}
|
||||
|
||||
var ret *response.LoginResponse
|
||||
err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
var (
|
||||
identity = repositories.UserIdentityRepository.GetBy(ctx.Tx, enums.ThirdProviderWxWork, profile.CorpID, profile.UserID)
|
||||
user *models.User
|
||||
err error
|
||||
)
|
||||
if identity == nil {
|
||||
user, identity, err = s.createWxWorkUser(ctx, profile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if identity.Status != enums.StatusOk {
|
||||
return errorsx.BusinessError(3, "当前企业微信绑定已停用")
|
||||
}
|
||||
user = repositories.UserRepository.Get(ctx.Tx, identity.UserID)
|
||||
if user == nil {
|
||||
return errorsx.BusinessError(4, "企业微信账号绑定的系统用户不存在")
|
||||
}
|
||||
}
|
||||
|
||||
if user.Status != enums.StatusOk {
|
||||
return errorsx.Unauthorized("当前系统账号已被禁用")
|
||||
}
|
||||
|
||||
if err = repositories.UserRepository.Updates(ctx.Tx, user.ID, map[string]any{
|
||||
"nickname": s.resolveWxWorkNickname(user.Nickname, profile),
|
||||
"avatar": s.resolveWxWorkAvatar(user.Avatar, profile),
|
||||
"last_login_at": time.Now(),
|
||||
"last_login_ip": clientIP,
|
||||
"update_user_id": user.ID,
|
||||
"update_user_name": user.Username,
|
||||
"updated_at": time.Now(),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = repositories.UserIdentityRepository.Updates(ctx.Tx, identity.ID, map[string]any{
|
||||
"raw_profile": jsons.ToJsonStr(profile),
|
||||
"last_auth_at": time.Now(),
|
||||
"status": enums.StatusOk,
|
||||
"update_user_id": user.ID,
|
||||
"update_user_name": user.Username,
|
||||
"updated_at": time.Now(),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ret, err = AuthService.issueTokens(ctx, user, clientIP, userAgent, authCfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (s *wxWorkLoginService) createWxWorkUser(ctx *sqls.TxContext, profile *wxwork.LoginUser) (*models.User, *models.UserIdentity, error) {
|
||||
username := strings.TrimSpace(profile.UserID)
|
||||
mobile := strings.TrimSpace(profile.Mobile)
|
||||
email := strings.TrimSpace(s.firstNonEmpty(profile.Email, profile.BizMail))
|
||||
|
||||
if err := s.checkWxWorkProfile(ctx.Tx, username, mobile, email); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
user := &models.User{
|
||||
Username: username,
|
||||
Nickname: s.resolveWxWorkNickname("", profile),
|
||||
Avatar: s.resolveWxWorkAvatar("", profile),
|
||||
Password: "",
|
||||
PasswordSalt: "",
|
||||
Status: enums.StatusOk,
|
||||
AuditFields: models.AuditFields{
|
||||
CreatedAt: time.Now(),
|
||||
CreateUserID: 0,
|
||||
CreateUserName: enums.GetThirdProviderLabel(enums.ThirdProviderWxWork),
|
||||
UpdatedAt: time.Now(),
|
||||
UpdateUserID: 0,
|
||||
UpdateUserName: enums.GetThirdProviderLabel(enums.ThirdProviderWxWork),
|
||||
},
|
||||
}
|
||||
if err := repositories.UserRepository.Create(ctx.Tx, user); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
identity := &models.UserIdentity{
|
||||
UserID: user.ID,
|
||||
Provider: enums.ThirdProviderWxWork,
|
||||
ProviderUserID: strings.TrimSpace(profile.UserID),
|
||||
ProviderCorpID: strings.TrimSpace(profile.CorpID),
|
||||
ProviderName: enums.GetThirdProviderLabel(enums.ThirdProviderWxWork),
|
||||
RawProfile: jsons.ToJsonStr(profile),
|
||||
Status: enums.StatusOk,
|
||||
LastAuthAt: new(time.Now()),
|
||||
AuditFields: models.AuditFields{
|
||||
CreatedAt: time.Now(),
|
||||
CreateUserID: user.ID,
|
||||
CreateUserName: user.Username,
|
||||
UpdatedAt: time.Now(),
|
||||
UpdateUserID: user.ID,
|
||||
UpdateUserName: user.Username,
|
||||
},
|
||||
}
|
||||
if unionID := strings.TrimSpace(profile.OpenID); unionID != "" {
|
||||
identity.ProviderUnionID = &unionID
|
||||
}
|
||||
if err := repositories.UserIdentityRepository.Create(ctx.Tx, identity); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return user, identity, nil
|
||||
}
|
||||
|
||||
func (s *wxWorkLoginService) resolveWxWorkNickname(current string, profile *wxwork.LoginUser) string {
|
||||
if profile != nil {
|
||||
if name := strings.TrimSpace(profile.Name); name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
if current = strings.TrimSpace(current); current != "" {
|
||||
return current
|
||||
}
|
||||
if profile != nil {
|
||||
return strings.TrimSpace(profile.UserID)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *wxWorkLoginService) resolveWxWorkAvatar(current string, profile *wxwork.LoginUser) string {
|
||||
if profile != nil {
|
||||
if avatar := strings.TrimSpace(profile.Avatar); avatar != "" {
|
||||
return avatar
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(current)
|
||||
}
|
||||
|
||||
func (s *wxWorkLoginService) checkWxWorkProfile(tx *gorm.DB, username, mobile string, email string) error {
|
||||
if strs.IsBlank(username) {
|
||||
return errorsx.BusinessError(5, "企业微信用户ID获取失败")
|
||||
}
|
||||
if existing := repositories.UserRepository.GetByUsername(tx, username); existing != nil {
|
||||
return errorsx.BusinessError(5, "企业微信用户ID已被系统用户名占用")
|
||||
}
|
||||
if strs.IsNotBlank(mobile) {
|
||||
if repositories.UserRepository.GetByMobile(tx, mobile) != nil {
|
||||
return errorsx.BusinessError(6, "企业微信手机号已被系统用户占用")
|
||||
}
|
||||
}
|
||||
if strs.IsNotBlank(email) {
|
||||
if repositories.UserRepository.GetByEmail(tx, email) != nil {
|
||||
return errorsx.BusinessError(7, "企业微信邮箱已被系统用户占用")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *wxWorkLoginService) firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value = strings.TrimSpace(value); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Reference in New Issue
Block a user