fix(conversations): 修复状态切换与历史会话路由

- 处理历史主键与 display_id 冲突,确保消息和状态操作命中同一会话
- 允许已软删除收件箱中的历史会话切换状态
- 解决会话后自动打开列表中的相邻会话
This commit is contained in:
Rogee
2026-08-06 10:08:05 +08:00
parent cb50726620
commit 8f2cc12628
5 changed files with 117 additions and 9 deletions
@@ -799,6 +799,27 @@ func (s *ConversationCrudTestSuite) TestToggleStatus_Success() {
assert.Equal(s.T(), "resolved", resp.Payload.CurrentStatus)
}
func (s *ConversationCrudTestSuite) TestToggleStatus_SoftDeletedInboxStillUpdatesConversation() {
s.Require().NoError(s.db.Delete(s.testInbox).Error)
body, _ := json.Marshal(map[string]interface{}{
"status": "resolved",
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", s.convURL(s.testConv.ID)+"/toggle_status", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
s.Require().Equal(http.StatusOK, w.Code, w.Body.String())
var resp struct {
Payload struct {
CurrentStatus string `json:"current_status"`
} `json:"payload"`
}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
s.Equal("resolved", resp.Payload.CurrentStatus)
}
func (s *ConversationCrudTestSuite) TestToggleStatus_NoStatusTogglesLikeChatwoot() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", s.convURL(s.testConv.ID)+"/toggle_status", nil)
@@ -2,6 +2,7 @@ package repository
import (
"context"
"errors"
"strings"
"time"
@@ -49,14 +50,29 @@ func (r *ConversationRepo) FindByAccountAndID(ctx context.Context, accountID, id
}
// FindByAccountAndDisplayIDOrID retrieves a conversation using Chatwoot's
// account-scoped display_id route semantics, falling back to primary key for
// legacy GoChat data and tests that predate display_id.
// account-scoped display_id route semantics, while preserving routes emitted
// for legacy rows that still have a NULL/zero display_id. Legacy primary keys
// must win before display_id lookup because the two identifier spaces can
// collide after newer conversations receive account-scoped display IDs.
func (r *ConversationRepo) FindByAccountAndDisplayIDOrID(ctx context.Context, accountID, routeID uint) (*model.Conversation, error) {
var conversation model.Conversation
err := r.db.WithContext(ctx).Where("account_id = ? AND display_id = ?", accountID, routeID).First(&conversation).Error
err := r.db.WithContext(ctx).
Where("account_id = ? AND id = ? AND (display_id IS NULL OR display_id = 0)", accountID, routeID).
First(&conversation).Error
if err == nil {
return &conversation, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
err = r.db.WithContext(ctx).Where("account_id = ? AND display_id = ?", accountID, routeID).First(&conversation).Error
if err == nil {
return &conversation, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
err = r.db.WithContext(ctx).Where("account_id = ? AND id = ?", accountID, routeID).First(&conversation).Error
if err != nil {
@@ -70,6 +70,26 @@ func TestCov2_ConversationRepo_FindByAccountAndDisplayIDOrID(t *testing.T) {
assert.Error(t, err)
}
func TestConversationRepo_FindByAccountAndDisplayIDOrID_PrefersLegacyNullDisplayIDPrimaryKey(t *testing.T) {
db := setupTestDB(t)
repo := NewConversationRepo(db)
ctx := context.Background()
account, inbox, contact := createConvAccount(t, db)
legacy := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"}
require.NoError(t, db.Create(legacy).Error)
require.Nil(t, legacy.DisplayID)
collidingDisplayID := legacy.ID
modern := &model.Conversation{DisplayID: &collidingDisplayID, AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: "resolved", ChannelType: "web_widget", Channel: "web_widget"}
require.NoError(t, db.Create(modern).Error)
found, err := repo.FindByAccountAndDisplayIDOrID(ctx, account.ID, legacy.ID)
require.NoError(t, err)
assert.Equal(t, legacy.ID, found.ID)
assert.Nil(t, found.DisplayID)
}
func TestCov2_ConversationRepo_FindByContact(t *testing.T) {
db := setupTestDB(t)
repo := NewConversationRepo(db)
@@ -644,7 +644,7 @@ func (s *ConversationService) ToggleStatus(ctx context.Context, accountID, id ui
func (s *ConversationService) persistShangwutongConversationStatus(ctx context.Context, conversation *model.Conversation, previousStatus string) error {
var inbox model.Inbox
if err := s.repo.DB().WithContext(ctx).Select("id", "channel_type").First(&inbox, conversation.InboxID).Error; err != nil {
if err := s.repo.DB().WithContext(ctx).Unscoped().Select("id", "channel_type").First(&inbox, conversation.InboxID).Error; err != nil {
return err
}
metadata, _ := ctx.Value(shangwutongRequestMetadataKey{}).(shangwutongRequestMetadata)
@@ -1,5 +1,6 @@
<script setup>
import { ref, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useAlert } from 'dashboard/composables';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
@@ -19,9 +20,20 @@ import {
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.vue';
import {
conversationListPageURL,
} from 'dashboard/helper/URLHelper';
import {
isOnMentionsView,
isOnParticipatingView,
isOnUnattendedView,
isOnFoldersView,
} from 'dashboard/store/modules/conversations/helpers/actionHelpers';
const store = useStore();
const getters = useStoreGetters();
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const { checkMissingAttributes } = useConversationRequiredAttributes();
@@ -35,6 +47,30 @@ const openDropdown = () => toggleDropdown(true);
const currentChat = computed(() => getters.getSelectedChat.value);
const redirectToConversationList = () => {
const {
params: { accountId, inbox_id: inboxId, label, teamId, id: customViewId },
name,
} = route;
let conversationType = '';
if (isOnMentionsView({ route: { name } })) conversationType = 'mention';
else if (isOnParticipatingView({ route: { name } })) {
conversationType = 'participating';
} else if (isOnUnattendedView({ route: { name } })) {
conversationType = 'unattended';
}
router.push(
conversationListPageURL({
accountId,
conversationType,
customViewId: isOnFoldersView({ route: { name } }) ? customViewId : 0,
inboxId,
label,
teamId,
})
);
};
const isOpen = computed(
() => currentChat.value.status === wootConstants.STATUS_TYPE.OPEN
);
@@ -84,9 +120,13 @@ const openSnoozeModal = () => {
const toggleStatus = (status, snoozedUntil, customAttributes = null) => {
closeDropdown();
isLoading.value = true;
const conversationId = currentChat.value.id;
const { all, activeIndex, lastIndex } = getConversationParams();
const adjacentConversation =
activeIndex < lastIndex ? all[activeIndex + 1] : all[activeIndex - 1];
const payload = {
conversationId: currentChat.value.id,
conversationId,
status,
snoozedUntil,
};
@@ -95,10 +135,21 @@ const toggleStatus = (status, snoozedUntil, customAttributes = null) => {
payload.customAttributes = customAttributes;
}
store.dispatch('toggleStatus', payload).then(() => {
useAlert(t('CONVERSATION.CHANGE_STATUS'));
isLoading.value = false;
});
store
.dispatch('toggleStatus', payload)
.then(() => {
useAlert(t('CONVERSATION.CHANGE_STATUS'));
if (status !== wootConstants.STATUS_TYPE.OPEN) {
if (adjacentConversation) {
adjacentConversation.click();
} else {
redirectToConversationList();
}
}
})
.finally(() => {
isLoading.value = false;
});
};
const handleResolveWithAttributes = ({ attributes, context }) => {