chore: 提交剩余开发改动
包含商务通消息映射、会话时间与未读状态、前端消息展示、数据库修复迁移、测试覆盖备份及 Captain 设计文档。
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -1019,7 +1019,12 @@ func (h *ConversationHandler) UpdateLastSeen(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
updatedConversation, svcErr := h.conversationSvc.GetByAccountAndID(c.Request.Context(), accountID, conversation.ID)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, serializeConversation(h.requestContext(c), h.conversationSvc.DB(), updatedConversation))
|
||||
}
|
||||
|
||||
// @Summary Assign a team to a conversation
|
||||
|
||||
@@ -90,6 +90,7 @@ func (s *ConversationCrudTestSuite) SetupSuite() {
|
||||
&model.CaptainAssistant{},
|
||||
&model.CaptainInbox{},
|
||||
&model.AgentBot{},
|
||||
&model.Notification{},
|
||||
&model.CustomAttributeDefinition{},
|
||||
)
|
||||
s.Require().NoError(err)
|
||||
@@ -135,6 +136,7 @@ func (s *ConversationCrudTestSuite) SetupSuite() {
|
||||
conversations.POST("/filter", handler.Filter)
|
||||
conversations.POST("/:conversation_id/priority", handler.UpdatePriority)
|
||||
conversations.POST("/:conversation_id/assignments", handler.AssignTeam)
|
||||
conversations.POST("/:conversation_id/update_last_seen", handler.UpdateLastSeen)
|
||||
conversations.GET("/:conversation_id/inbox_assistant", handler.InboxAssistant)
|
||||
}
|
||||
}
|
||||
@@ -205,6 +207,30 @@ func (s *ConversationCrudTestSuite) convURL(id uint) string {
|
||||
return s.accountURL() + "/conversations/" + strconv.FormatUint(uint64(id), 10)
|
||||
}
|
||||
|
||||
func (s *ConversationCrudTestSuite) TestUpdateLastSeen_ReturnsUpdatedConversation() {
|
||||
message := &model.Message{
|
||||
AccountID: s.testAccount.ID,
|
||||
InboxID: s.testInbox.ID,
|
||||
ConversationID: s.testConv.ID,
|
||||
MessageType: "incoming",
|
||||
ContentType: "text",
|
||||
Content: "unread message",
|
||||
Status: "sent",
|
||||
}
|
||||
s.Require().NoError(s.db.Create(message).Error)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", s.convURL(s.testConv.ID)+"/update_last_seen", nil)
|
||||
req.Header.Set("X-User-ID", "1")
|
||||
s.router.ServeHTTP(w, req)
|
||||
|
||||
s.Require().Equal(http.StatusOK, w.Code, w.Body.String())
|
||||
var payload map[string]any
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload))
|
||||
s.NotZero(payload["agent_last_seen_at"])
|
||||
s.Equal(float64(0), payload["unread_count"])
|
||||
}
|
||||
|
||||
// ========== List Handler Tests ==========
|
||||
|
||||
func (s *ConversationCrudTestSuite) TestList_Success() {
|
||||
|
||||
@@ -892,7 +892,7 @@ func unreadCount(ctx context.Context, db *gorm.DB, conversation *model.Conversat
|
||||
query := db.WithContext(ctx).Model(&model.Message{}).
|
||||
Where("account_id = ? AND conversation_id = ? AND message_type = ?", conversation.AccountID, conversation.ID, "incoming")
|
||||
if conversation.AgentLastSeenAt != nil {
|
||||
query = query.Where("created_at > ?", time.Unix(*conversation.AgentLastSeenAt, 0))
|
||||
query = query.Where("created_at >= ?", time.Unix(*conversation.AgentLastSeenAt+1, 0))
|
||||
}
|
||||
var count int64
|
||||
_ = query.Count(&count).Error
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1393,14 +1393,15 @@ func widgetContactFullPayload(contact *model.Contact) gin.H {
|
||||
|
||||
func bindPublicContactRequest(c *gin.Context) (service.PublicContactRequest, error) {
|
||||
var body struct {
|
||||
SourceID string `json:"source_id"`
|
||||
Identifier string `json:"identifier"`
|
||||
IdentifierHash string `json:"identifier_hash"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
PhoneNumber string `json:"phone_number"`
|
||||
CustomAttributes map[string]any `json:"custom_attributes"`
|
||||
SourceID string `json:"source_id"`
|
||||
Identifier string `json:"identifier"`
|
||||
IdentifierHash string `json:"identifier_hash"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
PhoneNumber string `json:"phone_number"`
|
||||
CustomAttributes map[string]any `json:"custom_attributes"`
|
||||
AdditionalAttributes map[string]any `json:"additional_attributes"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil && c.Request.ContentLength != 0 {
|
||||
return service.PublicContactRequest{}, err
|
||||
@@ -1415,14 +1416,15 @@ func bindPublicContactRequest(c *gin.Context) (service.PublicContactRequest, err
|
||||
body.IdentifierHash = c.Query("identifier_hash")
|
||||
}
|
||||
return service.PublicContactRequest{
|
||||
SourceID: body.SourceID,
|
||||
Identifier: body.Identifier,
|
||||
IdentifierHash: body.IdentifierHash,
|
||||
Email: body.Email,
|
||||
Name: body.Name,
|
||||
AvatarURL: body.AvatarURL,
|
||||
PhoneNumber: body.PhoneNumber,
|
||||
CustomAttributes: body.CustomAttributes,
|
||||
SourceID: body.SourceID,
|
||||
Identifier: body.Identifier,
|
||||
IdentifierHash: body.IdentifierHash,
|
||||
Email: body.Email,
|
||||
Name: body.Name,
|
||||
AvatarURL: body.AvatarURL,
|
||||
PhoneNumber: body.PhoneNumber,
|
||||
CustomAttributes: body.CustomAttributes,
|
||||
AdditionalAttributes: body.AdditionalAttributes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1917,7 +1917,7 @@ func (s *ConversationService) hasMessagesSince(ctx context.Context, conversation
|
||||
query := s.repo.DB().WithContext(ctx).Model(&model.Message{}).
|
||||
Where("account_id = ? AND conversation_id = ?", conversation.AccountID, conversation.ID)
|
||||
if seenAt != nil {
|
||||
query = query.Where("created_at > ?", time.Unix(*seenAt, 0))
|
||||
query = query.Where("created_at >= ?", time.Unix(*seenAt+1, 0))
|
||||
}
|
||||
var count int64
|
||||
if err := query.Count(&count).Error; err != nil {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -300,6 +300,19 @@ func (s *MessageService) Create(ctx context.Context, accountID uint, userID uint
|
||||
if err := tx.Create(message).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
messageTimestamp := message.CreatedAt.Unix()
|
||||
conversationUpdates := map[string]any{
|
||||
"last_activity_at": messageTimestamp,
|
||||
"last_message_at": messageTimestamp,
|
||||
}
|
||||
if message.MessageType != "activity" {
|
||||
conversationUpdates["last_non_sys_msg_at"] = messageTimestamp
|
||||
}
|
||||
if err := tx.Model(&model.Conversation{}).
|
||||
Where("id = ? AND account_id = ?", message.ConversationID, accountID).
|
||||
Updates(conversationUpdates).Error; err != nil {
|
||||
return fmt.Errorf("update conversation message timestamps: %w", err)
|
||||
}
|
||||
for _, input := range req.Attachments {
|
||||
fileType := attachmentFileType(input.ContentType)
|
||||
attachment := &model.Attachment{
|
||||
|
||||
@@ -295,6 +295,14 @@ func TestMessageService_Create(t *testing.T) {
|
||||
assert.Equal(t, user.ID, *created.SenderID)
|
||||
assert.Equal(t, "user", created.SenderType)
|
||||
assert.False(t, created.Private)
|
||||
var updatedConversation model.Conversation
|
||||
require.NoError(t, db.First(&updatedConversation, conv.ID).Error)
|
||||
require.NotNil(t, updatedConversation.LastActivityAt)
|
||||
require.NotNil(t, updatedConversation.LastMessageAt)
|
||||
require.NotNil(t, updatedConversation.LastNonSysMsgAt)
|
||||
assert.Equal(t, created.CreatedAt.Unix(), *updatedConversation.LastActivityAt)
|
||||
assert.Equal(t, created.CreatedAt.Unix(), *updatedConversation.LastMessageAt)
|
||||
assert.Equal(t, created.CreatedAt.Unix(), *updatedConversation.LastNonSysMsgAt)
|
||||
|
||||
// 正常路径:默认ContentType为text
|
||||
reqNoContentType := CreateMessageRequest{
|
||||
|
||||
@@ -197,14 +197,15 @@ type WidgetMessageUpdate struct {
|
||||
}
|
||||
|
||||
type PublicContactRequest struct {
|
||||
SourceID string
|
||||
Identifier string
|
||||
IdentifierHash string
|
||||
Email string
|
||||
Name string
|
||||
AvatarURL string
|
||||
PhoneNumber string
|
||||
CustomAttributes map[string]any
|
||||
SourceID string
|
||||
Identifier string
|
||||
IdentifierHash string
|
||||
Email string
|
||||
Name string
|
||||
AvatarURL string
|
||||
PhoneNumber string
|
||||
CustomAttributes map[string]any
|
||||
AdditionalAttributes map[string]any
|
||||
}
|
||||
|
||||
type PublicContactResponse struct {
|
||||
@@ -701,7 +702,9 @@ func (s *WidgetService) RemoveLabelFromLatestConversation(ctx context.Context, w
|
||||
|
||||
func (s *WidgetService) updateContactFields(ctx context.Context, contact *model.Contact, req WidgetContactUpdate) (*model.Contact, error) {
|
||||
if req.Name != "" {
|
||||
contact.Name = req.Name
|
||||
if !isGenericShangwutongName(req.Name) || isGenericShangwutongName(contact.Name) {
|
||||
contact.Name = req.Name
|
||||
}
|
||||
}
|
||||
if req.Email != "" {
|
||||
contact.Email = req.Email
|
||||
@@ -735,6 +738,11 @@ func (s *WidgetService) updateContactFields(ctx context.Context, contact *model.
|
||||
return contact, nil
|
||||
}
|
||||
|
||||
func isGenericShangwutongName(name string) bool {
|
||||
name = strings.TrimSpace(name)
|
||||
return name == "" || name == "商务通访客" || strings.HasPrefix(name, "商务通访客·")
|
||||
}
|
||||
|
||||
func (s *WidgetService) PublicGetInbox(ctx context.Context, inboxIdentifier string) (*model.Inbox, bool, error) {
|
||||
inbox, channelAPI, err := s.resolvePublicInbox(ctx, inboxIdentifier)
|
||||
if err != nil {
|
||||
@@ -762,6 +770,14 @@ func (s *WidgetService) PublicCreateContact(ctx context.Context, inboxIdentifier
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contact, err = s.updateContactFields(ctx, contact, WidgetContactUpdate{
|
||||
Name: req.Name, Email: strings.ToLower(req.Email), PhoneNumber: req.PhoneNumber,
|
||||
Identifier: req.Identifier, AvatarURL: req.AvatarURL,
|
||||
CustomAttributes: req.CustomAttributes, AdditionalAttributes: req.AdditionalAttributes,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existingInbox.Contact = *contact
|
||||
return &PublicContactResponse{ContactInbox: existingInbox, Contact: contact}, nil
|
||||
}
|
||||
@@ -808,12 +824,13 @@ func (s *WidgetService) PublicUpdateContact(ctx context.Context, inboxIdentifier
|
||||
return nil, err
|
||||
}
|
||||
contact, err := s.updateContactFields(ctx, &contactInbox.Contact, WidgetContactUpdate{
|
||||
Name: req.Name,
|
||||
Email: strings.ToLower(req.Email),
|
||||
PhoneNumber: req.PhoneNumber,
|
||||
Identifier: req.Identifier,
|
||||
AvatarURL: req.AvatarURL,
|
||||
CustomAttributes: req.CustomAttributes,
|
||||
Name: req.Name,
|
||||
Email: strings.ToLower(req.Email),
|
||||
PhoneNumber: req.PhoneNumber,
|
||||
Identifier: req.Identifier,
|
||||
AvatarURL: req.AvatarURL,
|
||||
CustomAttributes: req.CustomAttributes,
|
||||
AdditionalAttributes: req.AdditionalAttributes,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1970,14 +1987,15 @@ func (s *WidgetService) findPublicContact(ctx context.Context, accountID uint, r
|
||||
}
|
||||
}
|
||||
contact := &model.Contact{
|
||||
AccountID: accountID,
|
||||
Name: req.Name,
|
||||
Email: strings.ToLower(req.Email),
|
||||
PhoneNumber: req.PhoneNumber,
|
||||
AvatarURL: req.AvatarURL,
|
||||
Identifier: req.Identifier,
|
||||
ContactType: "visitor",
|
||||
CustomAttributes: mustJSON(req.CustomAttributes),
|
||||
AccountID: accountID,
|
||||
Name: req.Name,
|
||||
Email: strings.ToLower(req.Email),
|
||||
PhoneNumber: req.PhoneNumber,
|
||||
AvatarURL: req.AvatarURL,
|
||||
Identifier: req.Identifier,
|
||||
ContactType: "visitor",
|
||||
CustomAttributes: mustJSON(req.CustomAttributes),
|
||||
AdditionalAttributes: mustJSON(req.AdditionalAttributes),
|
||||
}
|
||||
if err := s.contactRepo.Create(ctx, contact); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -21,6 +21,13 @@ import (
|
||||
"github.com/gochat/gochat/internal/worker"
|
||||
)
|
||||
|
||||
func TestIsGenericShangwutongName(t *testing.T) {
|
||||
assert.True(t, isGenericShangwutongName("商务通访客"))
|
||||
assert.True(t, isGenericShangwutongName("商务通访客·贵州贵阳"))
|
||||
assert.False(t, isGenericShangwutongName("贵州贵阳"))
|
||||
assert.False(t, isGenericShangwutongName("张三"))
|
||||
}
|
||||
|
||||
// ========== Test Helpers ==========
|
||||
|
||||
// setupWidgetServiceTest creates an in-memory SQLite DB, migrates all models,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
DROP INDEX IF EXISTS idx_channel_shangwutong_config_version;
|
||||
DROP INDEX IF EXISTS idx_channel_shangwutong_deleted_at;
|
||||
DROP INDEX IF EXISTS uq_channel_shangwutong_active_identity;
|
||||
DROP INDEX IF EXISTS idx_messages_swt_source;
|
||||
ALTER TABLE messages DROP COLUMN IF EXISTS external_request_hash;
|
||||
DROP TABLE IF EXISTS channel_shangwutong_configs;
|
||||
DROP TABLE IF EXISTS channel_api;
|
||||
@@ -0,0 +1,51 @@
|
||||
CREATE TABLE IF NOT EXISTS channel_api (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
inbox_id BIGINT NOT NULL UNIQUE REFERENCES inboxes(id) ON DELETE CASCADE,
|
||||
webhook_url VARCHAR(512),
|
||||
secret VARCHAR(255),
|
||||
hmac_token VARCHAR(255),
|
||||
hmac_mandatory BOOLEAN DEFAULT FALSE,
|
||||
additional_attributes JSONB DEFAULT '{}'::jsonb,
|
||||
identifier VARCHAR(255),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_shangwutong_configs (
|
||||
inbox_id BIGINT PRIMARY KEY REFERENCES inboxes(id) ON DELETE CASCADE,
|
||||
session_id VARCHAR(64) NOT NULL,
|
||||
username VARCHAR(255) NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
desired_presence VARCHAR(20) NOT NULL DEFAULT 'online',
|
||||
config_version BIGINT NOT NULL DEFAULT 1,
|
||||
actual_presence VARCHAR(20) NOT NULL DEFAULT 'offline',
|
||||
connection_status VARCHAR(40) NOT NULL DEFAULT 'pending',
|
||||
credential_status VARCHAR(40) NOT NULL DEFAULT 'pending',
|
||||
last_heartbeat_at TIMESTAMPTZ,
|
||||
last_error_code VARCHAR(255),
|
||||
status_updated_at TIMESTAMPTZ,
|
||||
deleted_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT ck_channel_shangwutong_desired_presence CHECK (desired_presence IN ('online', 'busy', 'away', 'offline')),
|
||||
CONSTRAINT ck_channel_shangwutong_actual_presence CHECK (actual_presence IN ('online', 'busy', 'away', 'offline')),
|
||||
CONSTRAINT ck_channel_shangwutong_connection_status CHECK (connection_status IN ('pending', 'logging_in', 'connected', 'degraded', 'relogin_required', 'verification_required', 'auth_failed', 'disabled', 'offline')),
|
||||
CONSTRAINT ck_channel_shangwutong_credential_status CHECK (credential_status IN ('pending', 'verifying', 'applied', 'rejected', 'verification_required'))
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_shangwutong_active_identity
|
||||
ON channel_shangwutong_configs(session_id, username)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_shangwutong_deleted_at
|
||||
ON channel_shangwutong_configs(deleted_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_shangwutong_config_version
|
||||
ON channel_shangwutong_configs(config_version, inbox_id);
|
||||
|
||||
ALTER TABLE messages
|
||||
ADD COLUMN IF NOT EXISTS external_request_hash VARCHAR(64);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_swt_source
|
||||
ON messages(inbox_id, source_id)
|
||||
WHERE source_id LIKE 'swt:%';
|
||||
@@ -0,0 +1,9 @@
|
||||
DROP INDEX IF EXISTS idx_working_hours_account_id;
|
||||
ALTER TABLE working_hours
|
||||
DROP COLUMN IF EXISTS close_minutes,
|
||||
DROP COLUMN IF EXISTS close_hour,
|
||||
DROP COLUMN IF EXISTS open_minutes,
|
||||
DROP COLUMN IF EXISTS open_hour,
|
||||
DROP COLUMN IF EXISTS open_all_day,
|
||||
DROP COLUMN IF EXISTS closed_all_day,
|
||||
DROP COLUMN IF EXISTS account_id;
|
||||
@@ -0,0 +1,11 @@
|
||||
ALTER TABLE working_hours
|
||||
ADD COLUMN IF NOT EXISTS account_id BIGINT,
|
||||
ADD COLUMN IF NOT EXISTS closed_all_day BOOLEAN DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS open_all_day BOOLEAN DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS open_hour INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS open_minutes INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS close_hour INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS close_minutes INTEGER;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_working_hours_account_id
|
||||
ON working_hours(account_id);
|
||||
@@ -0,0 +1,4 @@
|
||||
-- This migration repairs existing data. The restored timestamps cannot be
|
||||
-- distinguished from timestamps written by normal application traffic, so the
|
||||
-- rollback intentionally preserves the repaired values.
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,41 @@
|
||||
UPDATE conversations AS c
|
||||
SET
|
||||
last_activity_at = COALESCE(
|
||||
c.last_activity_at,
|
||||
(
|
||||
SELECT EXTRACT(EPOCH FROM m.created_at)::bigint
|
||||
FROM messages AS m
|
||||
WHERE m.conversation_id = c.id
|
||||
ORDER BY m.created_at DESC, m.id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
),
|
||||
last_message_at = COALESCE(
|
||||
c.last_message_at,
|
||||
(
|
||||
SELECT EXTRACT(EPOCH FROM m.created_at)::bigint
|
||||
FROM messages AS m
|
||||
WHERE m.conversation_id = c.id
|
||||
ORDER BY m.created_at DESC, m.id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
),
|
||||
last_non_sys_msg_at = COALESCE(
|
||||
c.last_non_sys_msg_at,
|
||||
(
|
||||
SELECT EXTRACT(EPOCH FROM m.created_at)::bigint
|
||||
FROM messages AS m
|
||||
WHERE m.conversation_id = c.id
|
||||
AND m.message_type <> 'activity'
|
||||
ORDER BY m.created_at DESC, m.id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
)
|
||||
WHERE (c.last_activity_at IS NULL
|
||||
OR c.last_message_at IS NULL
|
||||
OR c.last_non_sys_msg_at IS NULL)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM messages AS m
|
||||
WHERE m.conversation_id = c.id
|
||||
);
|
||||
Reference in New Issue
Block a user