refactor: split backend into standalone server project

Remove embedded frontend and workflow editor assets, add standalone API deployment configuration, and retain the current backend service updates.
This commit is contained in:
t
2026-08-20 21:46:55 +08:00
parent 954a3fe8d9
commit 3d47227fbd
612 changed files with 515 additions and 97334 deletions
+2 -3
View File
@@ -315,9 +315,8 @@ func (s *aIAgentService) buildAIAgentModel(id int64, req request.CreateAIAgentRe
if err != nil {
return nil, err
}
if !slices.Contains(enums.IMConversationServiceModeValues, req.ServiceMode) {
return nil, errorsx.InvalidParamI18n("error.e0230")
}
// AI Agent 只描述 AI 的能力与转人工策略;渠道决定使用人工还是 AI 接待。
req.ServiceMode = enums.IMConversationServiceModeAIFirst
teamIDs, err := s.normalizeTeamIDs(req.TeamIDs)
if err != nil {
return nil, err
@@ -44,6 +44,19 @@ func TestUpdateAIAgentKeepsPublishedRevisionActive(t *testing.T) {
if err := db.Create(config).Error; err != nil {
t.Fatalf("create ai config: %v", err)
}
createdModel, err := AIAgentService.buildAIAgentModel(0, request.CreateAIAgentRequest{
Name: "fixed ai reception agent",
AIConfigID: config.ID,
ServiceMode: enums.IMConversationServiceModeHumanOnly,
HandoffMode: enums.AIAgentHandoffModeWaitPool,
FallbackMode: enums.AIAgentFallbackModeNoAnswer,
})
if err != nil {
t.Fatalf("build ai agent model: %v", err)
}
if createdModel.ServiceMode != enums.IMConversationServiceModeAIFirst {
t.Fatalf("service mode = %d, want AI first", createdModel.ServiceMode)
}
agent := &models.AIAgent{
Name: "published agent", Status: enums.StatusOk, AIConfigID: config.ID,
ServiceMode: enums.IMConversationServiceModeAIFirst, HandoffMode: enums.AIAgentHandoffModeWaitPool,
+1 -1
View File
@@ -340,7 +340,7 @@ func (s *authService) loadUserPermissionCodes(tx *gorm.DB, userID int64) ([]stri
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 {
if err := db.Scan(&permissionRows).Error; err != nil {
return nil, err
}
+48
View File
@@ -368,6 +368,54 @@ func TestAuthServiceLogoutRevokesCurrentTokenOnly(t *testing.T) {
}
}
func TestLoadUserPermissionCodesReturnsSortedDistinctCodes(t *testing.T) {
db := setupAuthServiceTestDB(t)
user := createAuthTestUser(t, db, "admin", "secret")
now := time.Now()
auditFields := models.AuditFields{CreatedAt: now, UpdatedAt: now}
permissions := []models.Permission{
{Name: "Zulu", Code: "z.read", SortNo: 1, Status: enums.StatusOk, AuditFields: auditFields},
{Name: "Alpha", Code: "a.read", SortNo: 2, Status: enums.StatusOk, AuditFields: auditFields},
}
if err := db.Create(&permissions).Error; err != nil {
t.Fatalf("seed permissions: %v", err)
}
roles := []models.Role{
{Name: "Role One", Code: "role_one", Status: enums.StatusOk, AuditFields: auditFields},
{Name: "Role Two", Code: "role_two", Status: enums.StatusOk, AuditFields: auditFields},
}
if err := db.Create(&roles).Error; err != nil {
t.Fatalf("seed roles: %v", err)
}
userRoles := []models.UserRole{
{UserID: user.ID, RoleID: roles[0].ID, AuditFields: auditFields},
{UserID: user.ID, RoleID: roles[1].ID, AuditFields: auditFields},
}
if err := db.Create(&userRoles).Error; err != nil {
t.Fatalf("seed user roles: %v", err)
}
rolePermissions := []models.RolePermission{
{RoleID: roles[0].ID, PermissionID: permissions[0].ID, AuditFields: auditFields},
{RoleID: roles[0].ID, PermissionID: permissions[1].ID, AuditFields: auditFields},
{RoleID: roles[1].ID, PermissionID: permissions[0].ID, AuditFields: auditFields},
}
if err := db.Create(&rolePermissions).Error; err != nil {
t.Fatalf("seed role permissions: %v", err)
}
codes, err := newAuthService().loadUserPermissionCodes(db, user.ID)
if err != nil {
t.Fatalf("load user permission codes: %v", err)
}
if got, want := strings.Join(codes, ","), "a.read,z.read"; got != want {
t.Fatalf("permission codes = %q, want %q", got, want)
}
}
func setupAuthServiceTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{
+8 -9
View File
@@ -423,21 +423,20 @@ func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRe
if name == "" {
return nil, errorsx.InvalidParamI18n("error.e0247")
}
if req.AIAgentID <= 0 {
return nil, errorsx.InvalidParamI18n("error.e0321")
}
if req.AIAgentRolloutPercent == 0 {
req.AIAgentRolloutPercent = 100
}
if req.AIAgentRolloutPercent < 1 || req.AIAgentRolloutPercent > 100 {
return nil, errorsx.InvalidParam("channel ai agent rollout percent must be between 1 and 100")
}
aiAgent := AIAgentService.Get(req.AIAgentID)
if aiAgent == nil || aiAgent.Status != enums.StatusOk {
return nil, errorsx.InvalidParamI18n("error.e0004")
}
if aiAgent.PublishedRevisionID <= 0 {
return nil, errorsx.InvalidParam("ai agent must be published before binding channel")
if req.AIAgentID > 0 {
aiAgent := AIAgentService.Get(req.AIAgentID)
if aiAgent == nil || aiAgent.Status != enums.StatusOk {
return nil, errorsx.InvalidParamI18n("error.e0004")
}
if aiAgent.PublishedRevisionID <= 0 {
return nil, errorsx.InvalidParam("ai agent must be published before binding channel")
}
}
status := enums.Status(req.Status)
if req.Status == 0 {
+17
View File
@@ -48,6 +48,23 @@ func TestChannelServiceAllowsAgentWithPublishedRevision(t *testing.T) {
}
}
func TestChannelServiceAllowsHumanReceptionWithoutAIAgent(t *testing.T) {
setupChannelServiceTestDB(t)
item, err := ChannelService.CreateChannel(request.CreateChannelRequest{
ChannelType: enums.ChannelTypeWeb,
AIAgentID: 0,
Name: "人工客服渠道",
Status: int(enums.StatusOk),
}, channelServiceTestOperator())
if err != nil {
t.Fatalf("create human channel: %v", err)
}
if item == nil || item.AIAgentID != 0 {
t.Fatalf("unexpected human channel: %#v", item)
}
}
func TestChannelServiceStoresAIAgentRolloutPercent(t *testing.T) {
db := setupChannelServiceTestDB(t)
agent := createChannelServiceTestAgent(t, db, 1001)
@@ -116,6 +116,16 @@ func (s *conversationHumanDispatchService) ApplyHumanOnlyCreate(conversationID i
return s.dispatchAfterHandoff(conversationID, aiAgent.ID, activeTeamIDs, "仅人工模式新会话", false)
}
func (s *conversationHumanDispatchService) ApplyHumanChannelCreate(conversationID int64) (*HandoffDecisionResult, error) {
if err := s.moveToGlobalPool(conversationID, "人工客服渠道"); err != nil {
return nil, err
}
if err := s.sendAIText(conversationID, 0, HandoffWaitingMessage); err != nil {
return nil, err
}
return &HandoffDecisionResult{Decision: HandoffDecisionGlobalPool, Message: HandoffWaitingMessage}, nil
}
func (s *conversationHumanDispatchService) DispatchPendingConversation(conversationID int64, aiAgent models.AIAgent) (*HandoffDecisionResult, error) {
conversation := ConversationService.Get(conversationID)
if conversation == nil {
+23 -9
View File
@@ -101,9 +101,14 @@ func (s *conversationService) getLatestNotFinishedByCustomerID(db *gorm.DB, cust
}
func (s *conversationService) Create(externalUser openidentity.ExternalUser, channelID, aiAgentID int64) (*models.Conversation, error) {
aiAgent := AIAgentService.Get(aiAgentID)
if aiAgent == nil || aiAgent.Status != enums.StatusOk {
return nil, errorsx.InvalidParamI18n("error.e0002")
serviceMode := enums.IMConversationServiceModeHumanOnly
var aiAgent *models.AIAgent
if aiAgentID > 0 {
aiAgent = AIAgentService.Get(aiAgentID)
if aiAgent == nil || aiAgent.Status != enums.StatusOk {
return nil, errorsx.InvalidParamI18n("error.e0002")
}
serviceMode = aiAgent.ServiceMode
}
var conversation *models.Conversation
@@ -135,8 +140,8 @@ func (s *conversationService) Create(externalUser openidentity.ExternalUser, cha
ChannelID: channelID,
CustomerID: customerID,
CustomerName: customerName,
Status: s.resolveInitialStatus(aiAgent.ServiceMode),
ServiceMode: aiAgent.ServiceMode,
Status: s.resolveInitialStatus(serviceMode),
ServiceMode: serviceMode,
Priority: 0,
CurrentAssigneeID: 0,
CurrentTeamID: 0,
@@ -153,8 +158,11 @@ func (s *conversationService) Create(externalUser openidentity.ExternalUser, cha
if err := ConversationEventLogService.CreateEvent(ctx, conversation.ID, enums.IMEventTypeCreate, enums.IMSenderTypeCustomer, 0, "用户创建会话", ""); err != nil {
return err
}
welcomeMessage, err = MessageService.createAIWelcomeMessage(ctx, conversation, aiAgent, now)
return err
if aiAgent != nil {
welcomeMessage, err = MessageService.createAIWelcomeMessage(ctx, conversation, aiAgent, now)
return err
}
return nil
}); err != nil {
return nil, err
}
@@ -174,8 +182,14 @@ func (s *conversationService) Create(externalUser openidentity.ExternalUser, cha
}
}
if aiAgent.ServiceMode == enums.IMConversationServiceModeHumanOnly {
if _, err := ConversationHumanDispatchService.ApplyHumanOnlyCreate(conversation.ID, *aiAgent); err != nil {
if serviceMode == enums.IMConversationServiceModeHumanOnly {
var err error
if aiAgent == nil {
_, err = ConversationHumanDispatchService.ApplyHumanChannelCreate(conversation.ID)
} else {
_, err = ConversationHumanDispatchService.ApplyHumanOnlyCreate(conversation.ID, *aiAgent)
}
if err != nil {
return nil, err
}
}
+29
View File
@@ -187,6 +187,35 @@ func TestConversationCreateCreatesAIWelcomeMessage(t *testing.T) {
}
}
func TestConversationCreateAllowsHumanChannelWithoutAIAgent(t *testing.T) {
db := setupMessageWelcomeTestDB(t)
conversation, err := ConversationService.Create(welcomeTestExternalUser("human-channel-1"), 11, 0)
if err != nil {
t.Fatalf("create human channel conversation: %v", err)
}
if conversation == nil {
t.Fatal("expected conversation")
}
if conversation.AIAgentID != 0 {
t.Fatalf("ai agent id = %d, want 0", conversation.AIAgentID)
}
if conversation.ServiceMode != enums.IMConversationServiceModeHumanOnly {
t.Fatalf("service mode = %d, want human only", conversation.ServiceMode)
}
if conversation.Status != enums.IMConversationStatusPending {
t.Fatalf("status = %d, want pending", conversation.Status)
}
var messages []models.Message
if err := db.Where("conversation_id = ?", conversation.ID).Find(&messages).Error; err != nil {
t.Fatalf("find messages: %v", err)
}
if len(messages) != 1 || messages[0].Content != HandoffWaitingMessage {
t.Fatalf("unexpected human channel messages: %#v", messages)
}
}
func TestSendCustomerMessageStoresRequestIDOnMessageAndEvent(t *testing.T) {
db := setupMessageWelcomeTestDB(t)
aiAgent := createWelcomeTestAIAgent(t, db, "")
@@ -548,13 +548,6 @@ func (s *wxWorkKFInboundService) getChannelByOpenKfID(openKfID string) (*models.
if channel == nil {
return nil, errorsx.InvalidParamI18n("error.e0231")
}
if channel.AIAgentID <= 0 {
return nil, errorsx.InvalidParamI18n("error.e0098")
}
agent := AIAgentService.Get(channel.AIAgentID)
if agent == nil || agent.Status != enums.StatusOk {
return nil, errorsx.InvalidParamI18n("error.e0099")
}
return channel, nil
}