This commit is contained in:
mlogclub
2026-04-09 10:01:23 +08:00
commit efe801b8bf
707 changed files with 110595 additions and 0 deletions
+206
View File
@@ -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)
}
+172
View File
@@ -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
}
+362
View File
@@ -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(),
})
}
+264
View File
@@ -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
}
+5
View File
@@ -0,0 +1,5 @@
package services
import "cs-agent/internal/models"
var TriggerAIReplyAsyncHook func(conversation models.Conversation, message models.Message)
+201
View File
@@ -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
}
+489
View File
@@ -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
}
+15
View File
@@ -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)
}
+264
View File
@@ -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
}
+149
View File
@@ -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
}
+734
View File
@@ -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
}
+49
View File
@@ -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)
}
+272
View File
@@ -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
}
+408
View File
@@ -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
}
+65
View File
@@ -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
}
+236
View File
@@ -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
}
+167
View File
@@ -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 := &params.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
}
+155
View File
@@ -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)
}
+584
View File
@@ -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, " "))
}
+66
View File
@@ -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)
}
+66
View File
@@ -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)
}
+124
View File
@@ -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)
}
+199
View File
@@ -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
}
+23
View File
@@ -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
}
+70
View File
@@ -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)
}
+177
View File
@@ -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), "/")
}
+34
View File
@@ -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("不支持的文件存储类型")
}
}
+85
View File
@@ -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)
}
+268
View File
@@ -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)
}
+84
View File
@@ -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
+851
View File
@@ -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)
}
+104
View File
@@ -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
}
+93
View File
@@ -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)
}
+66
View File
@@ -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)
}
+298
View File
@@ -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)
}
+126
View File
@@ -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)
}
+276
View File
@@ -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
}
+575
View File
@@ -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
}
+245
View File
@@ -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 ""
}