diff --git a/AGENTS.md b/AGENTS.md index 6c144085..a30d7d41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,6 +111,8 @@ make docker ## Deployment Notes +- Production deployment target: SSH host alias `server.gaozong`, deployment directory `/home/rogee/services/gochat`. +- Production updates must not be deployed without the user's explicit authorization for that deployment. Testing, validation, or build success does not imply deployment authorization. - The production `Dockerfile` (in `deploy/docker/`) uses repo root as build context and `COPY backend/` for sources. When adding files the image needs, place them under `backend/` or update the COPY directives. - `docker-compose*.yml` files in `deploy/docker/` use `context: ../..` (repo root) and `dockerfile: deploy/docker/Dockerfile`. diff --git a/backend/internal/auth/jwt.go b/backend/internal/auth/jwt.go index 7329baaf..cbc25257 100644 --- a/backend/internal/auth/jwt.go +++ b/backend/internal/auth/jwt.go @@ -62,8 +62,8 @@ func (s *JWTService) GenerateTokenPair(user *model.User, accountID uint, role st func (s *JWTService) GenerateTokenPairForClient(user *model.User, accountID uint, role, clientID string) (*TokenPair, error) { userType := "user" typeValue := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(user.Type), "_", "")) - if user.Role == "super_admin" || role == "super_admin" || typeValue == "superadmin" { - userType = "super_admin" + if user.Role == RoleSuperAdmin || role == RoleSuperAdmin || typeValue == "superadmin" { + userType = RoleSuperAdmin } // Access Token diff --git a/backend/internal/auth/oidc.go b/backend/internal/auth/oidc.go index 12a772ce..4ee9f16d 100644 --- a/backend/internal/auth/oidc.go +++ b/backend/internal/auth/oidc.go @@ -700,23 +700,23 @@ func (s *OIDCService) mapClaimsToUserInfo(claims map[string]interface{}, setting // MapOIDCGroupsToRoles maps OIDC groups/roles to GoChat roles. func (s *OIDCService) MapOIDCGroupsToRoles(settings *model.AccountOIDCSettings, groups []string) string { if len(settings.RoleMappings) == 0 { - return "agent" // default role + return RoleAgent // default role } var mappings map[string]string if err := json.Unmarshal(settings.RoleMappings, &mappings); err != nil { applogger.L().Warnf("Invalid OIDC role mappings JSON: %v", err) - return "agent" + return RoleAgent } rolePriority := map[string]int{ - "administrator": 4, - "admin": 3, - "supervisor": 2, - "agent": 1, + RoleAdministrator: 4, + "admin": 3, + "supervisor": 2, + RoleAgent: 1, } - bestRole := "agent" + bestRole := RoleAgent bestPriority := 1 for _, group := range groups { diff --git a/backend/internal/auth/permission.go b/backend/internal/auth/permission.go index 2dd58981..9665f422 100644 --- a/backend/internal/auth/permission.go +++ b/backend/internal/auth/permission.go @@ -10,64 +10,64 @@ type Permission string // Reference: Chatwoot's Pundit policies โ†’ GoChat flat permission constants const ( // Account permissions - PermAccountCreate Permission = "account:create" - PermAccountRead Permission = "account:read" - PermAccountUpdate Permission = "account:update" - PermAccountDelete Permission = "account:delete" + PermAccountCreate Permission = "account:create" + PermAccountRead Permission = "account:read" + PermAccountUpdate Permission = "account:update" + PermAccountDelete Permission = "account:delete" PermAccountManageUsers Permission = "account:manage_users" // Inbox permissions - PermInboxCreate Permission = "inbox:create" - PermInboxRead Permission = "inbox:read" - PermInboxUpdate Permission = "inbox:update" - PermInboxDelete Permission = "inbox:delete" + PermInboxCreate Permission = "inbox:create" + PermInboxRead Permission = "inbox:read" + PermInboxUpdate Permission = "inbox:update" + PermInboxDelete Permission = "inbox:delete" // Conversation permissions - PermConversationRead Permission = "conversation:read" - PermConversationCreate Permission = "conversation:create" - PermConversationUpdate Permission = "conversation:update" - PermConversationAssign Permission = "conversation:assign" - PermConversationResolve Permission = "conversation:resolve" - PermConversationDelete Permission = "conversation:delete" + PermConversationRead Permission = "conversation:read" + PermConversationCreate Permission = "conversation:create" + PermConversationUpdate Permission = "conversation:update" + PermConversationAssign Permission = "conversation:assign" + PermConversationResolve Permission = "conversation:resolve" + PermConversationDelete Permission = "conversation:delete" PermConversationManageLabels Permission = "conversation:manage_labels" // Message permissions - PermMessageRead Permission = "message:read" - PermMessageCreate Permission = "message:create" - PermMessageUpdate Permission = "message:update" - PermMessageDelete Permission = "message:delete" + PermMessageRead Permission = "message:read" + PermMessageCreate Permission = "message:create" + PermMessageUpdate Permission = "message:update" + PermMessageDelete Permission = "message:delete" // Contact permissions - PermContactCreate Permission = "contact:create" - PermContactRead Permission = "contact:read" - PermContactUpdate Permission = "contact:update" - PermContactDelete Permission = "contact:delete" - PermContactMerge Permission = "contact:merge" - PermContactExport Permission = "contact:export" + PermContactCreate Permission = "contact:create" + PermContactRead Permission = "contact:read" + PermContactUpdate Permission = "contact:update" + PermContactDelete Permission = "contact:delete" + PermContactMerge Permission = "contact:merge" + PermContactExport Permission = "contact:export" // Report permissions - PermReportRead Permission = "report:read" - PermReportExport Permission = "report:export" + PermReportRead Permission = "report:read" + PermReportExport Permission = "report:export" // Automation permissions - PermAutomationCreate Permission = "automation:create" - PermAutomationRead Permission = "automation:read" - PermAutomationUpdate Permission = "automation:update" - PermAutomationDelete Permission = "automation:delete" + PermAutomationCreate Permission = "automation:create" + PermAutomationRead Permission = "automation:read" + PermAutomationUpdate Permission = "automation:update" + PermAutomationDelete Permission = "automation:delete" // Team permissions - PermTeamCreate Permission = "team:create" - PermTeamRead Permission = "team:read" - PermTeamUpdate Permission = "team:update" - PermTeamDelete Permission = "team:delete" + PermTeamCreate Permission = "team:create" + PermTeamRead Permission = "team:read" + PermTeamUpdate Permission = "team:update" + PermTeamDelete Permission = "team:delete" // Captain AI permissions (Enterprise ๐Ÿ”’) PermCaptainRead Permission = "captain:read" - PermCaptainManage Permission = "captain:manage" - PermCopilotUse Permission = "copilot:use" + PermCaptainManage Permission = "captain:manage" + PermCopilotUse Permission = "copilot:use" // Super admin permissions - PermSuperAdminAll Permission = "super_admin:all" + PermSuperAdminAll Permission = "super_admin:all" PermPlatformManage Permission = "platform:manage" ) @@ -76,10 +76,10 @@ const ( type Role string const ( - RoleSuperAdmin Role = "super_admin" - RoleAdministrator Role = "administrator" - RoleAgent Role = "agent" - RoleCustom Role = "custom" // Enterprise ๐Ÿ”’ + RoleSuperAdmin = "super_admin" + RoleAdministrator = "administrator" + RoleAgent = "agent" + RoleCustom = "custom_role" // Enterprise ๐Ÿ”’ ) // PermissionMatrix defines which roles have which permissions. @@ -188,4 +188,4 @@ func GetPermissions(role Role) []Permission { return []Permission{} } return perms -} \ No newline at end of file +} diff --git a/backend/internal/auth/policy.go b/backend/internal/auth/policy.go index c8a4413a..9ad3f2d4 100644 --- a/backend/internal/auth/policy.go +++ b/backend/internal/auth/policy.go @@ -132,9 +132,9 @@ type PolicyContext struct { } // NewPolicyContext creates a PolicyContext from the given parameters. -// For "administrator" role, all permissions are set to "full". -// For "agent" role, agent default permissions are applied. -// For "custom_role" role, the provided permissions matrix is used. +// For RoleAdministrator, all permissions are set to "full". +// For RoleAgent, agent default permissions are applied. +// For RoleCustom, the provided permissions matrix is used. func NewPolicyContext(userID, accountID uint, role string, customRoleID uint, permissions PermissionMatrixMap) *PolicyContext { pc := &PolicyContext{ UserID: userID, @@ -144,17 +144,17 @@ func NewPolicyContext(userID, accountID uint, role string, customRoleID uint, pe Permissions: permissions, } - if role == "agent" && customRoleID > 0 { - pc.Role = "custom_role" + if role == RoleAgent && customRoleID > 0 { + pc.Role = RoleCustom } // Apply role-based defaults switch pc.Role { - case "administrator": + case RoleAdministrator: pc.Permissions = AdministratorPermissions - case "agent": + case RoleAgent: pc.Permissions = AgentDefaultPermissions - case "custom_role": + case RoleCustom: // Use only the provided permissions matrix (from CustomRole). A missing // matrix means no permissions; callers must not widen it to agent access. if pc.Permissions == nil { @@ -167,17 +167,17 @@ func NewPolicyContext(userID, accountID uint, role string, customRoleID uint, pe // IsAdministrator returns true if the role is administrator. func (pc *PolicyContext) IsAdministrator() bool { - return pc.Role == "administrator" + return pc.Role == RoleAdministrator } // IsAgent returns true if the role is agent. func (pc *PolicyContext) IsAgent() bool { - return pc.Role == "agent" && pc.CustomRoleID == 0 + return pc.Role == RoleAgent && pc.CustomRoleID == 0 } // IsCustomRole returns true if the role is a custom (enterprise) role. func (pc *PolicyContext) IsCustomRole() bool { - return pc.Role == "custom_role" || (pc.Role == "agent" && pc.CustomRoleID > 0) + return pc.Role == RoleCustom || (pc.Role == RoleAgent && pc.CustomRoleID > 0) } // Can checks whether the current user is authorized for an action on a resource. @@ -190,22 +190,22 @@ func (pc *PolicyContext) IsCustomRole() bool { // - "create" โ†’ requires PermissionFull on the corresponding manage dimension // - "update" โ†’ requires PermissionFull on the corresponding manage dimension // -// For "administrator" role, Can() always returns true. -// For "agent" role, Can() checks against AgentDefaultPermissions. -// For "custom_role", Can() checks the provided PermissionMatrix. +// For RoleAdministrator, Can() always returns true. +// For RoleAgent, Can() checks against AgentDefaultPermissions. +// For RoleCustom, Can() checks the provided PermissionMatrix. func (pc *PolicyContext) Can(action, resource string) bool { // Administrator and super_admin always have full access - if pc.IsAdministrator() || pc.Role == "super_admin" { + if pc.IsAdministrator() || pc.Role == RoleSuperAdmin { return true } // Apply default permissions if not explicitly set if pc.Permissions == nil { switch pc.Role { - case "administrator": + case RoleAdministrator: pc.Permissions = AdministratorPermissions - case "agent": + case RoleAgent: pc.Permissions = AgentDefaultPermissions default: return false diff --git a/backend/internal/auth/sso_middleware.go b/backend/internal/auth/sso_middleware.go index 12f667e1..88c88e02 100644 --- a/backend/internal/auth/sso_middleware.go +++ b/backend/internal/auth/sso_middleware.go @@ -163,14 +163,14 @@ func (m *SSOMiddleware) AuthenticateOIDC(ctx context.Context, state, code string } result := &SSOAuthResult{ - Provider: SSOProviderOIDC, - AccountID: oidcState.AccountID, - Email: oidcUserInfo.Email, - Name: oidcUserInfo.Name, - FirstName: oidcUserInfo.FirstName, - LastName: oidcUserInfo.LastName, - Subject: oidcUserInfo.Subject, // OIDC sub claim - Groups: oidcUserInfo.Groups, + Provider: SSOProviderOIDC, + AccountID: oidcState.AccountID, + Email: oidcUserInfo.Email, + Name: oidcUserInfo.Name, + FirstName: oidcUserInfo.FirstName, + LastName: oidcUserInfo.LastName, + Subject: oidcUserInfo.Subject, // OIDC sub claim + Groups: oidcUserInfo.Groups, } // Map OIDC groups to GoChat role @@ -178,7 +178,7 @@ func (m *SSOMiddleware) AuthenticateOIDC(ctx context.Context, state, code string if oidcSettings != nil { result.Role = m.oidcService.MapOIDCGroupsToRoles(oidcSettings, oidcUserInfo.Groups) } else { - result.Role = "agent" + result.Role = RoleAgent } // Look up existing GoChat user by email @@ -386,4 +386,4 @@ func (m *SSOMiddleware) ensureAccountMembership(ctx context.Context, userID, acc } else { applogger.L().Infof("Added user %d to account %d with role %s", userID, accountID, role) } -} \ No newline at end of file +} diff --git a/backend/internal/autoassignment/service.go b/backend/internal/autoassignment/service.go index 30af98e2..f51d043f 100644 --- a/backend/internal/autoassignment/service.go +++ b/backend/internal/autoassignment/service.go @@ -22,6 +22,7 @@ import ( "strings" "time" + "github.com/gochat/gochat/internal/auth" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" applogger "github.com/gochat/gochat/pkg/logger" @@ -326,7 +327,7 @@ func (s *AssignmentService) getEligibleAgents(ctx context.Context, inboxID uint, Joins("JOIN users ON users.id = inbox_members.user_id"). Joins("JOIN account_users ON account_users.user_id = inbox_members.user_id AND account_users.account_id = ?", accountID). Where("inbox_members.inbox_id = ? AND inbox_members.deleted_at IS NULL AND users.deleted_at IS NULL AND account_users.deleted_at IS NULL AND users.available = ? AND users.active = ? AND account_users.role IN ?", - inboxID, true, true, []string{"agent", "administrator"}) + inboxID, true, true, []string{auth.RoleAgent, auth.RoleAdministrator}) if teamID != nil { query = query. Joins("JOIN team_members ON team_members.user_id = inbox_members.user_id AND team_members.team_id = ? AND team_members.deleted_at IS NULL", *teamID). diff --git a/backend/internal/automation/macro_service.go b/backend/internal/automation/macro_service.go index 64e37a16..b69e6cea 100644 --- a/backend/internal/automation/macro_service.go +++ b/backend/internal/automation/macro_service.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" + "github.com/gochat/gochat/internal/auth" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/worker" applogger "github.com/gochat/gochat/pkg/logger" @@ -428,7 +429,7 @@ func (s *MacroService) CanAccess(macro *Macro, userID uint, role string, action } if action == "update" || action == "destroy" { if macro.Visibility == MacroVisibilityGlobal { - return role == "administrator" || role == "super_admin" + return role == auth.RoleAdministrator || role == auth.RoleSuperAdmin } return macro.CreatedByID == userID } diff --git a/backend/internal/channel/event.go b/backend/internal/channel/event.go index d9b7a378..3cda033c 100644 --- a/backend/internal/channel/event.go +++ b/backend/internal/channel/event.go @@ -1,5 +1,7 @@ package channel +const EventOriginShangwutongConnector = "shangwutong.connector" + // EventType defines channel lifecycle and messaging events. // Reference: P2D ยง8 + Chatwoot Dispatcher + Listener event-driven architecture // These events are published to the Event Bus for cross-module propagation. @@ -71,6 +73,8 @@ type ChannelEvent struct { ConversationID uint `json:"conversation_id,omitempty"` ContactID uint `json:"contact_id,omitempty"` UserID uint `json:"user_id,omitempty"` + Origin string `json:"origin,omitempty"` + OriginEventID string `json:"origin_event_id,omitempty"` Data map[string]interface{} `json:"data"` Timestamp int64 `json:"timestamp"` } diff --git a/backend/internal/handler/api/v1/audit_handler.go b/backend/internal/handler/api/v1/audit_handler.go index aaa0a634..4aaa0ec7 100644 --- a/backend/internal/handler/api/v1/audit_handler.go +++ b/backend/internal/handler/api/v1/audit_handler.go @@ -6,6 +6,7 @@ import ( "github.com/gin-gonic/gin" + "github.com/gochat/gochat/internal/auth" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/service" applogger "github.com/gochat/gochat/pkg/logger" @@ -100,7 +101,7 @@ func RegisterAuditRoutes(rg *gin.RouterGroup, h *AuditHandler) { func isAuditAdmin(c *gin.Context) bool { role := getRole(c) - return role == "administrator" || role == "super_admin" + return role == auth.RoleAdministrator || role == auth.RoleSuperAdmin } func parseAuditPage(raw string) int { diff --git a/backend/internal/handler/api/v1/captain_custom_tool_handler.go b/backend/internal/handler/api/v1/captain_custom_tool_handler.go index 55687954..676bb3d1 100644 --- a/backend/internal/handler/api/v1/captain_custom_tool_handler.go +++ b/backend/internal/handler/api/v1/captain_custom_tool_handler.go @@ -5,6 +5,7 @@ import ( "net/http" "github.com/gin-gonic/gin" + "github.com/gochat/gochat/internal/auth" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/service" applogger "github.com/gochat/gochat/pkg/logger" @@ -247,7 +248,7 @@ func (h *CaptainCustomToolHandler) ensureCustomToolsEnabled(c *gin.Context, acco func (h *CaptainCustomToolHandler) ensureCustomToolAdmin(c *gin.Context) bool { if role, exists := c.Get("role"); exists { - if role == "administrator" || role == "super_admin" { + if role == auth.RoleAdministrator || role == auth.RoleSuperAdmin { return true } c.JSON(http.StatusForbidden, gin.H{"error": "You are not authorized to do this action"}) @@ -293,5 +294,5 @@ func captainCustomToolPayload(c *gin.Context, tool *model.CaptainCustomTool) gin func captainCustomToolShowAuthConfig(c *gin.Context) bool { role, exists := c.Get("role") - return exists && (role == "administrator" || role == "super_admin") + return exists && (role == auth.RoleAdministrator || role == auth.RoleSuperAdmin) } diff --git a/backend/internal/handler/api/v1/captain_preference_handler.go b/backend/internal/handler/api/v1/captain_preference_handler.go index c27152db..4d1a6abe 100644 --- a/backend/internal/handler/api/v1/captain_preference_handler.go +++ b/backend/internal/handler/api/v1/captain_preference_handler.go @@ -4,6 +4,7 @@ import ( "net/http" "github.com/gin-gonic/gin" + "github.com/gochat/gochat/internal/auth" "github.com/gochat/gochat/internal/service" applogger "github.com/gochat/gochat/pkg/logger" "github.com/gochat/gochat/pkg/response" @@ -112,5 +113,5 @@ func (h *CaptainPreferenceHandler) Delete(c *gin.Context) { func captainPreferencesCanUpdate(c *gin.Context) bool { role := getRole(c) - return role == "administrator" || role == "super_admin" + return role == auth.RoleAdministrator || role == auth.RoleSuperAdmin } diff --git a/backend/internal/handler/api/v1/conversation_handler.go b/backend/internal/handler/api/v1/conversation_handler.go index 00a725d9..e353354c 100644 --- a/backend/internal/handler/api/v1/conversation_handler.go +++ b/backend/internal/handler/api/v1/conversation_handler.go @@ -10,6 +10,7 @@ import ( "github.com/gin-gonic/gin" + "github.com/gochat/gochat/internal/auth" "github.com/gochat/gochat/internal/llm" "github.com/gochat/gochat/internal/middleware" "github.com/gochat/gochat/internal/model" @@ -470,7 +471,7 @@ func (h *ConversationHandler) UpdateLabels(c *gin.Context) { handleServiceError(c, svcErr) return } - c.JSON(http.StatusOK, gin.H{"payload": gin.H{"conversationId": strconv.FormatUint(uint64(conversation.ID), 10), "labels": labelList(conversation.Labels)}}) + c.JSON(http.StatusOK, gin.H{"payload": labelList(conversation.Labels)}) } // GetLabels returns the labels assigned to a conversation. @@ -1145,7 +1146,7 @@ func (h *ConversationHandler) canManageConversationInbox(ctx context.Context, ac if err := h.conversationSvc.DB().WithContext(ctx).Where("account_id = ? AND user_id = ?", accountID, userID).First(&accountUser).Error; err != nil { return false } - if accountUser.Role == "administrator" { + if accountUser.Role == auth.RoleAdministrator { return true } var count int64 diff --git a/backend/internal/handler/api/v1/conversation_handler_crud_test.go b/backend/internal/handler/api/v1/conversation_handler_crud_test.go index a7121b17..dfd85499 100644 --- a/backend/internal/handler/api/v1/conversation_handler_crud_test.go +++ b/backend/internal/handler/api/v1/conversation_handler_crud_test.go @@ -1080,9 +1080,7 @@ func (s *ConversationCrudTestSuite) TestUpdateLabels_Success() { var resp map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &resp) assert.NoError(s.T(), err) - payload := resp["payload"].(map[string]interface{}) - assert.Equal(s.T(), strconv.FormatUint(uint64(s.testConv.ID), 10), payload["conversationId"]) - assert.ElementsMatch(s.T(), []interface{}{"support", "bug"}, payload["labels"]) + assert.ElementsMatch(s.T(), []interface{}{"support", "bug"}, resp["payload"]) } func (s *ConversationCrudTestSuite) TestChatwootFrontendConversationLabelsRuntimeRoutes() { @@ -1097,9 +1095,7 @@ func (s *ConversationCrudTestSuite) TestChatwootFrontendConversationLabelsRuntim assert.Equal(s.T(), http.StatusOK, w.Code, w.Body.String()) var updateResp map[string]interface{} s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &updateResp)) - updatePayload := updateResp["payload"].(map[string]interface{}) - assert.Equal(s.T(), strconv.FormatUint(uint64(s.testConv.ID), 10), updatePayload["conversationId"]) - assert.ElementsMatch(s.T(), []interface{}{"customer-success", "on-hold"}, updatePayload["labels"]) + assert.ElementsMatch(s.T(), []interface{}{"customer-success", "on-hold"}, updateResp["payload"]) w = httptest.NewRecorder() req, _ = http.NewRequest("GET", s.convURL(s.testConv.ID)+"/labels", nil) diff --git a/backend/internal/handler/api/v1/conversation_serializer.go b/backend/internal/handler/api/v1/conversation_serializer.go index 077580d7..39b13244 100644 --- a/backend/internal/handler/api/v1/conversation_serializer.go +++ b/backend/internal/handler/api/v1/conversation_serializer.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/gochat/gochat/internal/auth" "github.com/gochat/gochat/internal/model" channelmodel "github.com/gochat/gochat/internal/model/channel" "github.com/gochat/gochat/internal/service" @@ -721,7 +722,7 @@ func serializeUser(user *model.User, accountID uint) map[string]any { "provider": nonEmpty(user.Provider, "email"), "available_name": nonEmpty(user.DisplayName, user.Name), "name": user.Name, - "role": nonEmpty(user.Role, "agent"), + "role": nonEmpty(user.Role, auth.RoleAgent), "thumbnail": user.AvatarURL, "type": "user", } diff --git a/backend/internal/handler/api/v1/crm_serializer.go b/backend/internal/handler/api/v1/crm_serializer.go index 81b4ce3c..4f95f773 100644 --- a/backend/internal/handler/api/v1/crm_serializer.go +++ b/backend/internal/handler/api/v1/crm_serializer.go @@ -2,9 +2,11 @@ package v1 import ( "context" + "encoding/json" "time" "github.com/gochat/gochat/internal/model" + "gorm.io/datatypes" "gorm.io/gorm" ) @@ -93,10 +95,39 @@ func serializeCompanyContact(ctx context.Context, db *gorm.DB, contact *model.Co } func serializeContactInbox(contactInbox *model.ContactInbox) map[string]any { - return map[string]any{ + payload := map[string]any{ "source_id": contactInbox.SourceID, "inbox": serializeInboxSlim(&contactInbox.Inbox), } + if contactInbox.Inbox.ChannelType == "shangwutong" { + if status := shangwutongContactNameStatus(contactInbox.ChannelMetadata); status != nil { + status["contact_inbox_id"] = contactInbox.ID + status["source_id"] = contactInbox.SourceID + payload["shangwutong_contact_name_operation"] = status + } + } + return payload +} + +func shangwutongContactNameStatus(metadata datatypes.JSON) map[string]any { + var values map[string]any + if len(metadata) == 0 || json.Unmarshal(metadata, &values) != nil { + return nil + } + state, ok := values["swt_contact_name_operation"].(map[string]any) + if !ok { + return nil + } + status := map[string]any{} + for _, key := range []string{"event_id", "operation", "status", "error_code", "error_message", "updated_at"} { + if value, exists := state[key]; exists { + status[key] = value + } + } + if len(status) == 0 { + return nil + } + return status } func serializeContactInboxShell(ctx context.Context, db *gorm.DB, contactInbox *model.ContactInbox) map[string]any { diff --git a/backend/internal/handler/api/v1/custom_role_handler.go b/backend/internal/handler/api/v1/custom_role_handler.go index 9a7f70d0..536406d9 100644 --- a/backend/internal/handler/api/v1/custom_role_handler.go +++ b/backend/internal/handler/api/v1/custom_role_handler.go @@ -5,6 +5,7 @@ import ( "github.com/gin-gonic/gin" + "github.com/gochat/gochat/internal/auth" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/service" "github.com/gochat/gochat/internal/ws" @@ -234,7 +235,7 @@ func RegisterCustomRoleRoutes(rg *gin.RouterGroup, h *CustomRoleHandler) { func isCustomRoleAdmin(c *gin.Context) bool { role := getRole(c) - return role == "administrator" || role == "super_admin" + return role == auth.RoleAdministrator || role == auth.RoleSuperAdmin } func serializeCustomRoles(roles []model.CustomRole) []gin.H { diff --git a/backend/internal/handler/api/v1/inbox_handler.go b/backend/internal/handler/api/v1/inbox_handler.go index defc904e..3bda5cef 100644 --- a/backend/internal/handler/api/v1/inbox_handler.go +++ b/backend/internal/handler/api/v1/inbox_handler.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/gin-gonic/gin" + "github.com/gochat/gochat/internal/auth" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/service" @@ -67,7 +68,7 @@ func (h *InboxHandler) List(c *gin.Context) { inboxes []model.Inbox svcErr error ) - if getRole(c) == "administrator" { + if getRole(c) == auth.RoleAdministrator { inboxes, _, svcErr = h.svc.ListByAccount(c.Request.Context(), accountID, offset, perPage) } else { userID := getUserID(c) @@ -995,5 +996,5 @@ func (h *InboxHandler) ResetSecret(c *gin.Context) { } func inboxSerializationAdmin(c *gin.Context) bool { - return getRole(c) == "administrator" + return getRole(c) == auth.RoleAdministrator } diff --git a/backend/internal/handler/api/v1/inbox_member_handler.go b/backend/internal/handler/api/v1/inbox_member_handler.go index 2a067458..7f797cbc 100644 --- a/backend/internal/handler/api/v1/inbox_member_handler.go +++ b/backend/internal/handler/api/v1/inbox_member_handler.go @@ -4,6 +4,7 @@ import ( "net/http" "github.com/gin-gonic/gin" + "github.com/gochat/gochat/internal/auth" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/service" ) @@ -263,7 +264,7 @@ func serializeInboxMemberAgent(member model.InboxMember) gin.H { role = member.Role } if role == "" { - role = "agent" + role = auth.RoleAgent } return gin.H{ "id": user.ID, diff --git a/backend/internal/handler/api/v1/macro_handler.go b/backend/internal/handler/api/v1/macro_handler.go index 1a119b21..be85724b 100644 --- a/backend/internal/handler/api/v1/macro_handler.go +++ b/backend/internal/handler/api/v1/macro_handler.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/gin-gonic/gin" + "github.com/gochat/gochat/internal/auth" "github.com/gochat/gochat/internal/automation" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/service" @@ -100,7 +101,7 @@ func (h *MacroHandler) Create(c *gin.Context) { // set_visibility: Chatwoot forces visibility=personal if user role is agent // Reference: Chatwoot macro.rb set_visibility โ€” self.visibility = :personal if user.agent? role := getRole(c) - if role == "agent" { + if role == auth.RoleAgent { macro.Visibility = automation.MacroVisibilityPersonal } diff --git a/backend/internal/handler/api/v1/oidc_handler.go b/backend/internal/handler/api/v1/oidc_handler.go index 7822c45a..56d76711 100644 --- a/backend/internal/handler/api/v1/oidc_handler.go +++ b/backend/internal/handler/api/v1/oidc_handler.go @@ -19,17 +19,17 @@ import ( "github.com/gochat/gochat/internal/config" "github.com/gochat/gochat/internal/middleware" "github.com/gochat/gochat/internal/model" - "github.com/gochat/gochat/pkg/response" applogger "github.com/gochat/gochat/pkg/logger" + "github.com/gochat/gochat/pkg/response" ) // OIDCHandler handles OIDC/OAuth2 enterprise authentication HTTP endpoints. type OIDCHandler struct { - oidcService *auth.OIDCService - ssoMiddleware *auth.SSOMiddleware - jwtService *auth.JWTService - refreshStore *auth.RefreshTokenStore - oidcCfg *config.OIDCConfig + oidcService *auth.OIDCService + ssoMiddleware *auth.SSOMiddleware + jwtService *auth.JWTService + refreshStore *auth.RefreshTokenStore + oidcCfg *config.OIDCConfig } // NewOIDCHandler creates an OIDC handler with service dependencies. @@ -240,12 +240,12 @@ func (h *OIDCHandler) Callback(c *gin.Context) { c.JSON(http.StatusOK, response.APIResponse{ Success: true, Data: gin.H{ - "access_token": accessToken, - "user_id": result.UserID, - "account_id": result.AccountID, - "role": result.Role, - "provider": string(result.Provider), - "session_id": sessionID, + "access_token": accessToken, + "user_id": result.UserID, + "account_id": result.AccountID, + "role": result.Role, + "provider": string(result.Provider), + "session_id": sessionID, "auto_provisioned": result.AutoProvision, }, }) @@ -531,10 +531,10 @@ func RegisterOIDCRoutes(rg *gin.RouterGroup, handler *OIDCHandler, authMiddlewar // Admin-only config management routes (require auth + administrator role) configGroup := oidcGroup.Group("/config") - configGroup.Use(authMiddleware, middleware.RoleCheck("administrator")) + configGroup.Use(authMiddleware, middleware.RoleCheck(auth.RoleAdministrator)) { configGroup.GET("", handler.GetConfig) configGroup.PUT("", handler.UpdateConfig) } } -} \ No newline at end of file +} diff --git a/backend/internal/handler/api/v1/search_handler.go b/backend/internal/handler/api/v1/search_handler.go index d57a1bfa..ca33fe33 100644 --- a/backend/internal/handler/api/v1/search_handler.go +++ b/backend/internal/handler/api/v1/search_handler.go @@ -8,6 +8,7 @@ import ( "github.com/gin-gonic/gin" "gorm.io/gorm" + "github.com/gochat/gochat/internal/auth" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/search" "github.com/gochat/gochat/pkg/response" @@ -424,7 +425,7 @@ func serializeSearchConversationAgentModel(conv *model.Conversation) map[string] "available_name": nonEmpty(conv.Assignee.DisplayName, conv.Assignee.Name), "email": conv.Assignee.Email, "name": conv.Assignee.Name, - "role": nonEmpty(conv.Assignee.Role, "agent"), + "role": nonEmpty(conv.Assignee.Role, auth.RoleAgent), } } return map[string]any{} diff --git a/backend/internal/handler/api/v1/shangwutong_connector_handler.go b/backend/internal/handler/api/v1/shangwutong_connector_handler.go index ac539578..bcf7cfa3 100644 --- a/backend/internal/handler/api/v1/shangwutong_connector_handler.go +++ b/backend/internal/handler/api/v1/shangwutong_connector_handler.go @@ -1,6 +1,7 @@ package v1 import ( + "context" "encoding/base64" "encoding/json" "errors" @@ -16,8 +17,10 @@ import ( channelmodel "github.com/gochat/gochat/internal/model/channel" "github.com/gochat/gochat/internal/service" "github.com/gochat/gochat/internal/worker" + "github.com/google/uuid" "gorm.io/datatypes" "gorm.io/gorm" + "gorm.io/gorm/clause" ) type ShangwutongConnectorHandler struct { @@ -49,8 +52,97 @@ func (h *ShangwutongConnectorHandler) UpdateContactMetadata(c *gin.Context) { h.connectorError(c, http.StatusUnprocessableEntity, "invalid_contact_metadata", "cid is required", false) return } + var updated bool + err := h.db.WithContext(c.Request.Context()).Transaction(func(tx *gorm.DB) error { + var contactInbox model.ContactInbox + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("inbox_id = ? AND source_id = ?", inbox.ID, c.Param("source_id")).First(&contactInbox).Error; err != nil { + return err + } + metadata := map[string]any{} + if len(contactInbox.ChannelMetadata) > 0 { + if err := json.Unmarshal(contactInbox.ChannelMetadata, &metadata); err != nil { + return err + } + } + if metadata == nil { + metadata = map[string]any{} + } + if metadata["cid"] == request.CID { + return nil + } + metadata["cid"] = request.CID + encoded, err := json.Marshal(metadata) + if err != nil { + return err + } + if err := tx.Model(&contactInbox).Update("channel_metadata", encoded).Error; err != nil { + return err + } + updated = true + return nil + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + h.connectorError(c, http.StatusNotFound, "not_found", "contact source not found", false) + } else { + var syntaxErr *json.SyntaxError + if errors.As(err, &syntaxErr) { + h.connectorError(c, http.StatusInternalServerError, "contact_metadata_invalid", "stored contact metadata is invalid", true) + } else { + h.connectorError(c, http.StatusInternalServerError, "contact_metadata_update_failed", "failed to update contact metadata", true) + } + } + return + } + c.JSON(http.StatusOK, gin.H{"updated": updated}) +} + +type shangwutongContactOperationStatusRequest struct { + EventID string `json:"event_id"` + Operation string `json:"operation"` + ContactID uint `json:"contact_id"` + Name string `json:"name"` + Status string `json:"status"` + ErrorCode string `json:"error_code"` + ErrorMessage string `json:"error_message"` +} + +func (h *ShangwutongConnectorHandler) UpdateContactOperationStatus(c *gin.Context) { + inbox, ok := h.authorizedInbox(c) + if !ok { + return + } + sourceID := strings.TrimSpace(c.Param("source_id")) + if sourceID == "" { + h.connectorError(c, http.StatusBadRequest, "invalid_source_id", "source_id is required", false) + return + } + var request shangwutongContactOperationStatusRequest + if err := c.ShouldBindJSON(&request); err != nil { + h.connectorError(c, http.StatusUnprocessableEntity, "invalid_contact_operation_status", "contact operation status is invalid", false) + return + } + request.EventID = strings.TrimSpace(request.EventID) + request.Operation = strings.TrimSpace(request.Operation) + request.Name = strings.TrimSpace(request.Name) + request.Status = strings.TrimSpace(request.Status) + request.ErrorCode = strings.TrimSpace(request.ErrorCode) + request.ErrorMessage = strings.TrimSpace(request.ErrorMessage) + if request.EventID == "" || request.Operation != "change_contact_name" || request.ContactID == 0 || !oneOf(request.Status, "succeeded", "failed", "uncertain") { + h.connectorError(c, http.StatusUnprocessableEntity, "invalid_contact_operation_status", "contact operation status is invalid", false) + return + } + if request.Name == "" || len(request.Name) > 255 || len(request.ErrorCode) > 128 || len(request.ErrorMessage) > 1024 || request.Status == "failed" && request.ErrorCode == "" { + h.connectorError(c, http.StatusUnprocessableEntity, "invalid_contact_operation_status", "contact operation error is invalid", false) + return + } + expectedKey := fmt.Sprintf("swt-contact-operation:%d:%s", inbox.ID, request.EventID) + if c.GetHeader("Idempotency-Key") != expectedKey { + h.connectorError(c, http.StatusUnprocessableEntity, "invalid_idempotency_key", "Idempotency-Key does not match event_id", false) + return + } var contactInbox model.ContactInbox - if err := h.db.WithContext(c.Request.Context()).Where("inbox_id = ? AND source_id = ?", inbox.ID, c.Param("source_id")).First(&contactInbox).Error; err != nil { + if err := h.db.WithContext(c.Request.Context()).Preload("Contact").Where("inbox_id = ? AND source_id = ?", inbox.ID, sourceID).First(&contactInbox).Error; err != nil || contactInbox.Contact.ID == 0 || contactInbox.Contact.ID != request.ContactID || contactInbox.Contact.AccountID != inbox.AccountID { h.connectorError(c, http.StatusNotFound, "not_found", "contact source not found", false) return } @@ -61,21 +153,150 @@ func (h *ShangwutongConnectorHandler) UpdateContactMetadata(c *gin.Context) { return } } - if metadata["cid"] == request.CID { - c.JSON(http.StatusOK, gin.H{"updated": false}) + if metadata == nil { + metadata = map[string]any{} + } + existing, ok := metadata["swt_contact_name_operation"].(map[string]any) + if !ok { + h.connectorError(c, http.StatusConflict, "unknown_operation", "contact operation is not pending", false) return } - metadata["cid"] = request.CID - encoded, err := json.Marshal(metadata) + storedEventID, eventOK := classificationStateString(existing, "event_id") + storedOperation, operationOK := classificationStateString(existing, "operation") + storedName, nameOK := classificationStateString(existing, "name") + storedSourceID, sourceOK := classificationStateString(existing, "source_id") + storedAccountID, accountOK := classificationStateUint(existing, "account_id") + storedInboxID, inboxOK := classificationStateUint(existing, "inbox_id") + storedContactInboxID, contactInboxOK := classificationStateUint(existing, "contact_inbox_id") + storedContactID, contactOK := classificationStateUint(existing, "contact_id") + if !eventOK || !operationOK || !nameOK || !sourceOK || !accountOK || !inboxOK || !contactInboxOK || !contactOK || storedOperation != request.Operation { + h.connectorError(c, http.StatusConflict, "unknown_operation", "contact operation identity is incomplete", false) + return + } + if storedAccountID != inbox.AccountID || storedInboxID != inbox.ID || storedContactInboxID != contactInbox.ID || storedContactID != request.ContactID || storedSourceID != sourceID { + h.connectorError(c, http.StatusConflict, "stale_operation", "contact operation targets a different contact", false) + return + } + if storedName != request.Name { + h.connectorError(c, http.StatusConflict, "idempotency_conflict", "contact operation changes its target name", false) + return + } + status, _ := existing["status"].(string) + code, _ := existing["error_code"].(string) + message, _ := existing["error_message"].(string) + if storedEventID != request.EventID { + c.JSON(http.StatusOK, gin.H{"updated": false, "stale": true, "event_id": storedEventID, "status": status}) + return + } + if status != "pending" && status != request.Status && !(status == "uncertain" && code == "connector_delivery_uncertain") { + h.connectorError(c, http.StatusConflict, "idempotency_conflict", "contact operation result conflicts with its terminal state", false) + return + } + if status == request.Status && code == request.ErrorCode && message == request.ErrorMessage { + c.JSON(http.StatusOK, gin.H{"updated": false, "event_id": request.EventID, "status": status}) + return + } + updated, staleEventID, staleStatus, conflict, err := h.persistContactOperationStatus(c.Request.Context(), inbox.ID, sourceID, request) if err != nil { - h.connectorError(c, http.StatusInternalServerError, "contact_metadata_update_failed", "failed to encode contact metadata", true) + h.connectorError(c, http.StatusInternalServerError, "contact_metadata_update_failed", "failed to update contact operation status", true) return } - if err := h.db.WithContext(c.Request.Context()).Model(&contactInbox).Update("channel_metadata", encoded).Error; err != nil { - h.connectorError(c, http.StatusInternalServerError, "contact_metadata_update_failed", "failed to update contact metadata", true) + if conflict { + h.connectorError(c, http.StatusConflict, "idempotency_conflict", "contact operation result conflicts with its terminal state", false) return } - c.JSON(http.StatusOK, gin.H{"updated": true}) + if staleEventID != "" { + c.JSON(http.StatusOK, gin.H{"updated": false, "stale": true, "event_id": staleEventID, "status": staleStatus}) + return + } + c.JSON(http.StatusOK, gin.H{"updated": updated, "event_id": request.EventID, "status": request.Status}) +} + +func (h *ShangwutongConnectorHandler) persistContactOperationStatus(ctx context.Context, inboxID uint, sourceID string, request shangwutongContactOperationStatusRequest) (updated bool, staleEventID, staleStatus string, conflict bool, err error) { + err = h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var current model.ContactInbox + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("inbox_id = ? AND source_id = ?", inboxID, sourceID).First(¤t).Error; err != nil { + return err + } + metadata := map[string]any{} + if len(current.ChannelMetadata) > 0 { + if err := json.Unmarshal(current.ChannelMetadata, &metadata); err != nil { + return err + } + } + if metadata == nil { + metadata = map[string]any{} + } + if current.ContactID != request.ContactID { + conflict = true + return nil + } + existing, ok := metadata["swt_contact_name_operation"].(map[string]any) + if !ok { + conflict = true + return nil + } + storedEventID, eventOK := classificationStateString(existing, "event_id") + storedOperation, operationOK := classificationStateString(existing, "operation") + storedName, nameOK := classificationStateString(existing, "name") + storedSourceID, sourceOK := classificationStateString(existing, "source_id") + storedAccountID, accountOK := classificationStateUint(existing, "account_id") + storedInboxID, inboxOK := classificationStateUint(existing, "inbox_id") + storedContactInboxID, contactInboxOK := classificationStateUint(existing, "contact_inbox_id") + storedContactID, contactOK := classificationStateUint(existing, "contact_id") + if !eventOK || !operationOK || !nameOK || !sourceOK || !accountOK || !inboxOK || !contactInboxOK || !contactOK || storedOperation != request.Operation || storedAccountID == 0 || storedInboxID != inboxID || storedContactInboxID != current.ID || storedContactID != current.ContactID || storedSourceID != sourceID { + conflict = true + return nil + } + if storedEventID != request.EventID { + staleEventID, staleStatus = storedEventID, classificationMapStringValue(existing, "status") + return nil + } + if storedName != request.Name { + conflict = true + return nil + } + status, statusOK := classificationStateString(existing, "status") + if !statusOK { + conflict = true + return nil + } + existingCode := classificationMapStringValue(existing, "error_code") + existingMessage := classificationMapStringValue(existing, "error_message") + if status != "pending" { + if status == request.Status && existingCode == request.ErrorCode && existingMessage == request.ErrorMessage { + return nil + } + if !(status == "uncertain" && existingCode == "connector_delivery_uncertain") { + conflict = true + return nil + } + } + next := make(map[string]any, len(existing)+3) + for key, value := range existing { + next[key] = value + } + next["status"] = request.Status + next["error_code"] = request.ErrorCode + next["error_message"] = request.ErrorMessage + next["updated_at"] = time.Now().UTC() + metadata["swt_contact_name_operation"] = next + encoded, err := json.Marshal(metadata) + if err != nil { + return err + } + if err := tx.WithContext(ctx).Model(¤t).Update("channel_metadata", encoded).Error; err != nil { + return err + } + updated = true + return nil + }) + return +} + +func classificationMapStringValue(values map[string]any, key string) string { + value, _ := values[key].(string) + return strings.TrimSpace(value) } func NewShangwutongConnectorHandler(db *gorm.DB, messageSvc *service.MessageService, pools ...*worker.WorkerPool) *ShangwutongConnectorHandler { @@ -333,32 +554,15 @@ func (h *ShangwutongConnectorHandler) SyncClassifications(c *gin.Context) { h.connectorError(c, http.StatusServiceUnavailable, "classification_sync_unavailable", "classification sync is unavailable", true) return } - pending := map[string]any{"sync_status": "pending", "last_error_code": nil, "last_error_message": nil} - var cache model.ShangwutongClassificationCache - err := h.db.WithContext(c.Request.Context()).Where("inbox_id = ?", inbox.ID).First(&cache).Error - switch { - case errors.Is(err, gorm.ErrRecordNotFound): - cache = model.ShangwutongClassificationCache{InboxID: inbox.ID, ConversationKinds: datatypes.JSON([]byte(`[]`)), CustomerColorKinds: datatypes.JSON([]byte(`[]`)), SyncStatus: "pending"} - if err := h.db.WithContext(c.Request.Context()).Create(&cache).Error; err != nil { - h.connectorError(c, http.StatusInternalServerError, "classification_sync_state_failed", "failed to create sync state", true) - return - } - case err != nil: - h.connectorError(c, http.StatusInternalServerError, "classification_sync_state_failed", "failed to load sync state", true) - return - default: - if err := h.db.WithContext(c.Request.Context()).Model(&cache).Updates(pending).Error; err != nil { - h.connectorError(c, http.StatusInternalServerError, "classification_sync_state_failed", "failed to update sync state", true) - return - } - } - eventID, err := service.EnqueueShangwutongClassificationSync(c.Request.Context(), h.worker, inbox) + eventID := uuid.NewString() + job, created, err := h.enqueueClassificationSync(c.Request.Context(), inbox, eventID) if err != nil { - code, message := "classification_sync_queue_failed", err.Error() - _ = h.db.WithContext(c.Request.Context()).Model(&cache).Updates(map[string]any{"sync_status": "failed", "last_error_code": code, "last_error_message": message}) - h.connectorError(c, http.StatusServiceUnavailable, code, "failed to queue classification sync", true) + h.connectorError(c, http.StatusServiceUnavailable, "classification_sync_queue_failed", "failed to persist and queue classification sync", true) return } + if created { + h.worker.Publish(c.Request.Context(), job) + } c.JSON(http.StatusAccepted, gin.H{"sync_id": eventID, "sync_status": "pending"}) } @@ -407,11 +611,17 @@ func (h *ShangwutongConnectorHandler) UpdateConversationClassification(c *gin.Co return } var contactInbox model.ContactInbox - query := h.db.WithContext(c.Request.Context()).Where("inbox_id = ? AND contact_id = ?", inbox.ID, conversation.ContactID) - if conversation.ContactInboxID != nil && *conversation.ContactInboxID != 0 { - query = h.db.WithContext(c.Request.Context()).Where("id = ? AND inbox_id = ?", *conversation.ContactInboxID, inbox.ID) + if request.CustomerColorID != "" && (conversation.ContactInboxID == nil || *conversation.ContactInboxID == 0) { + h.connectorError(c, http.StatusUnprocessableEntity, "conversation_target_unavailable", "conversation has no Shangwutong session", false) + return } - if err := query.First(&contactInbox).Error; err != nil || strings.TrimSpace(contactInbox.SourceID) == "" { + contactInboxQuery := h.db.WithContext(c.Request.Context()).Where("contact_id = ? AND inbox_id = ?", conversation.ContactID, inbox.ID) + if conversation.ContactInboxID != nil && *conversation.ContactInboxID != 0 { + contactInboxQuery = h.db.WithContext(c.Request.Context()).Where( + "id = ? AND contact_id = ? AND inbox_id = ?", *conversation.ContactInboxID, conversation.ContactID, inbox.ID, + ) + } + if err := contactInboxQuery.First(&contactInbox).Error; err != nil || strings.TrimSpace(contactInbox.SourceID) == "" { h.connectorError(c, http.StatusUnprocessableEntity, "conversation_target_unavailable", "conversation has no Shangwutong session", false) return } @@ -459,11 +669,15 @@ func (h *ShangwutongConnectorHandler) UpdateConversationClassification(c *gin.Co h.connectorError(c, http.StatusServiceUnavailable, "classification_queue_unavailable", "classification queue is unavailable", true) return } - eventID, err := service.EnqueueShangwutongClassificationChange(c.Request.Context(), h.worker, &inbox, conversation.ID, contactInbox.SourceID, metadata.CID, "", request.CustomerColorID, colorName) + eventID := uuid.NewString() + job, created, err := h.enqueueClassificationChange(c.Request.Context(), &inbox, &conversation, contactInbox.ID, contactInbox.SourceID, metadata.CID, "", request.CustomerColorID, colorName, eventID) if err != nil { h.connectorError(c, http.StatusServiceUnavailable, "classification_queue_failed", "failed to queue classification change", true) return } + if created { + h.worker.Publish(c.Request.Context(), job) + } c.JSON(http.StatusAccepted, gin.H{"sync_id": eventID, "status": "pending", "customer_color_id": request.CustomerColorID}) return } @@ -471,14 +685,134 @@ func (h *ShangwutongConnectorHandler) UpdateConversationClassification(c *gin.Co h.connectorError(c, http.StatusServiceUnavailable, "classification_queue_unavailable", "classification queue is unavailable", true) return } - eventID, err := service.EnqueueShangwutongClassificationChange(c.Request.Context(), h.worker, &inbox, conversation.ID, contactInbox.SourceID, "", request.ChatKindID, "", "") + eventID := uuid.NewString() + job, created, err := h.enqueueClassificationChange(c.Request.Context(), &inbox, &conversation, contactInbox.ID, contactInbox.SourceID, "", request.ChatKindID, "", "", eventID) if err != nil { h.connectorError(c, http.StatusServiceUnavailable, "classification_queue_failed", "failed to queue classification change", true) return } + if created { + h.worker.Publish(c.Request.Context(), job) + } c.JSON(http.StatusAccepted, gin.H{"sync_id": eventID, "status": "pending", "chat_kind_id": request.ChatKindID}) } +func (h *ShangwutongConnectorHandler) enqueueClassificationSync(ctx context.Context, inbox *model.Inbox, eventID string) (*model.BackgroundJob, bool, error) { + var job *model.BackgroundJob + var created bool + err := h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var cache model.ShangwutongClassificationCache + err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("inbox_id = ?", inbox.ID).First(&cache).Error + switch { + case errors.Is(err, gorm.ErrRecordNotFound): + cache = model.ShangwutongClassificationCache{ + InboxID: inbox.ID, ConversationKinds: datatypes.JSON([]byte(`[]`)), CustomerColorKinds: datatypes.JSON([]byte(`[]`)), + SyncStatus: "pending", LastSyncEventID: eventID, + } + if err := tx.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "inbox_id"}}, + DoUpdates: clause.Assignments(map[string]any{ + "sync_status": "pending", "last_sync_event_id": eventID, + "last_error_code": nil, "last_error_message": nil, "synced_at": nil, + }), + }).Create(&cache).Error; err != nil { + return err + } + case err != nil: + return err + default: + if err := tx.WithContext(ctx).Model(&cache).Updates(map[string]any{ + "sync_status": "pending", "last_sync_event_id": eventID, "last_error_code": nil, "last_error_message": nil, "synced_at": nil, + }).Error; err != nil { + return err + } + } + job, created, err = service.EnqueueShangwutongClassificationSyncInTransaction(ctx, h.worker, tx, inbox, eventID) + return err + }) + return job, created, err +} + +func (h *ShangwutongConnectorHandler) enqueueClassificationChange(ctx context.Context, inbox *model.Inbox, conversation *model.Conversation, contactInboxID uint, sid, cid, chatKindID, customerColorID, customerColorName, eventID string) (*model.BackgroundJob, bool, error) { + var job *model.BackgroundJob + var created bool + err := h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := h.markClassificationStateInTransaction(ctx, tx, conversation, contactInboxID, sid, cid, classificationOperation(chatKindID, customerColorID), eventID, classificationValue(chatKindID, customerColorID), "pending", "", ""); err != nil { + return err + } + var err error + job, created, err = service.EnqueueShangwutongClassificationChangeInTransaction(ctx, h.worker, tx, inbox, conversation.ID, sid, cid, chatKindID, customerColorID, customerColorName, eventID) + return err + }) + return job, created, err +} + +func classificationOperation(chatKindID, customerColorID string) string { + if strings.TrimSpace(chatKindID) != "" { + return "set_chat_kind" + } + return "set_customer_color" +} + +func classificationValue(chatKindID, customerColorID string) string { + if strings.TrimSpace(chatKindID) != "" { + return strings.TrimSpace(chatKindID) + } + return strings.TrimSpace(customerColorID) +} + +func (h *ShangwutongConnectorHandler) markClassificationStateInTransaction(ctx context.Context, tx *gorm.DB, conversation *model.Conversation, contactInboxID uint, sid, cid, operation, eventID, value, status, errorCode, errorMessage string) error { + var current model.Conversation + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", conversation.ID).First(¤t).Error; err != nil { + return err + } + attributes := map[string]any{} + if len(current.AdditionalAttributes) > 0 { + if err := json.Unmarshal(current.AdditionalAttributes, &attributes); err != nil { + return err + } + } + if attributes == nil { + attributes = map[string]any{} + } + operations, _ := attributes["swt_classification_operations"].(map[string]any) + if operations == nil { + operations = map[string]any{} + } + if existing, ok := operations[operation].(map[string]any); ok { + if existingEventID, _ := existing["event_id"].(string); existingEventID == eventID && existing["status"] == status && existing["value"] == value { + return nil + } + } + state := map[string]any{ + "event_id": eventID, "operation": operation, "status": status, "value": value, + "account_id": current.AccountID, "inbox_id": current.InboxID, "conversation_id": current.ID, + "contact_id": current.ContactID, "contact_inbox_id": contactInboxID, + "swt_session_id": strings.TrimSpace(sid), "cid": strings.TrimSpace(cid), + "error_code": errorCode, "error_message": errorMessage, + "updated_at": time.Now().UTC(), + } + operations[operation] = state + attributes["swt_classification_operations"] = operations + attributes["swt_classification_status"] = status + attributes["swt_classification_event_id"] = eventID + if errorCode != "" { + attributes["swt_classification_error_code"] = errorCode + } else { + delete(attributes, "swt_classification_error_code") + } + if errorMessage != "" { + attributes["swt_classification_error"] = errorMessage + } else { + delete(attributes, "swt_classification_error") + } + encoded, err := json.Marshal(attributes) + if err != nil { + return err + } + return tx.WithContext(ctx).Model(¤t).Update("additional_attributes", encoded).Error +} + func containsConversationKind(kinds []shangwutongConversationKind, id string) bool { for _, kind := range kinds { if kind.ID == id { @@ -494,7 +828,12 @@ func (h *ShangwutongConnectorHandler) UpdateClassificationCatalog(c *gin.Context return } var request shangwutongClassificationCallbackRequest - if err := c.ShouldBindJSON(&request); err != nil || strings.TrimSpace(request.EventID) == "" || len(request.EventID) > 128 { + if err := c.ShouldBindJSON(&request); err != nil { + h.connectorError(c, http.StatusUnprocessableEntity, "invalid_classifications", "classification payload is invalid", false) + return + } + request.EventID = strings.TrimSpace(request.EventID) + if request.EventID == "" || len(request.EventID) > 128 { h.connectorError(c, http.StatusUnprocessableEntity, "invalid_classifications", "classification payload is invalid", false) return } @@ -517,30 +856,62 @@ func (h *ShangwutongConnectorHandler) UpdateClassificationCatalog(c *gin.Context h.connectorError(c, http.StatusUnprocessableEntity, "invalid_classifications", "classification payload is invalid", false) return } - var cache model.ShangwutongClassificationCache - err = h.db.WithContext(c.Request.Context()).Where("inbox_id = ?", inbox.ID).First(&cache).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - cache = model.ShangwutongClassificationCache{InboxID: inbox.ID} - } else if err != nil { - h.connectorError(c, http.StatusInternalServerError, "classification_cache_update_failed", "failed to load classification cache", true) - return - } else if cache.LastSyncEventID == request.EventID { - c.JSON(http.StatusOK, gin.H{"updated": false, "sync_status": cache.SyncStatus}) - return - } - now := time.Now().UTC() - cache.ConversationKinds = conversationKinds - cache.CustomerColorKinds = customerColors - cache.SyncStatus = "succeeded" - cache.SyncedAt = &now - cache.LastErrorCode = nil - cache.LastErrorMessage = nil - cache.LastSyncEventID = request.EventID - if err := h.db.WithContext(c.Request.Context()).Save(&cache).Error; err != nil { + var catalogStale bool + var catalogNoop bool + var catalogConflict bool + var catalogEventID, catalogStatus string + err = h.db.WithContext(c.Request.Context()).Transaction(func(tx *gorm.DB) error { + var cache model.ShangwutongClassificationCache + err := tx.WithContext(c.Request.Context()).Clauses(clause.Locking{Strength: "UPDATE"}).Where("inbox_id = ?", inbox.ID).First(&cache).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + catalogEventID, catalogStatus = "", "never" + catalogConflict = true + return nil + } + if err != nil { + return err + } + if cache.LastSyncEventID != request.EventID { + catalogStale = true + catalogEventID, catalogStatus = cache.LastSyncEventID, cache.SyncStatus + return nil + } + if cache.SyncStatus == "succeeded" { + if string(cache.ConversationKinds) == string(conversationKinds) && string(cache.CustomerColorKinds) == string(customerColors) { + catalogNoop = true + return nil + } + catalogConflict = true + return nil + } + now := time.Now().UTC() + if err := tx.WithContext(c.Request.Context()).Model(&cache).Updates(map[string]any{ + "conversation_kinds": conversationKinds, "customer_color_kinds": customerColors, + "sync_status": "succeeded", "synced_at": &now, "last_error_code": nil, + "last_error_message": nil, "last_sync_event_id": request.EventID, + }).Error; err != nil { + return err + } + catalogStatus = "succeeded" + return nil + }) + if err != nil { h.connectorError(c, http.StatusInternalServerError, "classification_cache_update_failed", "failed to save classifications", true) return } - c.JSON(http.StatusOK, gin.H{"updated": true, "sync_status": cache.SyncStatus, "synced_at": cache.SyncedAt}) + if catalogConflict { + h.connectorError(c, http.StatusConflict, "unknown_operation", "classification sync operation is not pending", false) + return + } + if catalogStale { + c.JSON(http.StatusOK, gin.H{"updated": false, "stale": true, "sync_status": catalogStatus, "event_id": catalogEventID}) + return + } + if catalogNoop { + c.JSON(http.StatusOK, gin.H{"updated": false, "sync_status": "succeeded", "event_id": request.EventID}) + return + } + c.JSON(http.StatusOK, gin.H{"updated": true, "sync_status": "succeeded"}) } type shangwutongClassificationSyncStatusRequest struct { @@ -556,7 +927,15 @@ func (h *ShangwutongConnectorHandler) UpdateClassificationSyncStatus(c *gin.Cont return } var request shangwutongClassificationSyncStatusRequest - if err := c.ShouldBindJSON(&request); err != nil || strings.TrimSpace(request.EventID) == "" || request.Status != "failed" { + if err := c.ShouldBindJSON(&request); err != nil { + h.connectorError(c, http.StatusUnprocessableEntity, "invalid_classification_sync_status", "classification sync status is invalid", false) + return + } + request.EventID = strings.TrimSpace(request.EventID) + request.Status = strings.TrimSpace(request.Status) + request.ErrorCode = strings.TrimSpace(request.ErrorCode) + request.ErrorMessage = strings.TrimSpace(request.ErrorMessage) + if request.EventID == "" || request.Status != "failed" || len(request.ErrorCode) > 128 || len(request.ErrorMessage) > 1024 { h.connectorError(c, http.StatusUnprocessableEntity, "invalid_classification_sync_status", "classification sync status is invalid", false) return } @@ -565,23 +944,63 @@ func (h *ShangwutongConnectorHandler) UpdateClassificationSyncStatus(c *gin.Cont h.connectorError(c, http.StatusUnprocessableEntity, "invalid_idempotency_key", "Idempotency-Key does not match event_id", false) return } - var cache model.ShangwutongClassificationCache - if err := h.db.WithContext(c.Request.Context()).Where("inbox_id = ?", inbox.ID).First(&cache).Error; err != nil { + var syncStale bool + var syncNoop bool + var syncConflict bool + var syncEventID, syncStatus string + err := h.db.WithContext(c.Request.Context()).Transaction(func(tx *gorm.DB) error { + var cache model.ShangwutongClassificationCache + err := tx.WithContext(c.Request.Context()).Clauses(clause.Locking{Strength: "UPDATE"}).Where("inbox_id = ?", inbox.ID).First(&cache).Error if errors.Is(err, gorm.ErrRecordNotFound) { - cache = model.ShangwutongClassificationCache{InboxID: inbox.ID, ConversationKinds: datatypes.JSON([]byte(`[]`)), CustomerColorKinds: datatypes.JSON([]byte(`[]`))} - } else { - h.connectorError(c, http.StatusInternalServerError, "classification_cache_update_failed", "failed to load classification cache", true) - return + syncConflict = true + return nil } - } - cache.SyncStatus = request.Status - cache.LastErrorCode = stringPointer(request.ErrorCode) - cache.LastErrorMessage = stringPointer(request.ErrorMessage) - if err := h.db.WithContext(c.Request.Context()).Save(&cache).Error; err != nil { + if err != nil { + return err + } + if cache.LastSyncEventID != request.EventID { + syncStale = true + syncEventID, syncStatus = cache.LastSyncEventID, cache.SyncStatus + return nil + } + if cache.SyncStatus == "succeeded" { + syncConflict = true + return nil + } + if cache.SyncStatus == "failed" && dereferenceString(cache.LastErrorCode) == request.ErrorCode && dereferenceString(cache.LastErrorMessage) == request.ErrorMessage { + syncNoop = true + return nil + } + if cache.SyncStatus == "failed" { + syncConflict = true + return nil + } + if err := tx.WithContext(c.Request.Context()).Model(&cache).Updates(map[string]any{ + "last_sync_event_id": request.EventID, "sync_status": request.Status, + "last_error_code": stringPointer(request.ErrorCode), "last_error_message": stringPointer(request.ErrorMessage), + }).Error; err != nil { + return err + } + syncStatus = request.Status + return nil + }) + if err != nil { h.connectorError(c, http.StatusInternalServerError, "classification_cache_update_failed", "failed to save classification sync status", true) return } - c.JSON(http.StatusOK, gin.H{"updated": true, "sync_status": cache.SyncStatus}) + if syncConflict { + h.connectorError(c, http.StatusConflict, "idempotency_conflict", "classification sync result conflicts with its operation", false) + return + } + if syncStale { + c.JSON(http.StatusOK, gin.H{"updated": false, "stale": true, "sync_status": syncStatus, "event_id": syncEventID}) + return + } + if syncNoop { + c.JSON(http.StatusOK, gin.H{"updated": false, "sync_status": "failed", "event_id": request.EventID}) + return + } + c.JSON(http.StatusOK, gin.H{"updated": true, "sync_status": syncStatus}) } func stringPointer(value string) *string { @@ -592,6 +1011,13 @@ func stringPointer(value string) *string { return &value } +func dereferenceString(value *string) string { + if value == nil { + return "" + } + return strings.TrimSpace(*value) +} + type shangwutongClassificationStatusRequest struct { EventID string `json:"event_id"` Operation string `json:"operation"` @@ -602,6 +1028,36 @@ type shangwutongClassificationStatusRequest struct { ErrorMessage string `json:"error_message"` } +func classificationStateString(state map[string]any, key string) (string, bool) { + value, ok := state[key].(string) + value = strings.TrimSpace(value) + return value, ok && value != "" +} + +func classificationStateUint(state map[string]any, key string) (uint, bool) { + value, ok := state[key] + if !ok { + return 0, false + } + switch value := value.(type) { + case float64: + if value <= 0 || value != float64(uint(value)) { + return 0, false + } + return uint(value), true + case int: + return uint(value), value > 0 + case int64: + return uint(value), value > 0 + case uint: + return value, value > 0 + case uint64: + return uint(value), value > 0 && uint64(uint(value)) == value + default: + return 0, false + } +} + func (h *ShangwutongConnectorHandler) UpdateClassificationStatus(c *gin.Context) { inbox, ok := h.authorizedInbox(c) if !ok { @@ -613,7 +1069,18 @@ func (h *ShangwutongConnectorHandler) UpdateClassificationStatus(c *gin.Context) return } var request shangwutongClassificationStatusRequest - if err := c.ShouldBindJSON(&request); err != nil || strings.TrimSpace(request.EventID) == "" || request.Operation == "" || (request.Status != "succeeded" && request.Status != "failed" && request.Status != "uncertain") { + if err := c.ShouldBindJSON(&request); err != nil { + h.connectorError(c, http.StatusUnprocessableEntity, "invalid_classification_status", "classification status is invalid", false) + return + } + request.EventID = strings.TrimSpace(request.EventID) + request.Operation = strings.TrimSpace(request.Operation) + request.Status = strings.TrimSpace(request.Status) + request.ChatKindID = strings.TrimSpace(request.ChatKindID) + request.CustomerColorID = strings.TrimSpace(request.CustomerColorID) + request.ErrorCode = strings.TrimSpace(request.ErrorCode) + request.ErrorMessage = strings.TrimSpace(request.ErrorMessage) + if request.EventID == "" || !oneOf(request.Status, "succeeded", "failed", "uncertain") || len(request.ErrorCode) > 128 || len(request.ErrorMessage) > 1024 { h.connectorError(c, http.StatusUnprocessableEntity, "invalid_classification_status", "classification status is invalid", false) return } @@ -622,9 +1089,10 @@ func (h *ShangwutongConnectorHandler) UpdateClassificationStatus(c *gin.Context) h.connectorError(c, http.StatusUnprocessableEntity, "invalid_idempotency_key", "Idempotency-Key does not match event_id", false) return } - request.Operation = strings.TrimSpace(request.Operation) - request.ChatKindID, request.CustomerColorID = strings.TrimSpace(request.ChatKindID), strings.TrimSpace(request.CustomerColorID) - if request.Operation != "set_chat_kind" && request.Operation != "set_customer_color" || (request.ChatKindID == "") == (request.CustomerColorID == "") { + if request.Operation != "set_chat_kind" && request.Operation != "set_customer_color" || + (request.Operation == "set_chat_kind" && request.ChatKindID == "") || + (request.Operation == "set_customer_color" && request.CustomerColorID == "") || + (request.ChatKindID != "" && request.CustomerColorID != "") { h.connectorError(c, http.StatusUnprocessableEntity, "invalid_classification_status", "classification operation and target are invalid", false) return } @@ -640,35 +1108,308 @@ func (h *ShangwutongConnectorHandler) UpdateClassificationStatus(c *gin.Context) return } } - if request.Status == "succeeded" { - if request.ChatKindID != "" { - attributes["swt_chat_kind"] = request.ChatKindID + if attributes == nil { + attributes = map[string]any{} + } + operationValue := request.ChatKindID + if request.Operation == "set_customer_color" { + operationValue = request.CustomerColorID + } + operations, _ := attributes["swt_classification_operations"].(map[string]any) + if operations == nil { + operations = map[string]any{} + } + existing, ok := operations[request.Operation].(map[string]any) + if !ok { + h.connectorError(c, http.StatusConflict, "unknown_operation", "classification operation is not pending", false) + return + } + existingEventID, _ := existing["event_id"].(string) + if existingEventID == "" { + h.connectorError(c, http.StatusConflict, "unknown_operation", "classification operation is not pending", false) + return + } + if existingContactID, ok := existing["contact_id"].(float64); ok && uint(existingContactID) != conversation.ContactID { + h.connectorError(c, http.StatusConflict, "stale_operation", "classification operation targets a different contact", false) + return + } + if existingContactInboxID, ok := existing["contact_inbox_id"].(float64); ok && conversation.ContactInboxID != nil && uint(existingContactInboxID) != *conversation.ContactInboxID { + h.connectorError(c, http.StatusConflict, "stale_operation", "classification operation targets a different contact inbox", false) + return + } + if existingEventID != request.EventID { + status, _ := existing["status"].(string) + c.JSON(http.StatusOK, gin.H{"updated": false, "stale": true, "event_id": existingEventID, "status": status}) + return + } + existingStatus, _ := existing["status"].(string) + existingValue, _ := existing["value"].(string) + existingCode, _ := existing["error_code"].(string) + existingMessage, _ := existing["error_message"].(string) + if existingValue != operationValue { + h.connectorError(c, http.StatusConflict, "idempotency_conflict", "classification result changes its target value", false) + return + } + if existingStatus != "pending" && existingStatus != "" && existingStatus != request.Status && + !(existingStatus == "uncertain" && existingCode == "connector_delivery_uncertain") { + h.connectorError(c, http.StatusConflict, "idempotency_conflict", "classification result conflicts with its terminal state", false) + return + } + if existingStatus == request.Status && existingValue == operationValue && existingCode == request.ErrorCode && existingMessage == request.ErrorMessage { + c.JSON(http.StatusOK, gin.H{"updated": false, "status": existingStatus, "event_id": existingEventID}) + return + } + var staleEventID, staleStatus string + var classificationConflict bool + var classificationErrorCode, classificationErrorMessage string + var classificationNoop bool + if err := h.db.WithContext(c.Request.Context()).Transaction(func(tx *gorm.DB) error { + var conversations []model.Conversation + var colorContactInboxIDs map[uint]struct{} + query := tx.WithContext(c.Request.Context()).Clauses(clause.Locking{Strength: "UPDATE"}).Where( + "account_id = ? AND inbox_id = ?", conversation.AccountID, conversation.InboxID, + ) + if request.Operation == "set_customer_color" && request.Status == "succeeded" { + currentCID := "" + if conversation.ContactInboxID == nil || *conversation.ContactInboxID == 0 { + classificationErrorCode = "stale_operation" + classificationErrorMessage = "classification operation has no active customer binding" + return nil + } + var currentBinding model.ContactInbox + if err := tx.WithContext(c.Request.Context()).Clauses(clause.Locking{Strength: "UPDATE"}).Where( + "id = ? AND contact_id = ? AND inbox_id = ?", *conversation.ContactInboxID, conversation.ContactID, inbox.ID, + ).First(¤tBinding).Error; err != nil { + classificationErrorCode = "stale_operation" + classificationErrorMessage = "classification operation has no active customer binding" + return nil + } + var currentMetadata struct { + CID string `json:"cid"` + } + if len(currentBinding.ChannelMetadata) > 0 && json.Unmarshal(currentBinding.ChannelMetadata, ¤tMetadata) == nil { + currentCID = strings.TrimSpace(currentMetadata.CID) + } + if currentCID == "" { + classificationErrorCode = "stale_operation" + classificationErrorMessage = "classification operation has no active customer binding" + return nil + } + var contactInboxes []model.ContactInbox + if err := tx.WithContext(c.Request.Context()).Clauses(clause.Locking{Strength: "UPDATE"}).Where("inbox_id = ?", inbox.ID).Find(&contactInboxes).Error; err != nil { + return err + } + targetContactInboxIDs := make([]uint, 0, len(contactInboxes)) + colorContactInboxIDs = make(map[uint]struct{}, len(contactInboxes)) + for _, candidate := range contactInboxes { + var metadata struct { + CID string `json:"cid"` + } + if len(candidate.ChannelMetadata) == 0 || json.Unmarshal(candidate.ChannelMetadata, &metadata) != nil { + continue + } + if strings.TrimSpace(metadata.CID) == currentCID { + targetContactInboxIDs = append(targetContactInboxIDs, candidate.ID) + colorContactInboxIDs[candidate.ID] = struct{}{} + } + } + if len(targetContactInboxIDs) == 0 { + classificationErrorCode = "stale_operation" + classificationErrorMessage = "classification operation has no active customer binding" + return nil + } + query = query.Where("contact_inbox_id IN ?", targetContactInboxIDs) } else { - attributes["swt_label_color"] = request.CustomerColorID + query = query.Where("id = ?", conversation.ID) + } + if err := query.Order("id ASC").Find(&conversations).Error; err != nil { + return err + } + var source *model.Conversation + for i := range conversations { + if conversations[i].ID == conversation.ID { + source = &conversations[i] + break + } + } + if source == nil { + return gorm.ErrRecordNotFound + } + + sourceAttributes := map[string]any{} + if len(source.AdditionalAttributes) > 0 { + if err := json.Unmarshal(source.AdditionalAttributes, &sourceAttributes); err != nil { + return err + } + } + if sourceAttributes == nil { + sourceAttributes = map[string]any{} + } + sourceOperations, _ := sourceAttributes["swt_classification_operations"].(map[string]any) + sourceState, ok := sourceOperations[request.Operation].(map[string]any) + if !ok { + classificationErrorCode = "unknown_operation" + classificationErrorMessage = "classification operation is not pending" + return nil + } + storedEventID, eventOK := classificationStateString(sourceState, "event_id") + storedOperation, operationOK := classificationStateString(sourceState, "operation") + storedValue, valueOK := classificationStateString(sourceState, "value") + storedStatus, statusOK := classificationStateString(sourceState, "status") + storedAccountID, accountOK := classificationStateUint(sourceState, "account_id") + storedInboxID, inboxOK := classificationStateUint(sourceState, "inbox_id") + storedConversationID, conversationOK := classificationStateUint(sourceState, "conversation_id") + storedContactID, contactOK := classificationStateUint(sourceState, "contact_id") + storedContactInboxID, contactInboxOK := classificationStateUint(sourceState, "contact_inbox_id") + storedSessionID, sessionOK := classificationStateString(sourceState, "swt_session_id") + storedCID, _ := sourceState["cid"].(string) + storedCID = strings.TrimSpace(storedCID) + if !eventOK || !operationOK || !valueOK || !statusOK || !accountOK || !inboxOK || !conversationOK || !contactOK || !contactInboxOK || !sessionOK || storedOperation != request.Operation { + classificationErrorCode = "unknown_operation" + classificationErrorMessage = "classification operation identity is incomplete" + return nil + } + if storedAccountID != inbox.AccountID || storedInboxID != inbox.ID || storedConversationID != source.ID || storedContactID != source.ContactID { + classificationErrorCode = "stale_operation" + classificationErrorMessage = "classification operation targets a different object" + return nil + } + if source.ContactInboxID != nil && *source.ContactInboxID != storedContactInboxID { + classificationErrorCode = "stale_operation" + classificationErrorMessage = "classification operation targets a different contact inbox" + return nil + } + var contactInbox model.ContactInbox + if err := tx.WithContext(c.Request.Context()).Where( + "id = ? AND contact_id = ? AND inbox_id = ?", storedContactInboxID, source.ContactID, inbox.ID, + ).First(&contactInbox).Error; err != nil || strings.TrimSpace(contactInbox.SourceID) != storedSessionID { + classificationErrorCode = "stale_operation" + classificationErrorMessage = "classification operation targets a different remote session" + return nil + } + var contactMetadata struct { + CID string `json:"cid"` + } + if len(contactInbox.ChannelMetadata) > 0 && json.Unmarshal(contactInbox.ChannelMetadata, &contactMetadata) != nil { + classificationErrorCode = "stale_operation" + classificationErrorMessage = "classification operation metadata is invalid" + return nil + } + if request.Operation == "set_customer_color" && (storedCID == "" || strings.TrimSpace(contactMetadata.CID) != storedCID) { + classificationErrorCode = "stale_operation" + classificationErrorMessage = "classification operation targets a different customer" + return nil + } + if storedEventID != request.EventID { + staleEventID, staleStatus = storedEventID, storedStatus + return nil + } + if storedValue != operationValue { + classificationErrorCode = "idempotency_conflict" + classificationErrorMessage = "classification result changes its target value" + return nil + } + if storedStatus != "pending" { + existingCode, _ := sourceState["error_code"].(string) + existingMessage, _ := sourceState["error_message"].(string) + if storedStatus == request.Status && existingCode == request.ErrorCode && existingMessage == request.ErrorMessage { + classificationNoop = true + return nil + } + if !(storedStatus == "uncertain" && existingCode == "connector_delivery_uncertain") { + classificationConflict = true + return nil + } + } + + nextState := make(map[string]any, len(sourceState)+2) + for key, value := range sourceState { + nextState[key] = value + } + nextState["status"] = request.Status + nextState["error_code"] = request.ErrorCode + nextState["error_message"] = request.ErrorMessage + nextState["updated_at"] = time.Now().UTC() + sourceOperations[request.Operation] = nextState + for i := range conversations { + isSource := conversations[i].ID == source.ID + isColorTarget := isSource + if request.Operation == "set_customer_color" && request.Status == "succeeded" && !isSource && conversations[i].ContactInboxID != nil { + _, isColorTarget = colorContactInboxIDs[*conversations[i].ContactInboxID] + } + if !isSource && !isColorTarget { + continue + } + current := map[string]any{} + if len(conversations[i].AdditionalAttributes) > 0 { + if err := json.Unmarshal(conversations[i].AdditionalAttributes, ¤t); err != nil { + return err + } + } + if current == nil { + current = map[string]any{} + } + if request.Status == "succeeded" { + if request.Operation == "set_chat_kind" { + current["swt_chat_kind"] = request.ChatKindID + } else { + current["swt_label_color"] = request.CustomerColorID + } + } + if isSource { + currentOperations, _ := current["swt_classification_operations"].(map[string]any) + if currentOperations == nil { + currentOperations = map[string]any{} + } + currentOperations[request.Operation] = nextState + current["swt_classification_operations"] = currentOperations + current["swt_classification_status"] = request.Status + current["swt_classification_event_id"] = request.EventID + if request.ErrorCode != "" { + current["swt_classification_error_code"] = request.ErrorCode + } else { + delete(current, "swt_classification_error_code") + } + if request.ErrorMessage != "" { + current["swt_classification_error"] = request.ErrorMessage + } else { + delete(current, "swt_classification_error") + } + } + updated, err := json.Marshal(current) + if err != nil { + return err + } + if err := tx.WithContext(c.Request.Context()).Model(&conversations[i]).Update("additional_attributes", updated).Error; err != nil { + return err + } + } + return nil + }); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + h.connectorError(c, http.StatusNotFound, "not_found", "conversation not found", false) + } else { + h.connectorError(c, http.StatusInternalServerError, "conversation_update_failed", "failed to update conversation classification", true) } - } - attributes["swt_classification_status"] = request.Status - attributes["swt_classification_event_id"] = request.EventID - if request.ErrorCode != "" { - attributes["swt_classification_error_code"] = request.ErrorCode - } else { - delete(attributes, "swt_classification_error_code") - } - if request.ErrorMessage != "" { - attributes["swt_classification_error"] = request.ErrorMessage - } else { - delete(attributes, "swt_classification_error") - } - updatedAttributes, err := json.Marshal(attributes) - if err != nil { - h.connectorError(c, http.StatusInternalServerError, "conversation_attributes_invalid", "conversation attributes are invalid", true) return } - if err := h.db.WithContext(c.Request.Context()).Model(&conversation).Update("additional_attributes", updatedAttributes).Error; err != nil { - h.connectorError(c, http.StatusInternalServerError, "conversation_update_failed", "failed to update conversation classification", true) + if classificationErrorCode != "" { + h.connectorError(c, http.StatusConflict, classificationErrorCode, classificationErrorMessage, false) return } - c.JSON(http.StatusOK, gin.H{"updated": true, "status": request.Status}) + if classificationConflict { + h.connectorError(c, http.StatusConflict, "idempotency_conflict", "classification result conflicts with its terminal state", false) + return + } + if classificationNoop { + c.JSON(http.StatusOK, gin.H{"updated": false, "status": request.Status, "event_id": request.EventID}) + return + } + if staleEventID != "" { + c.JSON(http.StatusOK, gin.H{"updated": false, "stale": true, "event_id": staleEventID, "status": staleStatus}) + return + } + c.JSON(http.StatusOK, gin.H{"updated": true, "status": request.Status, "event_id": request.EventID}) } func (h *ShangwutongConnectorHandler) userInbox(c *gin.Context) (*model.Inbox, bool) { diff --git a/backend/internal/handler/api/v1/shangwutong_connector_handler_test.go b/backend/internal/handler/api/v1/shangwutong_connector_handler_test.go index bc154af4..3db8dd26 100644 --- a/backend/internal/handler/api/v1/shangwutong_connector_handler_test.go +++ b/backend/internal/handler/api/v1/shangwutong_connector_handler_test.go @@ -181,6 +181,140 @@ func TestShangwutongClassificationUpdateAcceptsConversationDisplayID(t *testing. require.Contains(t, string(job.Payload), fmt.Sprintf(`"conversation_id":%d`, conversation.ID)) } +func TestShangwutongConnectorContactOperationStatusIsIdempotentAndStaleSafe(t *testing.T) { + router, db, token, inbox, _ := setupShangwutongConnectorAPI(t) + contact := &model.Contact{AccountID: inbox.AccountID, Name: "Visitor"} + require.NoError(t, db.Create(contact).Error) + ci := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, SourceID: "visitor", ChannelMetadata: datatypes.JSON([]byte(`{"cid":"cid-1","swt_contact_name_operation":{"event_id":"contact-event-1","operation":"change_contact_name","status":"pending","account_id":1,"inbox_id":1,"contact_inbox_id":1,"source_id":"visitor","contact_id":1,"name":"Renamed"}}`))} + require.NoError(t, db.Create(ci).Error) + path := fmt.Sprintf("/api/v1/connector/shangwutong/inboxes/%d/contacts/visitor/status", inbox.ID) + body := map[string]any{"event_id": "contact-event-1", "operation": "change_contact_name", "contact_id": contact.ID, "name": "Renamed", "status": "succeeded"} + request := func(payload map[string]any) *httptest.ResponseRecorder { + encoded, err := json.Marshal(payload) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPut, path, bytes.NewReader(encoded)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + eventID, _ := payload["event_id"].(string) + req.Header.Set("Idempotency-Key", fmt.Sprintf("swt-contact-operation:%d:%s", inbox.ID, eventID)) + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + return response + } + updated := request(body) + require.Equal(t, http.StatusOK, updated.Code, updated.Body.String()) + replayed := request(body) + require.Equal(t, http.StatusOK, replayed.Code, replayed.Body.String()) + require.Contains(t, replayed.Body.String(), `"updated":false`) + body["status"] = "failed" + body["error_code"] = "remote_rejected" + conflict := request(body) + require.Equal(t, http.StatusConflict, conflict.Code, conflict.Body.String()) + body["event_id"] = "old-event" + body["status"] = "failed" + stale := request(body) + require.Equal(t, http.StatusOK, stale.Code, stale.Body.String()) + require.Contains(t, stale.Body.String(), `"stale":true`) + + require.NoError(t, db.First(ci, ci.ID).Error) + var metadata map[string]any + require.NoError(t, json.Unmarshal(ci.ChannelMetadata, &metadata)) + state := metadata["swt_contact_name_operation"].(map[string]any) + require.Equal(t, "succeeded", state["status"]) +} + +func TestShangwutongClassificationCallbackIsScopedAndStaleSafe(t *testing.T) { + router, db, token, inbox, _ := setupShangwutongConnectorAPI(t) + contact := &model.Contact{AccountID: inbox.AccountID, Name: "Visitor"} + require.NoError(t, db.Create(contact).Error) + contactInbox := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, SourceID: "visitor", ChannelMetadata: datatypes.JSON([]byte(`{"cid":"cid-1"}`))} + otherContactInbox := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, SourceID: "visitor-other", ChannelMetadata: datatypes.JSON([]byte(`{"cid":"cid-2"}`))} + sameCIDContact := &model.Contact{AccountID: inbox.AccountID, Name: "Merged visitor"} + require.NoError(t, db.Create(contactInbox).Error) + require.NoError(t, db.Create(otherContactInbox).Error) + require.NoError(t, db.Create(sameCIDContact).Error) + sameCIDContactInbox := &model.ContactInbox{ContactID: sameCIDContact.ID, InboxID: inbox.ID, SourceID: "visitor-same-cid", ChannelMetadata: datatypes.JSON([]byte(`{"cid":"cid-1"}`))} + require.NoError(t, db.Create(sameCIDContactInbox).Error) + conversation := &model.Conversation{AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, ContactInboxID: &contactInbox.ID, Status: "open"} + peer := &model.Conversation{AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, ContactInboxID: &contactInbox.ID, Status: "open", AdditionalAttributes: datatypes.JSON([]byte(`{"keep":"yes"}`))} + otherConversation := &model.Conversation{AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, ContactInboxID: &otherContactInbox.ID, Status: "open"} + sameCIDConversation := &model.Conversation{AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: sameCIDContact.ID, ContactInboxID: &sameCIDContactInbox.ID, Status: "open"} + require.NoError(t, db.Create(conversation).Error) + require.NoError(t, db.Create(peer).Error) + require.NoError(t, db.Create(otherConversation).Error) + require.NoError(t, db.Create(sameCIDConversation).Error) + require.NoError(t, db.Create(&model.ShangwutongClassificationCache{ + InboxID: inbox.ID, ConversationKinds: datatypes.JSON([]byte(`[]`)), CustomerColorKinds: datatypes.JSON([]byte(`[{"id":"color-1","name":"VIP"},{"id":"color-2","name":"New"}]`)), SyncStatus: "succeeded", + }).Error) + changePath := fmt.Sprintf("/api/v1/accounts/%d/conversations/%d/shangwutong-classifications", inbox.AccountID, conversation.ID) + accepted := connectorRequest(t, router, "", http.MethodPatch, changePath, map[string]string{"customer_color_id": "color-1"}) + require.Equal(t, http.StatusAccepted, accepted.Code, accepted.Body.String()) + var queued struct { + SyncID string `json:"sync_id"` + } + require.NoError(t, json.Unmarshal(accepted.Body.Bytes(), &queued)) + require.NotEmpty(t, queued.SyncID) + callbackPath := fmt.Sprintf("/api/v1/connector/shangwutong/inboxes/%d/conversations/%d/classifications/status", inbox.ID, conversation.ID) + callback := func(eventID string, body map[string]any) *httptest.ResponseRecorder { + encoded, err := json.Marshal(body) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPut, callbackPath, bytes.NewReader(encoded)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Idempotency-Key", fmt.Sprintf("swt-classification-operation:%d:%s", inbox.ID, eventID)) + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + return response + } + result := map[string]any{"event_id": queued.SyncID, "operation": "set_customer_color", "status": "succeeded", "customer_color_id": "color-1"} + response := callback(queued.SyncID, result) + require.Equal(t, http.StatusOK, response.Code, response.Body.String()) + replayed := callback(queued.SyncID, result) + require.Equal(t, http.StatusOK, replayed.Code, replayed.Body.String()) + require.Contains(t, replayed.Body.String(), `"updated":false`) + conflicting := map[string]any{"event_id": queued.SyncID, "operation": "set_customer_color", "status": "succeeded", "customer_color_id": "color-2"} + conflict := callback(queued.SyncID, conflicting) + require.Equal(t, http.StatusConflict, conflict.Code, conflict.Body.String()) + require.Contains(t, conflict.Body.String(), `"code":"idempotency_conflict"`) + var updatedConversation, updatedPeer, updatedOther, updatedSameCID model.Conversation + require.NoError(t, db.First(&updatedConversation, conversation.ID).Error) + require.NoError(t, db.First(&updatedPeer, peer.ID).Error) + require.NoError(t, db.First(&updatedOther, otherConversation.ID).Error) + require.NoError(t, db.First(&updatedSameCID, sameCIDConversation.ID).Error) + var conversationAttributes, peerAttributes, sameCIDAttributes map[string]any + require.NoError(t, json.Unmarshal(updatedConversation.AdditionalAttributes, &conversationAttributes)) + require.NoError(t, json.Unmarshal(updatedPeer.AdditionalAttributes, &peerAttributes)) + require.NoError(t, json.Unmarshal(updatedSameCID.AdditionalAttributes, &sameCIDAttributes)) + require.Equal(t, "color-1", conversationAttributes["swt_label_color"]) + require.Equal(t, "color-1", peerAttributes["swt_label_color"]) + require.Equal(t, "color-1", sameCIDAttributes["swt_label_color"]) + require.Equal(t, "yes", peerAttributes["keep"]) + require.NotContains(t, string(updatedOther.AdditionalAttributes), "swt_label_color") + + accepted = connectorRequest(t, router, "", http.MethodPatch, changePath, map[string]string{"customer_color_id": "color-2"}) + require.Equal(t, http.StatusAccepted, accepted.Code, accepted.Body.String()) + var next struct { + SyncID string `json:"sync_id"` + } + require.NoError(t, json.Unmarshal(accepted.Body.Bytes(), &next)) + stale := callback(queued.SyncID, result) + require.Equal(t, http.StatusOK, stale.Code, stale.Body.String()) + require.Contains(t, stale.Body.String(), `"stale":true`) + + var pendingConversation model.Conversation + require.NoError(t, db.First(&pendingConversation, conversation.ID).Error) + var pendingAttributes map[string]any + require.NoError(t, json.Unmarshal(pendingConversation.AdditionalAttributes, &pendingAttributes)) + pendingState := pendingAttributes["swt_classification_operations"].(map[string]any)["set_customer_color"].(map[string]any) + pendingState["status"], pendingState["error_code"], pendingState["error_message"] = "uncertain", "connector_delivery_uncertain", "delivery acknowledgement was not received" + pendingEncoded, err := json.Marshal(pendingAttributes) + require.NoError(t, err) + require.NoError(t, db.Model(&pendingConversation).Update("additional_attributes", datatypes.JSON(pendingEncoded)).Error) + resolved := callback(next.SyncID, map[string]any{"event_id": next.SyncID, "operation": "set_customer_color", "status": "succeeded", "customer_color_id": "color-2"}) + require.Equal(t, http.StatusOK, resolved.Code, resolved.Body.String()) + require.Contains(t, resolved.Body.String(), `"updated":true`) +} + func uintPointer(value uint) *uint { return &value } func setupShangwutongConnectorAPI(t *testing.T) (*gin.Engine, *gorm.DB, string, *model.Inbox, *model.Inbox) { @@ -235,6 +369,8 @@ func setupShangwutongConnectorAPI(t *testing.T) (*gin.Engine, *gorm.DB, string, group.PUT("/inboxes/:inbox_id/status", handler.UpdateInboxStatus) group.PUT("/inboxes/:inbox_id/messages/:message_id/status", handler.UpdateMessageStatus) group.PATCH("/inboxes/:inbox_id/contacts/:source_id", handler.UpdateContactMetadata) + group.PUT("/inboxes/:inbox_id/contacts/:source_id/status", handler.UpdateContactOperationStatus) + group.PUT("/inboxes/:inbox_id/conversations/:conversation_id/classifications/status", handler.UpdateClassificationStatus) router.PATCH("/api/v1/accounts/:account_id/conversations/:conversation_id/shangwutong-classifications", handler.UpdateConversationClassification) return router, db, token, inboxes[0], inboxes[1] } diff --git a/backend/internal/handler/api/v1/whatsapp_call_handler.go b/backend/internal/handler/api/v1/whatsapp_call_handler.go index f9438f46..e4200fdb 100644 --- a/backend/internal/handler/api/v1/whatsapp_call_handler.go +++ b/backend/internal/handler/api/v1/whatsapp_call_handler.go @@ -9,6 +9,7 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/gochat/gochat/internal/auth" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/service" @@ -37,7 +38,7 @@ func (h *WhatsAppCallHandler) Index(c *gin.Context) { inboxID, _ := strconv.ParseUint(c.Query("inbox_id"), 10, 64) agentID, _ := strconv.ParseUint(c.Query("agent_id"), 10, 64) role := c.GetString("role") - filter := service.AccountCallListFilter{Page: page, UserID: getUserID(c), CustomRoleID: getCustomRoleID(c), AccountWide: role == "administrator" || role == "super_admin", Status: c.Query("status"), Direction: c.Query("direction"), InboxID: uint(inboxID), AgentID: uint(agentID)} + filter := service.AccountCallListFilter{Page: page, UserID: getUserID(c), CustomRoleID: getCustomRoleID(c), AccountWide: role == auth.RoleAdministrator || role == auth.RoleSuperAdmin, Status: c.Query("status"), Direction: c.Query("direction"), InboxID: uint(inboxID), AgentID: uint(agentID)} if c.Query("since") != "" && c.Query("until") != "" { since, errSince := parseChatwootReportTime(c.Query("since")) until, errUntil := parseChatwootReportTime(c.Query("until")) diff --git a/backend/internal/handler/widget/widget_handler.go b/backend/internal/handler/widget/widget_handler.go index 41bc2685..7e6dfc36 100644 --- a/backend/internal/handler/widget/widget_handler.go +++ b/backend/internal/handler/widget/widget_handler.go @@ -979,6 +979,8 @@ func (h *WidgetHandler) PublicInboxShow(c *gin.Context) { c.JSON(http.StatusOK, payload) } +// Public widget requests are not a trusted connector boundary. Origin headers +// are never promoted into internal event context here. func (h *WidgetHandler) PublicCreateContact(c *gin.Context) { req, err := bindPublicContactRequest(c) if err != nil { diff --git a/backend/internal/middleware/account_scope.go b/backend/internal/middleware/account_scope.go index a64197bd..8b8dd039 100644 --- a/backend/internal/middleware/account_scope.go +++ b/backend/internal/middleware/account_scope.go @@ -67,7 +67,7 @@ func AccountScope() gin.HandlerFunc { // Step 4: Get role from JWT claims. AuthMiddleware stores the current // keys as "role" and "claims"; retain the legacy names for callers that // still construct middleware contexts directly. - roleStr := "agent" // default fallback + roleStr := auth.RoleAgent // default fallback for _, key := range []string{"role", "user_role"} { if role, roleExists := c.Get(key); roleExists { if s, ok := role.(string); ok && s != "" { @@ -77,7 +77,7 @@ func AccountScope() gin.HandlerFunc { } } if isSuperAdminContext(c) { - roleStr = "super_admin" + roleStr = auth.RoleSuperAdmin } // Step 5: Get custom_role_id from JWT claims @@ -95,7 +95,7 @@ func AccountScope() gin.HandlerFunc { } } } - if roleStr == "administrator" && customRoleID > 0 { + if roleStr == auth.RoleAdministrator && customRoleID > 0 { response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden, "invalid administrator custom role assignment") return @@ -159,9 +159,9 @@ func AccountScopeWithService(lookup RBACLookup) gin.HandlerFunc { c.Set("account_id", accountID) if isSuperAdminContext(c) { - c.Set("role", "super_admin") + c.Set("role", auth.RoleSuperAdmin) c.Set("custom_role_id", uint(0)) - c.Set("policy_context", auth.NewPolicyContext(userID.(uint), accountID, "super_admin", 0, nil)) + c.Set("policy_context", auth.NewPolicyContext(userID.(uint), accountID, auth.RoleSuperAdmin, 0, nil)) c.Next() return } @@ -173,7 +173,7 @@ func AccountScopeWithService(lookup RBACLookup) gin.HandlerFunc { "User does not belong to this account") return } - if role == "administrator" && customRoleID > 0 { + if role == auth.RoleAdministrator && customRoleID > 0 { response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden, "invalid administrator custom role assignment") return @@ -193,8 +193,8 @@ func AccountScopeWithService(lookup RBACLookup) gin.HandlerFunc { } effectiveRole := role - if customRoleID > 0 && effectiveRole != "administrator" { - effectiveRole = "custom_role" + if customRoleID > 0 && effectiveRole != auth.RoleAdministrator { + effectiveRole = auth.RoleCustom } c.Set("role", effectiveRole) c.Set("custom_role_id", customRoleID) diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go index d535277c..7dd68e8c 100644 --- a/backend/internal/middleware/auth.go +++ b/backend/internal/middleware/auth.go @@ -91,7 +91,7 @@ func AuthMiddlewareWithServiceAndDB(jwtSvc *auth.JWTService, db *gorm.DB) gin.Ha } // Set defaults for role/provider so downstream PolicyMiddleware doesn't break - c.Set("role", "agent") + c.Set("role", auth.RoleAgent) c.Set("provider", "dev_header") c.Set("custom_role_id", uint(0)) c.Next() diff --git a/backend/internal/middleware/role_check.go b/backend/internal/middleware/role_check.go index 07483360..fd97ef74 100644 --- a/backend/internal/middleware/role_check.go +++ b/backend/internal/middleware/role_check.go @@ -17,8 +17,9 @@ import ( // This is a simple role gate โ€” for more granular permission checks, use PolicyMiddleware. // // Usage: -// router.POST("/accounts/:id/users", RoleCheck("administrator"), inviteUser) -// router.GET("/reports", RoleCheck("administrator"), viewReports) +// +// router.POST("/accounts/:id/users", RoleCheck("administrator"), inviteUser) +// router.GET("/reports", RoleCheck("administrator"), viewReports) // // Valid role values: "agent", "administrator", "custom_role" func RoleCheck(role string) gin.HandlerFunc { @@ -38,19 +39,19 @@ func RoleCheck(role string) gin.HandlerFunc { } switch role { - case "administrator": + case auth.RoleAdministrator: if !policyCtx.IsAdministrator() { response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden, "Administrator role required") return } - case "agent": + case auth.RoleAgent: if !policyCtx.IsAgent() { response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden, "Agent role required") return } - case "custom_role": + case auth.RoleCustom: if !policyCtx.IsCustomRole() { response.AbortWithStatusError(c, http.StatusForbidden, response.ErrForbidden, "Custom role required") @@ -73,7 +74,8 @@ func RoleCheck(role string) gin.HandlerFunc { // Useful for endpoints accessible to multiple roles. // // Usage: -// router.GET("/dashboard", RoleCheckAny("administrator", "custom_role"), viewDashboard) +// +// router.GET("/dashboard", RoleCheckAny("administrator", "custom_role"), viewDashboard) func RoleCheckAny(roles ...string) gin.HandlerFunc { roleSet := make(map[string]bool, len(roles)) for _, r := range roles { @@ -114,4 +116,4 @@ func formatRoles(roles []string) string { result += r } return result -} \ No newline at end of file +} diff --git a/backend/internal/model/account_user.go b/backend/internal/model/account_user.go index 1d2f1c08..5ada78f8 100644 --- a/backend/internal/model/account_user.go +++ b/backend/internal/model/account_user.go @@ -40,12 +40,12 @@ func (AccountUser) TableName() string { return "account_users" } // IsAdministrator returns true if the AccountUser has administrator role. func (au *AccountUser) IsAdministrator() bool { - return au.Role == "administrator" + return au.Role == string(AccountUserRoleAdministrator) } // IsAgent returns true if the AccountUser has agent role. func (au *AccountUser) IsAgent() bool { - return au.Role == "agent" + return au.Role == string(AccountUserRoleAgent) } // HasCustomRole returns true if the AccountUser has a custom enterprise role. diff --git a/backend/internal/model/inbox_member.go b/backend/internal/model/inbox_member.go index e8c099ac..dfaa6613 100644 --- a/backend/internal/model/inbox_member.go +++ b/backend/internal/model/inbox_member.go @@ -2,6 +2,8 @@ package model // InboxMember represents an agent assigned to an inbox. // Reference: Chatwoot InboxMember model + P2B M2 spec +const InboxMemberRoleAgent = "agent" + type InboxMember struct { Base InboxID uint `gorm:"column:inbox_id;not null;index" json:"inbox_id"` diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 0f384f48..784322fa 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -173,7 +173,7 @@ func (r *AccountRepo) UpsertAccountUser(ctx context.Context, accountID, userID u au = model.AccountUser{ AccountID: accountID, UserID: userID, - Role: "agent", + Role: string(model.AccountUserRoleAgent), Availability: "offline", AutoOffline: true, } diff --git a/backend/internal/repository/account_user_repo.go b/backend/internal/repository/account_user_repo.go index da705415..2e2e6aaa 100644 --- a/backend/internal/repository/account_user_repo.go +++ b/backend/internal/repository/account_user_repo.go @@ -44,7 +44,7 @@ func (r *AccountUserRepo) FindOnlineAgentsByAccount(ctx context.Context, account err := r.db.WithContext(ctx). Model(&model.AccountUser{}). Joins("JOIN users ON users.id = account_users.user_id AND users.active = ?", true). - Where("account_users.account_id = ? AND account_users.availability = ? AND account_users.role IN ?", accountID, "online", []string{"agent", "administrator"}). + Where("account_users.account_id = ? AND account_users.availability = ? AND account_users.role IN ?", accountID, "online", []string{string(model.AccountUserRoleAgent), string(model.AccountUserRoleAdministrator)}). Find(&agents).Error return agents, err } @@ -79,7 +79,7 @@ func (r *AccountUserRepo) IsAdministrator(ctx context.Context, accountID, userID var count int64 err := r.db.WithContext(ctx). Model(&model.AccountUser{}). - Where("account_id = ? AND user_id = ? AND role = ?", accountID, userID, "administrator"). + Where("account_id = ? AND user_id = ? AND role = ?", accountID, userID, string(model.AccountUserRoleAdministrator)). Count(&count).Error return count > 0, err } @@ -90,7 +90,7 @@ func (r *AccountUserRepo) IsAgentOrAdmin(ctx context.Context, accountID, userID err := r.db.WithContext(ctx). Model(&model.AccountUser{}). Joins("JOIN users ON users.id = account_users.user_id AND users.active = ?", true). - Where("account_users.account_id = ? AND account_users.user_id = ? AND account_users.role IN ?", accountID, userID, []string{"agent", "administrator"}). + Where("account_users.account_id = ? AND account_users.user_id = ? AND account_users.role IN ?", accountID, userID, []string{string(model.AccountUserRoleAgent), string(model.AccountUserRoleAdministrator)}). Count(&count).Error return count > 0, err } diff --git a/backend/internal/repository/agent_repo.go b/backend/internal/repository/agent_repo.go index 94ffb130..dcaf6b3c 100644 --- a/backend/internal/repository/agent_repo.go +++ b/backend/internal/repository/agent_repo.go @@ -32,10 +32,10 @@ func normalizeAgentRole(role string, customRoleID uint) (string, error) { if customRoleID == 0 { return role, nil } - if role == "administrator" { + if role == string(model.AccountUserRoleAdministrator) { return "", fmt.Errorf("invalid role assignment: administrator cannot have a custom role") } - return "agent", nil + return string(model.AccountUserRoleAgent), nil } func validateCustomRoleAssignment(tx *gorm.DB, accountID, customRoleID uint) error { @@ -276,7 +276,7 @@ func (r *AgentRepo) UpdateAgentWithActive(ctx context.Context, userID, accountID assignedCustomRoleID = *customRoleID } } - if role == "administrator" && assignedCustomRoleID > 0 { + if role == string(model.AccountUserRoleAdministrator) && assignedCustomRoleID > 0 { return fmt.Errorf("invalid role assignment: administrator cannot have a custom role") } if assignedCustomRoleID > 0 { diff --git a/backend/internal/repository/conversation_label_repo.go b/backend/internal/repository/conversation_label_repo.go index 914761d7..29fab90b 100644 --- a/backend/internal/repository/conversation_label_repo.go +++ b/backend/internal/repository/conversation_label_repo.go @@ -2,6 +2,7 @@ package repository import ( "context" + "strings" "github.com/gochat/gochat/internal/model" "gorm.io/gorm" @@ -18,26 +19,49 @@ func NewConversationLabelRepo(db *gorm.DB) *ConversationLabelRepo { // AddLabel attaches a tag to a conversation. func (r *ConversationLabelRepo) AddLabel(ctx context.Context, cl *model.ConversationLabel) error { - return r.db.WithContext(ctx).Create(cl).Error + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(cl).Error; err != nil { + return err + } + return syncConversationLabels(ctx, tx, cl.ConversationID) + }) } // AddLabels attaches multiple tags to a conversation in a single transaction. func (r *ConversationLabelRepo) AddLabels(ctx context.Context, labels []model.ConversationLabel) error { - return r.db.WithContext(ctx).CreateInBatches(labels, 100).Error + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.CreateInBatches(labels, 100).Error; err != nil { + return err + } + seen := make(map[uint]struct{}, len(labels)) + for _, label := range labels { + if _, ok := seen[label.ConversationID]; ok { + continue + } + seen[label.ConversationID] = struct{}{} + if err := syncConversationLabels(ctx, tx, label.ConversationID); err != nil { + return err + } + } + return nil + }) } // RemoveLabel detaches a specific tag from a conversation. func (r *ConversationLabelRepo) RemoveLabel(ctx context.Context, conversationID, tagID uint) error { - return r.db.WithContext(ctx). - Where("conversation_id = ? AND tag_id = ?", conversationID, tagID). - Delete(&model.ConversationLabel{}).Error + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("conversation_id = ? AND tag_id = ?", conversationID, tagID).Delete(&model.ConversationLabel{}).Error; err != nil { + return err + } + return syncConversationLabels(ctx, tx, conversationID) + }) } // RemoveAllLabels detaches all tags from a conversation. func (r *ConversationLabelRepo) RemoveAllLabels(ctx context.Context, conversationID uint) error { - return r.db.WithContext(ctx). - Where("conversation_id = ?", conversationID). - Delete(&model.ConversationLabel{}).Error + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return removeAllLabelsAndSync(ctx, tx, conversationID) + }) } // FindByConversationID returns all labels attached to a conversation, with tag details preloaded. @@ -89,14 +113,22 @@ func (r *ConversationLabelRepo) BatchAddLabels(ctx context.Context, conversation AccountID: accountID, } } - return r.db.WithContext(ctx).CreateInBatches(labels, 100).Error + return r.AddLabels(ctx, labels) } // BatchRemoveLabels removes a tag from multiple conversations at once. func (r *ConversationLabelRepo) BatchRemoveLabels(ctx context.Context, conversationIDs []uint, tagID uint) error { - return r.db.WithContext(ctx). - Where("conversation_id IN ? AND tag_id = ?", conversationIDs, tagID). - Delete(&model.ConversationLabel{}).Error + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("conversation_id IN ? AND tag_id = ?", conversationIDs, tagID).Delete(&model.ConversationLabel{}).Error; err != nil { + return err + } + for _, conversationID := range conversationIDs { + if err := syncConversationLabels(ctx, tx, conversationID); err != nil { + return err + } + } + return nil + }) } // Exists checks if a specific label is already attached to a conversation. @@ -112,21 +144,41 @@ func (r *ConversationLabelRepo) Exists(ctx context.Context, conversationID, tagI // ReplaceLabels replaces all labels on a conversation with the given set of tag IDs. func (r *ConversationLabelRepo) ReplaceLabels(ctx context.Context, conversationID, accountID uint, tagIDs []uint) error { - // Remove all existing labels - if err := r.RemoveAllLabels(ctx, conversationID); err != nil { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := removeAllLabelsAndSync(ctx, tx, conversationID); err != nil { + return err + } + if len(tagIDs) == 0 { + return nil + } + labels := make([]model.ConversationLabel, len(tagIDs)) + for i, tagID := range tagIDs { + labels[i] = model.ConversationLabel{ + ConversationID: conversationID, + TagID: tagID, + AccountID: accountID, + } + } + if err := tx.CreateInBatches(labels, 100).Error; err != nil { + return err + } + return syncConversationLabels(ctx, tx, conversationID) + }) +} + +func removeAllLabelsAndSync(ctx context.Context, tx *gorm.DB, conversationID uint) error { + if err := tx.Where("conversation_id = ?", conversationID).Delete(&model.ConversationLabel{}).Error; err != nil { return err } - // Add new labels - if len(tagIDs) == 0 { - return nil + return syncConversationLabels(ctx, tx, conversationID) +} + +func syncConversationLabels(ctx context.Context, tx *gorm.DB, conversationID uint) error { + var names []string + if err := tx.WithContext(ctx).Table("tags").Select("tags.name").Joins( + "JOIN conversation_labels ON conversation_labels.tag_id = tags.id", + ).Where("conversation_labels.conversation_id = ?", conversationID).Order("conversation_labels.id ASC").Scan(&names).Error; err != nil { + return err } - labels := make([]model.ConversationLabel, len(tagIDs)) - for i, tagID := range tagIDs { - labels[i] = model.ConversationLabel{ - ConversationID: conversationID, - TagID: tagID, - AccountID: accountID, - } - } - return r.db.WithContext(ctx).CreateInBatches(labels, 100).Error -} \ No newline at end of file + return tx.WithContext(ctx).Model(&model.Conversation{}).Where("id = ?", conversationID).Update("labels", strings.Join(names, ",")).Error +} diff --git a/backend/internal/repository/conversation_repo.go b/backend/internal/repository/conversation_repo.go index 801fb438..1937ece3 100644 --- a/backend/internal/repository/conversation_repo.go +++ b/backend/internal/repository/conversation_repo.go @@ -398,10 +398,39 @@ func (r *ConversationRepo) ToggleStatus(ctx context.Context, id uint, status mod Update("status", status).Error } -// UpdateLabels updates the labels on a conversation. +// UpdateLabels updates the legacy labels column and normalized associations atomically. func (r *ConversationRepo) UpdateLabels(ctx context.Context, id uint, labels string) error { - return r.db.WithContext(ctx).Model(&model.Conversation{}).Where("id = ?", id). - Update("labels", labels).Error + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var conversation model.Conversation + if err := tx.Select("id", "account_id").First(&conversation, id).Error; err != nil { + return err + } + if err := tx.Model(&conversation).Update("labels", labels).Error; err != nil { + return err + } + if err := tx.Where("conversation_id = ?", id).Delete(&model.ConversationLabel{}).Error; err != nil { + if strings.Contains(strings.ToLower(err.Error()), "conversation_labels") && strings.Contains(strings.ToLower(err.Error()), "table") { + return nil + } + return err + } + if strings.TrimSpace(labels) == "" { + return nil + } + parts := strings.Split(labels, ",") + var tags []model.Tag + if err := tx.Where("account_id = ? AND name IN ?", conversation.AccountID, parts).Find(&tags).Error; err != nil { + return err + } + associations := make([]model.ConversationLabel, 0, len(tags)) + for _, tag := range tags { + associations = append(associations, model.ConversationLabel{ConversationID: id, TagID: tag.ID, AccountID: conversation.AccountID}) + } + if len(associations) == 0 { + return nil + } + return tx.CreateInBatches(associations, 100).Error + }) } // Delete soft-deletes a conversation. diff --git a/backend/internal/repository/conversationassignee/gate.go b/backend/internal/repository/conversationassignee/gate.go index 27808113..a6c9326e 100644 --- a/backend/internal/repository/conversationassignee/gate.go +++ b/backend/internal/repository/conversationassignee/gate.go @@ -49,5 +49,5 @@ func eligible(db *gorm.DB, assigneeID uint) *gorm.DB { Where("account_users.account_id = conversations.account_id"). Where("account_users.user_id = ?", assigneeID). Where("account_users.deleted_at IS NULL AND users.deleted_at IS NULL"). - Where("account_users.role IN ? AND users.active = ?", []string{"agent", "administrator"}, true) + Where("account_users.role IN ? AND users.active = ?", []string{string(model.AccountUserRoleAgent), string(model.AccountUserRoleAdministrator)}, true) } diff --git a/backend/internal/repository/custom_role_repo.go b/backend/internal/repository/custom_role_repo.go index 5b5979d6..afb23e30 100644 --- a/backend/internal/repository/custom_role_repo.go +++ b/backend/internal/repository/custom_role_repo.go @@ -91,7 +91,7 @@ func (r *CustomRoleRepo) Delete(ctx context.Context, id, accountID uint) error { } if err := tx.Model(&model.AccountUser{}). Where("account_id = ? AND custom_role_id = ?", accountID, id). - Updates(map[string]interface{}{"role": "agent", "custom_role_id": 0}).Error; err != nil { + Updates(map[string]interface{}{"role": string(model.AccountUserRoleAgent), "custom_role_id": 0}).Error; err != nil { return err } return tx.Delete(&role).Error diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index a46b9a8b..612a1e69 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -275,6 +275,13 @@ func RegisterRoutes( } handlers.ShangwutongConnector.UpdateContactMetadata(c) }) + connector.PUT("/inboxes/:inbox_id/contacts/:source_id/status", func(c *gin.Context) { + if handlers == nil || handlers.ShangwutongConnector == nil { + webhookProviderUnavailable(c) + return + } + handlers.ShangwutongConnector.UpdateContactOperationStatus(c) + }) connector.PUT("/inboxes/:inbox_id/classifications", func(c *gin.Context) { if handlers == nil || handlers.ShangwutongConnector == nil { webhookProviderUnavailable(c) @@ -728,8 +735,8 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { accounts.PUT("/:account_id", middleware.SuperAdminOrAdministrator(), h.Account.Update) accounts.DELETE("/:account_id", middleware.SuperAdminOrAdministrator(), h.Account.Delete) // Account onboarding update (ref: Chatwoot resource :onboarding, only: [:update]) - accounts.PATCH("/:account_id/onboarding", middleware.RoleCheck("administrator"), h.Account.UpdateOnboarding) - accounts.GET("/:account_id/onboarding/help_center_generation", middleware.RoleCheck("administrator"), h.Account.HelpCenterGeneration) + accounts.PATCH("/:account_id/onboarding", middleware.RoleCheck(auth.RoleAdministrator), h.Account.UpdateOnboarding) + accounts.GET("/:account_id/onboarding/help_center_generation", middleware.RoleCheck(auth.RoleAdministrator), h.Account.HelpCenterGeneration) // Account settings (ref: Chatwoot accounts#update settings subset) accounts.PUT("/:account_id/settings", middleware.SuperAdminOrAdministrator(), h.Account.UpdateSettings) @@ -820,10 +827,10 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { // POST /api/v1/accounts/:id/inboxes/:inbox_id/register_webhook โ€” register channel webhook inboxes.POST("/:inbox_id/register_webhook", h.Inbox.RegisterWebhook) // POST /api/v1/accounts/:id/inboxes/:inbox_id/enable_whatsapp_calling โ€” enable WhatsApp Calling - inboxes.POST("/:inbox_id/enable_whatsapp_calling", middleware.RoleCheck("administrator"), h.Inbox.EnableWhatsAppCalling) + inboxes.POST("/:inbox_id/enable_whatsapp_calling", middleware.RoleCheck(auth.RoleAdministrator), h.Inbox.EnableWhatsAppCalling) // POST /api/v1/accounts/:id/inboxes/:inbox_id/disable_whatsapp_calling โ€” disable WhatsApp Calling - inboxes.POST("/:inbox_id/disable_whatsapp_calling", middleware.RoleCheck("administrator"), h.Inbox.DisableWhatsAppCalling) - inboxes.POST("/:inbox_id/set_inbound_calls", middleware.RoleCheck("administrator"), h.Inbox.SetInboundCalls) + inboxes.POST("/:inbox_id/disable_whatsapp_calling", middleware.RoleCheck(auth.RoleAdministrator), h.Inbox.DisableWhatsAppCalling) + inboxes.POST("/:inbox_id/set_inbound_calls", middleware.RoleCheck(auth.RoleAdministrator), h.Inbox.SetInboundCalls) // GET /api/v1/accounts/:id/inboxes/:inbox_id/agent_bot โ€” get currently active agent bot inboxes.GET("/:inbox_id/agent_bot", h.Inbox.GetAgentBot) @@ -1391,7 +1398,7 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { captain := accountScoped.Group("/captain") { // Assistant-level Skill catalog management (administrator only). - skills := captain.Group("/skills", middleware.RoleCheckAny("administrator", "super_admin")) + skills := captain.Group("/skills", middleware.RoleCheckAny(auth.RoleAdministrator, auth.RoleSuperAdmin)) { skills.GET("", h.CaptainSkill.List) skills.POST("", h.CaptainSkill.Create) @@ -1401,7 +1408,7 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { } // Assistant CRUD - assistants := captain.Group("/assistants", middleware.RoleCheck("administrator")) + assistants := captain.Group("/assistants", middleware.RoleCheck(auth.RoleAdministrator)) { assistants.GET("", h.CaptainAssistant.List) assistants.GET("/", h.CaptainAssistant.List) @@ -1421,8 +1428,8 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { assistants.POST("/:assistant_id/inboxes", h.CaptainAssistant.AssociateInbox) assistants.DELETE("/:assistant_id/inboxes/:inbox_id", h.CaptainAssistant.DissociateInbox) assistants.POST("/:assistant_id/playground", h.CaptainAssistant.GenerateResponse) - assistants.POST("/:assistant_id/skills/:skill_id", middleware.RoleCheckAny("administrator", "super_admin"), h.CaptainSkill.Bind) - assistants.DELETE("/:assistant_id/skills/:skill_id", middleware.RoleCheckAny("administrator", "super_admin"), h.CaptainSkill.Unbind) + assistants.POST("/:assistant_id/skills/:skill_id", middleware.RoleCheckAny(auth.RoleAdministrator, auth.RoleSuperAdmin), h.CaptainSkill.Bind) + assistants.DELETE("/:assistant_id/skills/:skill_id", middleware.RoleCheckAny(auth.RoleAdministrator, auth.RoleSuperAdmin), h.CaptainSkill.Unbind) // Documents nested under assistant assistantDocs := assistants.Group("/:assistant_id/documents") @@ -1459,7 +1466,7 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { } // Flat document routes (Chatwoot: resources :documents, only: [:index, :show, :create, :destroy]) - documents := captain.Group("/documents", middleware.RoleCheck("administrator")) + documents := captain.Group("/documents", middleware.RoleCheck(auth.RoleAdministrator)) { documents.GET("/", h.CaptainDocument.List) documents.POST("/", h.CaptainDocument.Create) @@ -1469,7 +1476,7 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { } // Flat scenario routes (Chatwoot: resources :scenarios) - scenarios := captain.Group("/scenarios", middleware.RoleCheck("administrator")) + scenarios := captain.Group("/scenarios", middleware.RoleCheck(auth.RoleAdministrator)) { scenarios.GET("/", h.CaptainScenario.List) scenarios.POST("/", h.CaptainScenario.Create) @@ -1786,12 +1793,12 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { } // Notion OAuth authorization (ref: Chatwoot namespace :notion resource :authorization) - accountScoped.POST("/notion/authorization", middleware.RoleCheck("administrator"), h.NotionIntegration.Authorization) - accountScoped.POST("/twitter/authorization", middleware.RoleCheck("administrator"), h.TwitterChannel.ChatwootAuthorization) - accountScoped.POST("/microsoft/authorization", middleware.RoleCheck("administrator"), h.MicrosoftChannel.ChatwootAuthorization) - accountScoped.POST("/google/authorization", middleware.RoleCheck("administrator"), h.GoogleChannel.ChatwootAuthorization) - accountScoped.POST("/instagram/authorization", middleware.RoleCheck("administrator"), h.InstagramChannel.ChatwootAuthorization) - accountScoped.POST("/tiktok/authorization", middleware.RoleCheck("administrator"), h.TikTokChannel.ChatwootAuthorization) + accountScoped.POST("/notion/authorization", middleware.RoleCheck(auth.RoleAdministrator), h.NotionIntegration.Authorization) + accountScoped.POST("/twitter/authorization", middleware.RoleCheck(auth.RoleAdministrator), h.TwitterChannel.ChatwootAuthorization) + accountScoped.POST("/microsoft/authorization", middleware.RoleCheck(auth.RoleAdministrator), h.MicrosoftChannel.ChatwootAuthorization) + accountScoped.POST("/google/authorization", middleware.RoleCheck(auth.RoleAdministrator), h.GoogleChannel.ChatwootAuthorization) + accountScoped.POST("/instagram/authorization", middleware.RoleCheck(auth.RoleAdministrator), h.InstagramChannel.ChatwootAuthorization) + accountScoped.POST("/tiktok/authorization", middleware.RoleCheck(auth.RoleAdministrator), h.TikTokChannel.ChatwootAuthorization) accountScoped.POST("/whatsapp/authorization", h.Inbox.WhatsAppAuthorization) // G16: Third-party Integrations โ€” IntegrationHook CRUD + Slack/Shopify/Linear/Notion @@ -2064,7 +2071,7 @@ func registerV2Routes(g *gin.RouterGroup, h *Handlers) { reports := accountScoped.Group("/reports") { reports.GET("", h.Analytics.Index) - reports.GET("/drilldown", middleware.RoleCheck("administrator"), h.Analytics.Drilldown) + reports.GET("/drilldown", middleware.RoleCheck(auth.RoleAdministrator), h.Analytics.Drilldown) reports.GET("/summary", h.Analytics.Summary) reports.GET("/bot_summary", h.Analytics.BotSummary) reports.GET("/agents", h.Analytics.AgentMetrics) diff --git a/backend/internal/search/engine.go b/backend/internal/search/engine.go index 1be49161..976103c6 100644 --- a/backend/internal/search/engine.go +++ b/backend/internal/search/engine.go @@ -195,7 +195,7 @@ func conversationSearchData(conv model.Conversation) map[string]interface{} { "available_name": firstNonEmpty(conv.Assignee.DisplayName, conv.Assignee.Name), "email": conv.Assignee.Email, "name": conv.Assignee.Name, - "role": firstNonEmpty(conv.Assignee.Role, "agent"), + "role": firstNonEmpty(conv.Assignee.Role, string(model.AccountUserRoleAgent)), } } if len(conv.Messages) > 0 { diff --git a/backend/internal/service/account_service.go b/backend/internal/service/account_service.go index 9c4c8cdc..68eb188c 100644 --- a/backend/internal/service/account_service.go +++ b/backend/internal/service/account_service.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/gochat/gochat/internal/auth" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/worker" @@ -224,7 +225,7 @@ func (s *AccountService) Create(ctx context.Context, userID uint, req CreateAcco } // Assign creator as administrator - if err := s.repo.AddUserToAccount(ctx, account.ID, userID, "administrator"); err != nil { + if err := s.repo.AddUserToAccount(ctx, account.ID, userID, auth.RoleAdministrator); err != nil { applogger.L().Errorf("Failed to assign creator to account: %v", err) return nil, err } diff --git a/backend/internal/service/account_user_service.go b/backend/internal/service/account_user_service.go index 80818aa6..119b9d37 100644 --- a/backend/internal/service/account_user_service.go +++ b/backend/internal/service/account_user_service.go @@ -8,6 +8,7 @@ import ( "gorm.io/gorm" + "github.com/gochat/gochat/internal/auth" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/pubsub" "github.com/gochat/gochat/internal/repository" @@ -171,7 +172,7 @@ func (s *AccountUserService) UpdateAvailability(ctx context.Context, accountID, // UpdateRole changes a user's role in an account. func (s *AccountUserService) UpdateRole(ctx context.Context, accountID, userID uint, role string) error { - if role != "agent" && role != "administrator" { + if role != auth.RoleAgent && role != auth.RoleAdministrator { return fmt.Errorf("invalid role: %s (must be agent/administrator)", role) } diff --git a/backend/internal/service/agent_service.go b/backend/internal/service/agent_service.go index 038d3d05..a43d467e 100644 --- a/backend/internal/service/agent_service.go +++ b/backend/internal/service/agent_service.go @@ -124,7 +124,7 @@ func (s *AgentService) Create(ctx context.Context, accountID uint, inviterID uin role := req.Role if role == "" { - role = "agent" + role = auth.RoleAgent } availability := req.Availability if availability == "" { @@ -205,10 +205,10 @@ func normalizeAgentRoleAssignment(role string, customRoleID *uint) (string, erro if customRoleID == nil || *customRoleID == 0 { return role, nil } - if role == "administrator" { + if role == auth.RoleAdministrator { return "", fmt.Errorf("%w: administrator cannot have a custom role", ErrInvalidAgentRoleAssignee) } - return "agent", nil + return auth.RoleAgent, nil } // ResetPassword assigns a new one-time-visible random password to an email agent. diff --git a/backend/internal/service/applied_sla_service.go b/backend/internal/service/applied_sla_service.go index 3ba39e18..63623380 100644 --- a/backend/internal/service/applied_sla_service.go +++ b/backend/internal/service/applied_sla_service.go @@ -7,6 +7,7 @@ import ( "sort" "time" + "github.com/gochat/gochat/internal/auth" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" applogger "github.com/gochat/gochat/pkg/logger" @@ -393,7 +394,7 @@ func (s *AppliedSlaService) slaNotificationUserIDs(ctx context.Context, conversa var admins []model.AccountUser if err := s.conversationRepo.DB().WithContext(ctx). - Where("account_id = ? AND role = ?", conversation.AccountID, "administrator"). + Where("account_id = ? AND role = ?", conversation.AccountID, auth.RoleAdministrator). Find(&admins).Error; err != nil { return nil, err } diff --git a/backend/internal/service/assignable_agent_service.go b/backend/internal/service/assignable_agent_service.go index 34ed0698..91fa3067 100644 --- a/backend/internal/service/assignable_agent_service.go +++ b/backend/internal/service/assignable_agent_service.go @@ -5,6 +5,7 @@ import ( "fmt" "sort" + "github.com/gochat/gochat/internal/auth" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" ) @@ -189,7 +190,7 @@ func (s *AssignableAgentService) GetAssignableAgents(ctx context.Context, accoun CustomRoleID: accountUser.CustomRoleID, AvailabilityStatus: availabilityStatus, Workload: workloadMap[u.ID], - IsAdministrator: role == "administrator", + IsAdministrator: role == auth.RoleAdministrator, } } @@ -237,7 +238,7 @@ func (s *AssignableAgentService) findAdministratorIDs(ctx context.Context, accou adminIDs := make([]uint, 0) for _, au := range accountUsers { - if au.Role == "administrator" { + if au.Role == auth.RoleAdministrator { adminIDs = append(adminIDs, au.UserID) } } diff --git a/backend/internal/service/auto_reply_listener.go b/backend/internal/service/auto_reply_listener.go index 5b3ea68e..e7d31c02 100644 --- a/backend/internal/service/auto_reply_listener.go +++ b/backend/internal/service/auto_reply_listener.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "github.com/gochat/gochat/internal/auth" "github.com/gochat/gochat/internal/channel" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" @@ -213,7 +214,7 @@ func (l *AutoReplyListener) fetchRecentMessages(ctx context.Context, conversatio for _, m := range messages { role := "contact" if m.SenderType == "User" || m.SenderType == "user" || m.SenderType == "agent_bot" { - role = "agent" + role = auth.RoleAgent } result = append(result, fmt.Sprintf("[%s]: %s", role, m.Content)) } diff --git a/backend/internal/service/contact_service.go b/backend/internal/service/contact_service.go index 5f811d50..c74236e9 100644 --- a/backend/internal/service/contact_service.go +++ b/backend/internal/service/contact_service.go @@ -600,11 +600,18 @@ func (s *ContactService) Update(ctx context.Context, accountID, id uint, req Upd } if s.worker != nil && req.Name != "" && req.Name != previousName { event := channel.NewChannelEvent(channel.EventContactUpdated, channel.ChannelAPI, accountID, 0) + metadata, _ := ctx.Value(shangwutongRequestMetadataKey{}).(shangwutongRequestMetadata) + if metadata.ConnectorOrigin { + event.Origin = channel.EventOriginShangwutongConnector + event.OriginEventID = metadata.OriginEventID + } event.ContactID, event.Data["contact"] = contact.ID, contact + event.Data["contact_name"] = contact.Name + event.Data["contact_version"] = fmt.Sprintf("%d", contact.UpdatedAt.UTC().UnixNano()) event.Data["changed_attributes"] = map[string]any{"name": contact.Name} var err error eventJob, eventCreated, err = s.worker.EnqueueInTransaction(ctx, tx, channel.TaskTypeEventDispatch, event, - worker.WithQueue("events"), worker.WithMaxAttempts(10), worker.WithIdempotencyKey(fmt.Sprintf("contact:%d:name:%d", contact.ID, contact.UpdatedAt.UnixNano()))) + worker.WithQueue("events"), worker.WithMaxAttempts(10), worker.WithIdempotencyKey(contactNameEventID(contact.ID, 0, contact.UpdatedAt.UTC().UnixNano(), contact.Name))) return err } return nil @@ -675,17 +682,26 @@ func (s *ContactService) CreateNote(ctx context.Context, accountID, contactID, u // GetNote retrieves a single note scoped to account and contact. func (s *ContactService) GetNote(ctx context.Context, accountID, contactID, noteID uint) (*model.Note, error) { - if s == nil || s.noteRepo == nil { + if s == nil || s.noteRepo == nil || s.repo == nil { return nil, errors.New("contact service not ready") } + if err := s.ensureNoteContact(ctx, accountID, contactID); err != nil { + return nil, err + } return s.noteRepo.GetByIDContext(ctx, accountID, contactID, noteID) } // UpdateNote updates a note scoped to account and contact. func (s *ContactService) UpdateNote(ctx context.Context, accountID, contactID, noteID uint, req CreateNoteRequest) (*model.Note, error) { + if s == nil || s.noteRepo == nil || s.repo == nil { + return nil, errors.New("contact service not ready") + } if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, err } + if err := s.ensureNoteContact(ctx, accountID, contactID); err != nil { + return nil, err + } note, err := s.noteRepo.GetByIDContext(ctx, accountID, contactID, noteID) if err != nil { return nil, err @@ -696,15 +712,25 @@ func (s *ContactService) UpdateNote(ctx context.Context, accountID, contactID, n // DeleteNote removes a note scoped to account and contact. func (s *ContactService) DeleteNote(ctx context.Context, accountID, contactID, noteID uint) error { - if s == nil || s.noteRepo == nil { + if s == nil || s.noteRepo == nil || s.repo == nil { return errors.New("contact service not ready") } + if err := s.ensureNoteContact(ctx, accountID, contactID); err != nil { + return err + } if _, err := s.noteRepo.GetByIDContext(ctx, accountID, contactID, noteID); err != nil { return err } return s.noteRepo.DeleteContext(ctx, accountID, contactID, noteID) } +func (s *ContactService) ensureNoteContact(ctx context.Context, accountID, contactID uint) error { + if _, err := s.repo.FindByAccountAndID(ctx, accountID, contactID); err != nil { + return errors.New("contact not found") + } + return nil +} + // ListActive retrieves contacts with recent activity for an account. // GET /api/v1/accounts/:id/contacts/active // Reference: Chatwoot contacts#active diff --git a/backend/internal/service/conversation_service.go b/backend/internal/service/conversation_service.go index f36283d2..92013317 100644 --- a/backend/internal/service/conversation_service.go +++ b/backend/internal/service/conversation_service.go @@ -40,12 +40,23 @@ type ConversationService struct { type shangwutongRequestMetadata struct { ConnectorOrigin bool ActorID uint + OriginEventID string } type shangwutongRequestMetadataKey struct{} func WithShangwutongRequestMetadata(ctx context.Context, connectorOrigin bool, actorID uint) context.Context { - return context.WithValue(ctx, shangwutongRequestMetadataKey{}, shangwutongRequestMetadata{ConnectorOrigin: connectorOrigin, ActorID: actorID}) + metadata, _ := ctx.Value(shangwutongRequestMetadataKey{}).(shangwutongRequestMetadata) + metadata.ConnectorOrigin = connectorOrigin + metadata.ActorID = actorID + return context.WithValue(ctx, shangwutongRequestMetadataKey{}, metadata) +} + +func WithShangwutongOriginEvent(ctx context.Context, eventID string) context.Context { + metadata, _ := ctx.Value(shangwutongRequestMetadataKey{}).(shangwutongRequestMetadata) + metadata.ConnectorOrigin = true + metadata.OriginEventID = strings.TrimSpace(eventID) + return context.WithValue(ctx, shangwutongRequestMetadataKey{}, metadata) } // NewConversationService creates a new Conversation service. diff --git a/backend/internal/service/dyte_integration_service.go b/backend/internal/service/dyte_integration_service.go index bf6cfb5e..be291963 100644 --- a/backend/internal/service/dyte_integration_service.go +++ b/backend/internal/service/dyte_integration_service.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/gochat/gochat/internal/auth" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" @@ -195,7 +196,7 @@ func (s *DyteIntegrationService) canAccessConversation(ctx context.Context, acco return false } switch strings.ToLower(role) { - case "administrator", "admin", "super_admin": + case auth.RoleAdministrator, "admin", auth.RoleSuperAdmin: return true } var member model.InboxMember diff --git a/backend/internal/service/enterprise_billing_worker.go b/backend/internal/service/enterprise_billing_worker.go index 7c856b24..f572c1e0 100644 --- a/backend/internal/service/enterprise_billing_worker.go +++ b/backend/internal/service/enterprise_billing_worker.go @@ -12,6 +12,7 @@ import ( "sync" "time" + "github.com/gochat/gochat/internal/auth" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/worker" "gorm.io/gorm" @@ -194,7 +195,7 @@ func cloudPlanContainsProduct(plan map[string]any, productID string) bool { func (s *AccountService) stripeCreateCustomer(ctx context.Context, secret string, account *model.Account, currency string) (string, error) { values := url.Values{"name": {account.Name}} var admin model.User - _ = s.repo.DB().WithContext(ctx).Joins("JOIN account_users ON account_users.user_id = users.id").Where("account_users.account_id = ? AND account_users.role = ?", account.ID, "administrator").Order("account_users.id ASC").First(&admin).Error + _ = s.repo.DB().WithContext(ctx).Joins("JOIN account_users ON account_users.user_id = users.id").Where("account_users.account_id = ? AND account_users.role = ?", account.ID, auth.RoleAdministrator).Order("account_users.id ASC").First(&admin).Error values.Set("email", admin.Email) if currency == "brl" { values.Set("address[country]", "BR") diff --git a/backend/internal/service/inbox_member_service.go b/backend/internal/service/inbox_member_service.go index 0764cd0d..8efe5397 100644 --- a/backend/internal/service/inbox_member_service.go +++ b/backend/internal/service/inbox_member_service.go @@ -169,7 +169,7 @@ func (s *InboxMemberService) AddMembers(ctx context.Context, req UpdateMultipleR if currentIDs[userID] { continue } - im := &model.InboxMember{InboxID: req.InboxID, UserID: userID, Role: "agent", AvailabilityStatus: "offline"} + im := &model.InboxMember{InboxID: req.InboxID, UserID: userID, Role: model.InboxMemberRoleAgent, AvailabilityStatus: "offline"} if err := s.repo.Create(ctx, im); err != nil { applogger.L().Errorf("InboxMemberService.AddMembers: failed to add member user_id=%d: %v", userID, err) return nil, err @@ -210,7 +210,7 @@ func (s *InboxMemberService) UpdateMultiple(ctx context.Context, req UpdateMulti if currentIDs[userID] { continue } - im := &model.InboxMember{InboxID: req.InboxID, UserID: userID, Role: "agent", AvailabilityStatus: "offline"} + im := &model.InboxMember{InboxID: req.InboxID, UserID: userID, Role: model.InboxMemberRoleAgent, AvailabilityStatus: "offline"} if err := s.repo.Create(ctx, im); err != nil { applogger.L().Errorf("InboxMemberService.UpdateMultiple: failed to add member user_id=%d: %v", userID, err) return nil, err diff --git a/backend/internal/service/profile_service.go b/backend/internal/service/profile_service.go index 66e46a13..862062b9 100644 --- a/backend/internal/service/profile_service.go +++ b/backend/internal/service/profile_service.go @@ -571,7 +571,7 @@ func profileAccountResponse(accountUser model.AccountUser) ProfileAccountRespons activeAt := timeStringPtr(accountUser.ActiveAt) availability := defaultString(accountUser.Availability, "offline") status := defaultString(accountUser.Account.Status, "active") - role := defaultString(accountUser.Role, "agent") + role := defaultString(accountUser.Role, auth.RoleAgent) permissions := []string{role} var customRole any var customRoleID *uint @@ -584,7 +584,7 @@ func profileAccountResponse(accountUser model.AccountUser) ProfileAccountRespons for _, key := range keys { permissions = append(permissions, string(key)) } - permissions = append(permissions, "custom_role") + permissions = append(permissions, auth.RoleCustom) } customRole = map[string]any{ "id": accountUser.CustomRole.ID, @@ -612,7 +612,7 @@ func profileAccountResponse(accountUser model.AccountUser) ProfileAccountRespons func permissionsWithoutMarker(permissions []string) []string { keys := make([]string, 0, len(permissions)) for _, permission := range permissions { - if permission == "custom_role" { + if permission == auth.RoleCustom { continue } keys = append(keys, permission) diff --git a/backend/internal/service/rbac_service.go b/backend/internal/service/rbac_service.go index d642b864..c879df3d 100644 --- a/backend/internal/service/rbac_service.go +++ b/backend/internal/service/rbac_service.go @@ -74,10 +74,10 @@ func (s *RBACService) AddAccountUser(userID, accountID uint, role string, custom if !isValidRole(role) { return nil, fmt.Errorf("invalid role '%s': must be 'agent', 'administrator', or 'custom_role'", role) } - if role == "administrator" && customRoleID > 0 { + if role == auth.RoleAdministrator && customRoleID > 0 { return nil, fmt.Errorf("invalid role assignment: administrator cannot have a custom role") } - if role == "custom_role" && customRoleID == 0 { + if role == auth.RoleCustom && customRoleID == 0 { return nil, fmt.Errorf("invalid role assignment: custom_role requires a custom role id") } @@ -113,10 +113,10 @@ func (s *RBACService) UpdateAccountUserRole(userID, accountID uint, newRole stri if !isValidRole(newRole) { return nil, fmt.Errorf("invalid role '%s'", newRole) } - if newRole == "administrator" && customRoleID > 0 { + if newRole == auth.RoleAdministrator && customRoleID > 0 { return nil, fmt.Errorf("invalid role assignment: administrator cannot have a custom role") } - if newRole == "custom_role" && customRoleID == 0 { + if newRole == auth.RoleCustom && customRoleID == 0 { return nil, fmt.Errorf("invalid role assignment: custom_role requires a custom role id") } @@ -309,7 +309,7 @@ func (s *RBACService) DeleteCustomRole(customRoleID uint) error { } if err := tx.Model(&model.AccountUser{}). Where("account_id = ? AND custom_role_id = ?", role.AccountID, customRoleID). - Updates(map[string]interface{}{"role": "agent", "custom_role_id": 0}).Error; err != nil { + Updates(map[string]interface{}{"role": auth.RoleAgent, "custom_role_id": 0}).Error; err != nil { return err } return tx.Delete(&role).Error @@ -333,7 +333,7 @@ func (s *RBACService) BuildPolicyContext(userID, accountID uint) (*auth.PolicyCo if err != nil { return nil, err } - if au.Role == "administrator" && au.CustomRoleID > 0 { + if au.Role == auth.RoleAdministrator && au.CustomRoleID > 0 { return nil, fmt.Errorf("invalid role assignment: administrator cannot have a custom role") } @@ -343,20 +343,20 @@ func (s *RBACService) BuildPolicyContext(userID, accountID uint) (*auth.PolicyCo // agent/administrator; custom-role behavior comes from custom_role_id. effectiveRole := au.Role switch au.Role { - case "administrator": + case auth.RoleAdministrator: permissions = auth.AdministratorPermissions - case "agent": + case auth.RoleAgent: if au.CustomRoleID > 0 { pm, err := s.GetCustomRolePermissionMatrixForAccount(au.CustomRoleID, accountID) if err != nil { return nil, fmt.Errorf("load custom role permissions: %w", err) } permissions = pm - effectiveRole = "custom_role" + effectiveRole = auth.RoleCustom } else { permissions = auth.AgentDefaultPermissions } - case "custom_role": + case auth.RoleCustom: if au.CustomRoleID == 0 { return nil, fmt.Errorf("custom role assignment is missing a role id") } @@ -365,7 +365,7 @@ func (s *RBACService) BuildPolicyContext(userID, accountID uint) (*auth.PolicyCo return nil, fmt.Errorf("load custom role permissions: %w", err) } permissions = pm - effectiveRole = "custom_role" + effectiveRole = auth.RoleCustom } return auth.NewPolicyContext(userID, accountID, effectiveRole, au.CustomRoleID, permissions), nil @@ -510,12 +510,12 @@ func (s *RBACService) ListPlatformApps(accountID uint) ([]model.PlatformApp, err // --- Helper Functions --- func isValidRole(role string) bool { - return role == "agent" || role == "administrator" || role == "custom_role" + return role == auth.RoleAgent || role == auth.RoleAdministrator || role == auth.RoleCustom } func normalizeAccountUserRole(role string, customRoleID uint) string { - if role == "custom_role" || (role == "" && customRoleID > 0) { - return "agent" + if role == auth.RoleCustom || (role == "" && customRoleID > 0) { + return auth.RoleAgent } return role } diff --git a/backend/internal/service/shangwutong_contact_listener.go b/backend/internal/service/shangwutong_contact_listener.go index 5af47d64..9b342fe5 100644 --- a/backend/internal/service/shangwutong_contact_listener.go +++ b/backend/internal/service/shangwutong_contact_listener.go @@ -3,12 +3,16 @@ package service import ( "context" "encoding/json" + "fmt" + "strconv" "strings" + "time" "github.com/gochat/gochat/internal/channel" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/worker" "gorm.io/gorm" + "gorm.io/gorm/clause" ) type ShangwutongContactListener struct { @@ -26,6 +30,9 @@ func (l *ShangwutongContactListener) OnEvent(ctx context.Context, event *channel if l == nil || l.db == nil || l.worker == nil || event == nil || event.Type != channel.EventContactUpdated || event.ContactID == 0 { return nil } + if event.Origin == channel.EventOriginShangwutongConnector { + return nil + } var contact model.Contact if err := l.db.WithContext(ctx).Where("id = ? AND account_id = ?", event.ContactID, event.AccountID).First(&contact).Error; err != nil { return err @@ -34,6 +41,7 @@ func (l *ShangwutongContactListener) OnEvent(ctx context.Context, event *channel if err := l.db.WithContext(ctx).Preload("Inbox").Where("contact_id = ?", contact.ID).Find(&inboxes).Error; err != nil { return err } + eventName, eventVersion := contactEventSnapshot(event, &contact) for i := range inboxes { if inboxes[i].Inbox.ChannelType != "shangwutong" || strings.TrimSpace(inboxes[i].SourceID) == "" { continue @@ -41,14 +49,139 @@ func (l *ShangwutongContactListener) OnEvent(ctx context.Context, event *channel var metadata struct { CID string `json:"cid"` } - if err := json.Unmarshal(inboxes[i].ChannelMetadata, &metadata); err != nil || strings.TrimSpace(metadata.CID) == "" { - continue + if len(inboxes[i].ChannelMetadata) > 0 { + if err := json.Unmarshal(inboxes[i].ChannelMetadata, &metadata); err != nil { + return err + } } job := newShangwutongContactJob(&contact, &inboxes[i]) + job.ContactName = eventName + job.ContactVersion = eventVersion + job.EventID = contactNameEventID(job.ContactID, job.InboxID, eventVersion, eventName) + job.OccurredAt = time.Unix(0, eventVersion).UTC() job.CID = strings.TrimSpace(metadata.CID) - if _, err := l.worker.Enqueue(ctx, TaskTypeShangwutongWebhookDelivery, job, worker.WithMaxAttempts(10), worker.WithIdempotencyKey(job.EventID)); err != nil { + var durableJob *model.BackgroundJob + var created bool + var accepted bool + if err := l.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var err error + accepted, err = markShangwutongContactNamePendingInTransaction(ctx, tx, &inboxes[i], &job) + if err != nil || !accepted { + return err + } + durableJob, created, err = l.worker.EnqueueInTransaction(ctx, tx, TaskTypeShangwutongWebhookDelivery, job, + worker.WithMaxAttempts(10), worker.WithIdempotencyKey(job.EventID)) return err + }); err != nil { + return err + } + if accepted && created { + l.worker.Publish(ctx, durableJob) } } return nil } + +func contactEventSnapshot(event *channel.ChannelEvent, fallback *model.Contact) (string, int64) { + name := fallback.Name + version := fallback.UpdatedAt.UTC().UnixNano() + if event == nil || event.Data == nil { + return name, version + } + if value, ok := event.Data["contact_name"].(string); ok && strings.TrimSpace(value) != "" { + name = strings.TrimSpace(value) + } + if value, ok := event.Data["contact_version"].(string); ok { + if parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64); err == nil && parsed > 0 { + version = parsed + } + } + if value, ok := event.Data["contact"].(map[string]interface{}); ok { + if nestedName, ok := value["name"].(string); ok && strings.TrimSpace(nestedName) != "" && name == fallback.Name { + name = strings.TrimSpace(nestedName) + } + } + if version <= 0 { + version = time.Now().UTC().UnixNano() + } + return name, version +} + +func markShangwutongContactNamePendingInTransaction(ctx context.Context, tx *gorm.DB, contactInbox *model.ContactInbox, job *shangwutongWebhookDeliveryJob) (bool, error) { + var contact model.Contact + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND account_id = ?", job.ContactID, job.AccountID).First(&contact).Error; err != nil { + return false, err + } + if contact.Name != job.ContactName { + return false, nil + } + contactVersion := job.ContactVersion + if contactVersion <= 0 { + contactVersion = contact.UpdatedAt.UTC().UnixNano() + } + if contactVersion <= 0 { + contactVersion = time.Now().UTC().UnixNano() + } + if contact.UpdatedAt.UTC().UnixNano() > contactVersion { + return false, nil + } + var current model.ContactInbox + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND contact_id = ? AND inbox_id = ?", contactInbox.ID, job.ContactID, job.InboxID).First(¤t).Error; err != nil { + return false, err + } + metadata := map[string]any{} + if len(current.ChannelMetadata) > 0 { + if err := json.Unmarshal(current.ChannelMetadata, &metadata); err != nil { + return false, err + } + } + if metadata == nil { + metadata = map[string]any{} + } + generation := int64(0) + if existing, ok := metadata["swt_contact_name_operation"].(map[string]any); ok { + existingEventID, _ := existing["event_id"].(string) + existingName, _ := existing["name"].(string) + existingVersion, _ := existing["contact_version"].(float64) + existingGeneration, _ := existing["operation_generation"].(float64) + generation = int64(existingGeneration) + if existingName == job.ContactName && int64(existingVersion) == contactVersion && existingEventID != "" { + job.EventID = existingEventID + job.OperationGeneration = generation + job.ContactVersion = contactVersion + if status, _ := existing["status"].(string); status == "succeeded" || status == "failed" || status == "uncertain" { + return false, nil + } + return true, nil + } + } + if generation < contactVersion { + generation = contactVersion + } + if generation <= 0 { + generation = time.Now().UTC().UnixNano() + } + if existing, ok := metadata["swt_contact_name_operation"].(map[string]any); ok { + if existingGeneration, _ := existing["operation_generation"].(float64); int64(existingGeneration) >= generation { + generation = int64(existingGeneration) + 1 + } + } + job.ContactVersion = contactVersion + job.OperationGeneration = generation + job.EventID = fmt.Sprintf("contact:%d:inbox:%d:name:%d", job.ContactID, job.InboxID, generation) + metadata["swt_contact_name_operation"] = map[string]any{ + "event_id": job.EventID, "operation": "change_contact_name", "status": "pending", + "account_id": job.AccountID, "inbox_id": job.InboxID, "contact_inbox_id": current.ID, + "source_id": current.SourceID, "contact_id": job.ContactID, "name": job.ContactName, + "cid": strings.TrimSpace(job.CID), "contact_version": contactVersion, + "operation_generation": generation, "updated_at": time.Now().UTC(), + } + encoded, err := json.Marshal(metadata) + if err != nil { + return false, err + } + if err := tx.WithContext(ctx).Model(¤t).Update("channel_metadata", encoded).Error; err != nil { + return false, err + } + return true, nil +} diff --git a/backend/internal/service/shangwutong_contact_sync_test.go b/backend/internal/service/shangwutong_contact_sync_test.go index 8f6ee839..78ba7d0a 100644 --- a/backend/internal/service/shangwutong_contact_sync_test.go +++ b/backend/internal/service/shangwutong_contact_sync_test.go @@ -3,6 +3,7 @@ package service import ( "context" "encoding/json" + "fmt" "testing" "github.com/gochat/gochat/internal/channel" @@ -46,16 +47,98 @@ func TestContactUpdateRollsBackWhenDurableEventCannotBeQueued(t *testing.T) { require.Equal(t, "ๆ—งๆ˜ต็งฐ", contact.Name) } -func TestShangwutongContactListenerQueuesOnlyCIDBoundInboxes(t *testing.T) { +func TestContactUpdateCarriesShangwutongOriginMetadata(t *testing.T) { + db := setupServiceTestDB(t) + account := createTestAccount(t, db) + contact := &model.Contact{AccountID: account.ID, Name: "ๆ—งๆ˜ต็งฐ"} + require.NoError(t, db.Create(contact).Error) + svc := NewContactService(repository.NewContactRepo(db), nil, repository.NewNoteRepo(db)) + svc.SetWorkerPool(worker.NewWorkerPool(db)) + + ctx := WithShangwutongOriginEvent(context.Background(), "swt-inbound:42") + _, err := svc.Update(ctx, account.ID, contact.ID, UpdateContactRequest{Name: "่ฟœ็จ‹ๆ˜ต็งฐ"}) + require.NoError(t, err) + var job model.BackgroundJob + require.NoError(t, db.Where("job_type = ?", channel.TaskTypeEventDispatch).First(&job).Error) + var event channel.ChannelEvent + require.NoError(t, json.Unmarshal(job.Payload, &event)) + require.Equal(t, channel.EventOriginShangwutongConnector, event.Origin) + require.Equal(t, "swt-inbound:42", event.OriginEventID) +} + +func TestShangwutongContactListenerSkipsConnectorOrigin(t *testing.T) { + db := setupServiceTestDB(t) + account := createTestAccount(t, db) + contact := &model.Contact{AccountID: account.ID, Name: "ๆ˜ต็งฐ"} + require.NoError(t, db.Create(contact).Error) + inbox := &model.Inbox{AccountID: account.ID, Name: "SWT", ChannelType: "shangwutong", Enabled: true} + require.NoError(t, db.Create(inbox).Error) + require.NoError(t, db.Create(&model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, SourceID: "sid"}).Error) + + listener := NewShangwutongContactListener(db, worker.NewWorkerPool(db)) + require.NoError(t, listener.OnEvent(context.Background(), &channel.ChannelEvent{ + Type: channel.EventContactUpdated, AccountID: account.ID, ContactID: contact.ID, + Origin: channel.EventOriginShangwutongConnector, OriginEventID: "swt-inbound:42", + })) + var jobs []model.BackgroundJob + require.NoError(t, db.Where("job_type = ?", TaskTypeShangwutongWebhookDelivery).Find(&jobs).Error) + require.Empty(t, jobs) +} + +func TestShangwutongContactListenerRollsBackPendingStateWhenEnqueueFails(t *testing.T) { + db := setupServiceTestDB(t) + account := createTestAccount(t, db) + contact := &model.Contact{AccountID: account.ID, Name: "ๆ˜ต็งฐ"} + require.NoError(t, db.Create(contact).Error) + inbox := &model.Inbox{AccountID: account.ID, Name: "SWT", ChannelType: "shangwutong", Enabled: true} + require.NoError(t, db.Create(inbox).Error) + require.NoError(t, db.Create(&model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, SourceID: "sid"}).Error) + require.NoError(t, db.Migrator().DropTable(&model.BackgroundJob{})) + + listener := NewShangwutongContactListener(db, worker.NewWorkerPool(db)) + err := listener.OnEvent(context.Background(), &channel.ChannelEvent{ + Type: channel.EventContactUpdated, AccountID: account.ID, ContactID: contact.ID, + }) + require.Error(t, err) + var contactInbox model.ContactInbox + require.NoError(t, db.Where("inbox_id = ? AND source_id = ?", inbox.ID, "sid").First(&contactInbox).Error) + require.NotContains(t, string(contactInbox.ChannelMetadata), "swt_contact_name_operation") +} + +func TestShangwutongContactListenerRejectsLateRenameSnapshot(t *testing.T) { + db := setupServiceTestDB(t) + account := createTestAccount(t, db) + contact := &model.Contact{AccountID: account.ID, Name: "A"} + require.NoError(t, db.Create(contact).Error) + versionA := contact.UpdatedAt.UTC().UnixNano() + inbox := &model.Inbox{AccountID: account.ID, Name: "SWT", ChannelType: "shangwutong", Enabled: true} + require.NoError(t, db.Create(inbox).Error) + require.NoError(t, db.Create(&model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, SourceID: "sid"}).Error) + require.NoError(t, db.Model(contact).Update("name", "B").Error) + + listener := NewShangwutongContactListener(db, worker.NewWorkerPool(db)) + require.NoError(t, listener.OnEvent(context.Background(), &channel.ChannelEvent{ + Type: channel.EventContactUpdated, AccountID: account.ID, ContactID: contact.ID, + Data: map[string]any{"contact_name": "A", "contact_version": fmt.Sprintf("%d", versionA)}, + })) + var jobs []model.BackgroundJob + require.NoError(t, db.Where("job_type = ?", TaskTypeShangwutongWebhookDelivery).Find(&jobs).Error) + require.Empty(t, jobs) +} + +func TestShangwutongContactListenerQueuesCIDBoundAndUnboundInboxes(t *testing.T) { db := setupServiceTestDB(t) account := createTestAccount(t, db) contact := &model.Contact{AccountID: account.ID, Name: "ๆ˜ต็งฐ"} require.NoError(t, db.Create(contact).Error) swtInbox := &model.Inbox{AccountID: account.ID, Name: "SWT", ChannelType: "shangwutong", Enabled: true} + swtUnboundInbox := &model.Inbox{AccountID: account.ID, Name: "SWT unbound", ChannelType: "shangwutong", Enabled: true} webInbox := &model.Inbox{AccountID: account.ID, Name: "Widget", ChannelType: "web_widget", Enabled: true} require.NoError(t, db.Create(swtInbox).Error) + require.NoError(t, db.Create(swtUnboundInbox).Error) require.NoError(t, db.Create(webInbox).Error) require.NoError(t, db.Create(&model.ContactInbox{ContactID: contact.ID, InboxID: swtInbox.ID, SourceID: "sid", ChannelMetadata: datatypes.JSON(`{"cid":"cid-1"}`)}).Error) + require.NoError(t, db.Create(&model.ContactInbox{ContactID: contact.ID, InboxID: swtUnboundInbox.ID, SourceID: "sid-unbound"}).Error) require.NoError(t, db.Create(&model.ContactInbox{ContactID: contact.ID, InboxID: webInbox.ID, SourceID: "widget"}).Error) wp := worker.NewWorkerPool(db) @@ -65,9 +148,18 @@ func TestShangwutongContactListenerQueuesOnlyCIDBoundInboxes(t *testing.T) { })) var jobs []model.BackgroundJob require.NoError(t, db.Where("job_type = ?", TaskTypeShangwutongWebhookDelivery).Find(&jobs).Error) - require.Len(t, jobs, 1) - var job shangwutongWebhookDeliveryJob - require.NoError(t, json.Unmarshal(jobs[0].Payload, &job)) - require.Equal(t, "sid", job.SourceID) - require.Equal(t, "cid-1", job.CID) + require.Len(t, jobs, 2) + jobsBySource := map[string]shangwutongWebhookDeliveryJob{} + for _, queued := range jobs { + var job shangwutongWebhookDeliveryJob + require.NoError(t, json.Unmarshal(queued.Payload, &job)) + jobsBySource[job.SourceID] = job + } + require.Equal(t, "cid-1", jobsBySource["sid"].CID) + require.Empty(t, jobsBySource["sid-unbound"].CID) + var unbound model.ContactInbox + require.NoError(t, db.Where("inbox_id = ? AND source_id = ?", swtUnboundInbox.ID, "sid-unbound").First(&unbound).Error) + var metadata map[string]any + require.NoError(t, json.Unmarshal(unbound.ChannelMetadata, &metadata)) + require.Equal(t, "pending", metadata["swt_contact_name_operation"].(map[string]any)["status"]) } diff --git a/backend/internal/service/shangwutong_delivery_failure.go b/backend/internal/service/shangwutong_delivery_failure.go new file mode 100644 index 00000000..3ff07b56 --- /dev/null +++ b/backend/internal/service/shangwutong_delivery_failure.go @@ -0,0 +1,182 @@ +package service + +import ( + "context" + "encoding/json" + "strconv" + "strings" + "time" + + "github.com/gochat/gochat/internal/model" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +func isShangwutongStateDelivery(job shangwutongWebhookDeliveryJob) bool { + switch job.Event { + case "contact_updated", "conversation_classification_changed", "classification_sync_requested": + return true + default: + return false + } +} + +func shangwutongDeliveryFailureMessage(status string) string { + if status == "failed" { + return "connector rejected the webhook delivery" + } + return "connector delivery acknowledgement was not received" +} + +func (r *shangwutongWebhookDeliveryRunner) markShangwutongStateDelivery(ctx context.Context, job shangwutongWebhookDeliveryJob, status, message string) error { + code := "connector_delivery_uncertain" + if status == "failed" { + code = "connector_delivery_rejected" + } + switch job.Event { + case "contact_updated": + return r.markContactNameDelivery(ctx, job, status, code, message) + case "conversation_classification_changed": + return r.markClassificationDelivery(ctx, job, status, code, message) + case "classification_sync_requested": + return r.markClassificationSyncDelivery(ctx, job, status, code, message) + default: + return nil + } +} + +func (r *shangwutongWebhookDeliveryRunner) markContactNameDelivery(ctx context.Context, job shangwutongWebhookDeliveryJob, status, code, message string) error { + if job.ContactID == 0 || strings.TrimSpace(job.SourceID) == "" || strings.TrimSpace(job.EventID) == "" { + return nil + } + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var contactInbox model.ContactInbox + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where( + "contact_id = ? AND inbox_id = ? AND source_id = ?", job.ContactID, job.InboxID, strings.TrimSpace(job.SourceID), + ).First(&contactInbox).Error; err != nil { + return nil + } + metadata := map[string]any{} + if len(contactInbox.ChannelMetadata) > 0 && json.Unmarshal(contactInbox.ChannelMetadata, &metadata) != nil { + return nil + } + state, ok := metadata["swt_contact_name_operation"].(map[string]any) + if !ok || stateString(state, "event_id") != job.EventID || stateString(state, "operation") != "change_contact_name" || stateString(state, "status") != "pending" { + return nil + } + if !stateMatchesInt64(state, "account_id", int64(job.AccountID)) || + !stateMatchesInt64(state, "inbox_id", int64(job.InboxID)) || + !stateMatchesInt64(state, "contact_id", int64(job.ContactID)) || + !stateMatchesInt64(state, "contact_inbox_id", int64(contactInbox.ID)) || + stateString(state, "source_id") != strings.TrimSpace(job.SourceID) { + return nil + } + if job.ContactVersion > 0 && !stateMatchesInt64(state, "contact_version", job.ContactVersion) { + return nil + } + if job.OperationGeneration > 0 && !stateMatchesInt64(state, "operation_generation", job.OperationGeneration) { + return nil + } + if strings.TrimSpace(job.CID) != "" && stateString(state, "cid") != strings.TrimSpace(job.CID) { + return nil + } + state["status"], state["error_code"], state["error_message"], state["updated_at"] = status, code, message, time.Now().UTC() + encoded, err := json.Marshal(metadata) + if err != nil { + return err + } + return tx.WithContext(ctx).Model(&contactInbox).Update("channel_metadata", encoded).Error + }) +} + +func (r *shangwutongWebhookDeliveryRunner) markClassificationDelivery(ctx context.Context, job shangwutongWebhookDeliveryJob, status, code, message string) error { + if job.ConversationID == 0 || strings.TrimSpace(job.EventID) == "" || strings.TrimSpace(job.SWTSessionID) == "" { + return nil + } + operation, value := "set_chat_kind", strings.TrimSpace(job.ChatKindID) + if strings.TrimSpace(job.CustomerColorID) != "" { + operation, value = "set_customer_color", strings.TrimSpace(job.CustomerColorID) + } + if value == "" || (operation == "set_customer_color" && strings.TrimSpace(job.CID) == "") { + return nil + } + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var conversation model.Conversation + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where( + "id = ? AND account_id = ? AND inbox_id = ?", job.ConversationID, job.AccountID, job.InboxID, + ).First(&conversation).Error; err != nil { + return nil + } + attributes := map[string]any{} + if len(conversation.AdditionalAttributes) > 0 && json.Unmarshal(conversation.AdditionalAttributes, &attributes) != nil { + return nil + } + stateMap, ok := attributes["swt_classification_operations"].(map[string]any) + if !ok { + return nil + } + state, ok := stateMap[operation].(map[string]any) + if !ok || stateString(state, "event_id") != job.EventID || stateString(state, "operation") != operation || stateString(state, "value") != value || stateString(state, "status") != "pending" { + return nil + } + if !stateMatchesInt64(state, "account_id", int64(job.AccountID)) || + !stateMatchesInt64(state, "inbox_id", int64(job.InboxID)) || + !stateMatchesInt64(state, "conversation_id", int64(conversation.ID)) || + !stateMatchesInt64(state, "contact_id", int64(conversation.ContactID)) || + stateString(state, "swt_session_id") != strings.TrimSpace(job.SWTSessionID) { + return nil + } + if operation == "set_customer_color" && stateString(state, "cid") != strings.TrimSpace(job.CID) { + return nil + } + state["status"], state["error_code"], state["error_message"], state["updated_at"] = status, code, message, time.Now().UTC() + attributes["swt_classification_status"], attributes["swt_classification_event_id"] = status, job.EventID + attributes["swt_classification_error_code"], attributes["swt_classification_error"] = code, message + encoded, err := json.Marshal(attributes) + if err != nil { + return err + } + return tx.WithContext(ctx).Model(&conversation).Update("additional_attributes", encoded).Error + }) +} + +func (r *shangwutongWebhookDeliveryRunner) markClassificationSyncDelivery(ctx context.Context, job shangwutongWebhookDeliveryJob, status, code, message string) error { + if job.InboxID == 0 || strings.TrimSpace(job.EventID) == "" { + return nil + } + updates := map[string]any{ + "last_error_code": &code, + "last_error_message": &message, + } + if status == "failed" { + updates["sync_status"] = "failed" + } + return r.db.WithContext(ctx).Model(&model.ShangwutongClassificationCache{}).Where( + "inbox_id = ? AND last_sync_event_id = ? AND sync_status = ?", job.InboxID, job.EventID, "pending", + ).Updates(updates).Error +} + +func stateString(state map[string]any, key string) string { + value, _ := state[key].(string) + return strings.TrimSpace(value) +} + +func stateMatchesInt64(state map[string]any, key string, expected int64) bool { + value, ok := state[key] + if !ok { + return false + } + switch value := value.(type) { + case float64: + return value == float64(expected) + case int: + return int64(value) == expected + case int64: + return value == expected + case string: + parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) + return err == nil && parsed == expected + default: + return false + } +} diff --git a/backend/internal/service/shangwutong_webhook_delivery.go b/backend/internal/service/shangwutong_webhook_delivery.go index 2bf4296e..ac4a1aa0 100644 --- a/backend/internal/service/shangwutong_webhook_delivery.go +++ b/backend/internal/service/shangwutong_webhook_delivery.go @@ -40,6 +40,8 @@ type shangwutongWebhookDeliveryJob struct { SourceID string `json:"source_id,omitempty"` CID string `json:"cid,omitempty"` ContactName string `json:"contact_name,omitempty"` + ContactVersion int64 `json:"contact_version,omitempty"` + OperationGeneration int64 `json:"operation_generation,omitempty"` RetryVersion int64 `json:"retry_version,omitempty"` ConversationID uint `json:"conversation_id,omitempty"` ConversationVersion int64 `json:"conversation_version,omitempty"` @@ -80,8 +82,21 @@ type shangwutongWebhookDeliveryRunner struct { dispatcher *channel.Dispatcher } -func (r *shangwutongWebhookDeliveryRunner) perform(ctx context.Context, backgroundJob *model.BackgroundJob) error { +func (r *shangwutongWebhookDeliveryRunner) perform(ctx context.Context, backgroundJob *model.BackgroundJob) (retErr error) { var job shangwutongWebhookDeliveryJob + terminal := false + terminalStatus := "uncertain" + defer func() { + if retErr == nil || backgroundJob == nil || !isShangwutongStateDelivery(job) { + return + } + if !terminal && (backgroundJob.MaxAttempts <= 0 || backgroundJob.Attempts < backgroundJob.MaxAttempts) { + return + } + if err := r.markShangwutongStateDelivery(ctx, job, terminalStatus, shangwutongDeliveryFailureMessage(terminalStatus)); err != nil { + retErr = errors.Join(retErr, err) + } + }() if err := json.Unmarshal(backgroundJob.Payload, &job); err != nil { return fmt.Errorf("decode shangwutong webhook job: %w", err) } @@ -136,6 +151,10 @@ func (r *shangwutongWebhookDeliveryRunner) perform(ctx context.Context, backgrou payload, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) deliveryErr := fmt.Errorf("shangwutong webhook returned %d: %s", response.StatusCode, string(payload)) permanent := response.StatusCode >= 400 && response.StatusCode < 500 && response.StatusCode != http.StatusTooManyRequests + if permanent { + terminal = true + terminalStatus = "failed" + } if isShangwutongMessageDelivery(job) && (permanent || backgroundJob.Attempts >= backgroundJob.MaxAttempts) { code := "connector_delivery_exhausted" if permanent { @@ -213,7 +232,10 @@ func (r *shangwutongWebhookDeliveryRunner) payload(ctx context.Context, job shan case "contact_updated": data = map[string]any{"contact": map[string]any{ "id": job.ContactID, "source_id": job.SourceID, "name": job.ContactName, - }, "cid": job.CID, "cnote": ""} + }} + if strings.TrimSpace(job.CID) != "" { + data["cid"] = strings.TrimSpace(job.CID) + } case "classification_sync_requested": data = map[string]any{} case "conversation_classification_changed": @@ -375,34 +397,65 @@ func newShangwutongLifecycleJob(event string, inbox *model.Inbox, configVersion // EnqueueShangwutongClassificationSync asks the Connector to fetch the remote // catalog. The Connector reports the result back through its service API. func EnqueueShangwutongClassificationSync(ctx context.Context, pool *worker.WorkerPool, inbox *model.Inbox) (string, error) { - if pool == nil || inbox == nil || inbox.ID == 0 || inbox.AccountID == 0 { - return "", errors.New("worker and inbox are required") + return EnqueueShangwutongClassificationSyncWithEventID(ctx, pool, inbox, uuid.NewString()) +} + +func EnqueueShangwutongClassificationSyncWithEventID(ctx context.Context, pool *worker.WorkerPool, inbox *model.Inbox, eventID string) (string, error) { + _, _, err := enqueueShangwutongClassificationSync(ctx, pool, nil, inbox, eventID) + return strings.TrimSpace(eventID), err +} + +func EnqueueShangwutongClassificationSyncInTransaction(ctx context.Context, pool *worker.WorkerPool, tx *gorm.DB, inbox *model.Inbox, eventID string) (*model.BackgroundJob, bool, error) { + return enqueueShangwutongClassificationSync(ctx, pool, tx, inbox, eventID) +} + +func enqueueShangwutongClassificationSync(ctx context.Context, pool *worker.WorkerPool, tx *gorm.DB, inbox *model.Inbox, eventID string) (*model.BackgroundJob, bool, error) { + if pool == nil || inbox == nil || inbox.ID == 0 || inbox.AccountID == 0 || strings.TrimSpace(eventID) == "" { + return nil, false, errors.New("worker, inbox and event id are required") } job := shangwutongWebhookDeliveryJob{ - Event: "classification_sync_requested", EventID: uuid.NewString(), OccurredAt: time.Now().UTC(), + Event: "classification_sync_requested", EventID: strings.TrimSpace(eventID), OccurredAt: time.Now().UTC(), AccountID: inbox.AccountID, InboxID: inbox.ID, } - _, err := pool.Enqueue(ctx, TaskTypeShangwutongWebhookDelivery, job, - worker.WithMaxAttempts(10), worker.WithIdempotencyKey(job.EventID)) - return job.EventID, err + return enqueueShangwutongWebhookJob(ctx, pool, tx, job) } func EnqueueShangwutongClassificationChange(ctx context.Context, pool *worker.WorkerPool, inbox *model.Inbox, conversationID uint, sid, cid, chatKindID, customerColorID, customerColorName string) (string, error) { - if pool == nil || inbox == nil || inbox.ID == 0 || inbox.AccountID == 0 || conversationID == 0 || strings.TrimSpace(sid) == "" { - return "", errors.New("worker, inbox, conversation and sid are required") + return EnqueueShangwutongClassificationChangeWithEventID(ctx, pool, inbox, conversationID, sid, cid, chatKindID, customerColorID, customerColorName, uuid.NewString()) +} + +func EnqueueShangwutongClassificationChangeWithEventID(ctx context.Context, pool *worker.WorkerPool, inbox *model.Inbox, conversationID uint, sid, cid, chatKindID, customerColorID, customerColorName, eventID string) (string, error) { + _, _, err := enqueueShangwutongClassificationChange(ctx, pool, nil, inbox, conversationID, sid, cid, chatKindID, customerColorID, customerColorName, eventID) + return strings.TrimSpace(eventID), err +} + +func EnqueueShangwutongClassificationChangeInTransaction(ctx context.Context, pool *worker.WorkerPool, tx *gorm.DB, inbox *model.Inbox, conversationID uint, sid, cid, chatKindID, customerColorID, customerColorName, eventID string) (*model.BackgroundJob, bool, error) { + return enqueueShangwutongClassificationChange(ctx, pool, tx, inbox, conversationID, sid, cid, chatKindID, customerColorID, customerColorName, eventID) +} + +func enqueueShangwutongClassificationChange(ctx context.Context, pool *worker.WorkerPool, tx *gorm.DB, inbox *model.Inbox, conversationID uint, sid, cid, chatKindID, customerColorID, customerColorName, eventID string) (*model.BackgroundJob, bool, error) { + if pool == nil || inbox == nil || inbox.ID == 0 || inbox.AccountID == 0 || conversationID == 0 || strings.TrimSpace(sid) == "" || strings.TrimSpace(eventID) == "" { + return nil, false, errors.New("worker, inbox, conversation, sid and event id are required") } chatKindID, customerColorID, cid = strings.TrimSpace(chatKindID), strings.TrimSpace(customerColorID), strings.TrimSpace(cid) if (chatKindID == "") == (customerColorID == "") || (customerColorID != "" && cid == "") { - return "", errors.New("exactly one classification is required") + return nil, false, errors.New("exactly one classification is required") } job := shangwutongWebhookDeliveryJob{ - Event: "conversation_classification_changed", EventID: uuid.NewString(), OccurredAt: time.Now().UTC(), + Event: "conversation_classification_changed", EventID: strings.TrimSpace(eventID), OccurredAt: time.Now().UTC(), AccountID: inbox.AccountID, InboxID: inbox.ID, ConversationID: conversationID, SWTSessionID: strings.TrimSpace(sid), CID: cid, ChatKindID: chatKindID, CustomerColorID: customerColorID, CustomerColorName: strings.TrimSpace(customerColorName), } - _, err := pool.Enqueue(ctx, TaskTypeShangwutongWebhookDelivery, job, - worker.WithMaxAttempts(10), worker.WithIdempotencyKey(job.EventID)) - return job.EventID, err + return enqueueShangwutongWebhookJob(ctx, pool, tx, job) +} + +func enqueueShangwutongWebhookJob(ctx context.Context, pool *worker.WorkerPool, tx *gorm.DB, job shangwutongWebhookDeliveryJob) (*model.BackgroundJob, bool, error) { + options := []worker.EnqueueOption{worker.WithMaxAttempts(10), worker.WithIdempotencyKey(job.EventID)} + if tx != nil { + return pool.EnqueueInTransaction(ctx, tx, TaskTypeShangwutongWebhookDelivery, job, options...) + } + queued, err := pool.Enqueue(ctx, TaskTypeShangwutongWebhookDelivery, job, options...) + return queued, err == nil, err } func newShangwutongMessageJob(event string, message *model.Message, retryVersion int64) shangwutongWebhookDeliveryJob { @@ -428,11 +481,24 @@ func newShangwutongConversationStatusJob(conversation *model.Conversation, previ } } +func contactNameEventID(contactID, inboxID uint, version int64, name string) string { + sum := sha256.Sum256([]byte(name)) + return fmt.Sprintf("contact:%d:inbox:%d:name:%d:%s", contactID, inboxID, version, hex.EncodeToString(sum[:8])) +} + func newShangwutongContactJob(contact *model.Contact, contactInbox *model.ContactInbox) shangwutongWebhookDeliveryJob { version := contact.UpdatedAt.UTC().UnixNano() + if version <= 0 { + version = time.Now().UTC().UnixNano() + } + occurredAt := contact.UpdatedAt.UTC() + if occurredAt.IsZero() { + occurredAt = time.Unix(0, version).UTC() + } return shangwutongWebhookDeliveryJob{ - Event: "contact_updated", EventID: fmt.Sprintf("contact:%d:inbox:%d:name:%d", contact.ID, contactInbox.InboxID, version), - OccurredAt: contact.UpdatedAt.UTC(), AccountID: contact.AccountID, InboxID: contactInbox.InboxID, + Event: "contact_updated", EventID: contactNameEventID(contact.ID, contactInbox.InboxID, version, contact.Name), + OccurredAt: occurredAt, ContactVersion: version, + AccountID: contact.AccountID, InboxID: contactInbox.InboxID, ContactID: contact.ID, SourceID: contactInbox.SourceID, ContactName: contact.Name, } } diff --git a/backend/internal/service/shangwutong_webhook_delivery_test.go b/backend/internal/service/shangwutong_webhook_delivery_test.go index c2101511..66cba2ef 100644 --- a/backend/internal/service/shangwutong_webhook_delivery_test.go +++ b/backend/internal/service/shangwutong_webhook_delivery_test.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "fmt" "io" "net/http" "net/http/httptest" @@ -19,6 +20,7 @@ import ( "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/worker" "github.com/stretchr/testify/require" + "gorm.io/datatypes" "gorm.io/driver/sqlite" "gorm.io/gorm" ) @@ -151,6 +153,79 @@ func TestShangwutongMessageWebhookPermanentFailureMarksMessageFailed(t *testing. require.Contains(t, string(message.ContentAttributes), "unsupported_outbound_content") } +func TestShangwutongContactWebhookPermanentFailureMarksMatchingRenameFailed(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + response.WriteHeader(http.StatusUnprocessableEntity) + })) + defer server.Close() + db := newShangwutongDeliveryTestDB(t) + inbox := seedShangwutongDeliveryInbox(t, db, server.URL, "secret") + contact := &model.Contact{AccountID: inbox.AccountID, Name: "ๆ–ฐๅ็งฐ"} + require.NoError(t, db.Create(contact).Error) + contactInbox := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, SourceID: "visitor"} + require.NoError(t, db.Create(contactInbox).Error) + job := newShangwutongContactJob(contact, contactInbox) + job.ContactVersion, job.OperationGeneration = contact.UpdatedAt.UTC().UnixNano(), 7 + require.NoError(t, db.Model(contactInbox).Update("channel_metadata", datatypes.JSON([]byte(fmt.Sprintf(`{"swt_contact_name_operation":{"event_id":%q,"operation":"change_contact_name","status":"pending","account_id":%d,"inbox_id":%d,"contact_inbox_id":%d,"source_id":"visitor","contact_id":%d,"name":"ๆ–ฐๅ็งฐ","contact_version":%d,"operation_generation":7}}`, job.EventID, inbox.AccountID, inbox.ID, contactInbox.ID, contact.ID, job.ContactVersion)))).Error) + runner := &shangwutongWebhookDeliveryRunner{db: db, client: server.Client(), now: time.Now} + payload, err := json.Marshal(job) + require.NoError(t, err) + require.Error(t, runner.perform(context.Background(), &model.BackgroundJob{Payload: payload, Attempts: 1, MaxAttempts: 10})) + require.NoError(t, db.First(contactInbox, contactInbox.ID).Error) + var metadata map[string]any + require.NoError(t, json.Unmarshal(contactInbox.ChannelMetadata, &metadata)) + require.Equal(t, "failed", metadata["swt_contact_name_operation"].(map[string]any)["status"]) +} + +func TestShangwutongClassificationWebhookTimeoutMarksUncertainWithoutChangingValue(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + response.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + db := newShangwutongDeliveryTestDB(t) + inbox := seedShangwutongDeliveryInbox(t, db, server.URL, "secret") + contact := &model.Contact{AccountID: inbox.AccountID, Name: "่ฎฟๅฎข"} + require.NoError(t, db.Create(contact).Error) + contactInbox := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, SourceID: "visitor", ChannelMetadata: datatypes.JSON([]byte(`{"cid":"cid-1"}`))} + require.NoError(t, db.Create(contactInbox).Error) + conversation := &model.Conversation{AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, ContactInboxID: &contactInbox.ID, Status: "open"} + require.NoError(t, db.Create(conversation).Error) + attributes := fmt.Sprintf(`{"swt_classification_operations":{"set_customer_color":{"event_id":"classification-event","operation":"set_customer_color","status":"pending","value":"color-1","account_id":%d,"inbox_id":%d,"conversation_id":%d,"contact_id":%d,"contact_inbox_id":%d,"swt_session_id":"visitor","cid":"cid-1"}}}`, inbox.AccountID, inbox.ID, conversation.ID, contact.ID, contactInbox.ID) + require.NoError(t, db.Model(conversation).Update("additional_attributes", datatypes.JSON([]byte(attributes))).Error) + job := shangwutongWebhookDeliveryJob{Event: "conversation_classification_changed", EventID: "classification-event", AccountID: inbox.AccountID, InboxID: inbox.ID, ConversationID: conversation.ID, SWTSessionID: "visitor", CID: "cid-1", CustomerColorID: "color-1"} + runner := &shangwutongWebhookDeliveryRunner{db: db, client: server.Client(), now: time.Now} + payload, err := json.Marshal(job) + require.NoError(t, err) + require.Error(t, runner.perform(context.Background(), &model.BackgroundJob{Payload: payload, Attempts: 10, MaxAttempts: 10})) + require.NoError(t, db.First(conversation, conversation.ID).Error) + var updated map[string]any + require.NoError(t, json.Unmarshal(conversation.AdditionalAttributes, &updated)) + state := updated["swt_classification_operations"].(map[string]any)["set_customer_color"].(map[string]any) + require.Equal(t, "uncertain", state["status"]) + require.NotContains(t, string(conversation.AdditionalAttributes), "swt_label_color") +} + +func TestShangwutongClassificationSyncPermanentFailureMarksPendingCacheFailed(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + response.WriteHeader(http.StatusBadRequest) + })) + defer server.Close() + db := newShangwutongDeliveryTestDB(t) + inbox := seedShangwutongDeliveryInbox(t, db, server.URL, "secret") + eventID := "classification-sync-event" + require.NoError(t, db.Create(&model.ShangwutongClassificationCache{ + InboxID: inbox.ID, SyncStatus: "pending", LastSyncEventID: eventID, + }).Error) + runner := &shangwutongWebhookDeliveryRunner{db: db, client: server.Client(), now: time.Now} + payload, err := json.Marshal(shangwutongWebhookDeliveryJob{Event: "classification_sync_requested", EventID: eventID, AccountID: inbox.AccountID, InboxID: inbox.ID}) + require.NoError(t, err) + require.Error(t, runner.perform(context.Background(), &model.BackgroundJob{Payload: payload, Attempts: 1, MaxAttempts: 10})) + var cache model.ShangwutongClassificationCache + require.NoError(t, db.First(&cache, "inbox_id = ?", inbox.ID).Error) + require.Equal(t, "failed", cache.SyncStatus) + require.Equal(t, "connector_delivery_rejected", *cache.LastErrorCode) +} + func TestShangwutongStaleLifecycleJobIsObsoleteAfterInboxDeletion(t *testing.T) { db := newShangwutongDeliveryTestDB(t) runner := &shangwutongWebhookDeliveryRunner{db: db, client: http.DefaultClient, now: time.Now} @@ -234,7 +309,7 @@ func newShangwutongDeliveryTestDB(t *testing.T) *gorm.DB { require.NoError(t, err) require.NoError(t, db.AutoMigrate( &model.Account{}, &model.Inbox{}, &channelmodel.ChannelAPI{}, &model.Contact{}, &model.Conversation{}, - &model.User{}, &model.Message{}, &model.Attachment{}, &model.AgentBot{}, + &model.User{}, &model.Message{}, &model.Attachment{}, &model.AgentBot{}, &model.ContactInbox{}, &model.ShangwutongClassificationCache{}, )) return db } diff --git a/backend/internal/service/widget_service.go b/backend/internal/service/widget_service.go index c0756631..d637abfc 100644 --- a/backend/internal/service/widget_service.go +++ b/backend/internal/service/widget_service.go @@ -2110,6 +2110,7 @@ func (s *WidgetService) findPublicContact(ctx context.Context, accountID uint, r PhoneNumber: req.PhoneNumber, AvatarURL: req.AvatarURL, Identifier: req.Identifier, + SourceID: req.SourceID, ContactType: "visitor", CustomAttributes: mustJSON(req.CustomAttributes), AdditionalAttributes: mustJSON(req.AdditionalAttributes), diff --git a/channels/shangwutong/README.md b/channels/shangwutong/README.md index 3092a3e3..7c50e888 100644 --- a/channels/shangwutong/README.md +++ b/channels/shangwutong/README.md @@ -94,14 +94,25 @@ shangwutong doctor `healthz` ๅช่กจ็คบ่ฟ›็จ‹ๅญ˜ๆดป๏ผ›`readyz` ไฝฟ็”จ่ฝป้‡ SQLite quick check ๅนถๆฃ€ๆŸฅๅ†™้”ใ€‚`doctor` ๆฃ€ๆŸฅ้…็ฝฎใ€migrationใ€ๅฎŒๆ•ด็š„ SQLite integrity check ๅ’Œ GoChat ้…็ฝฎ API๏ผŒไฝ†ไธไผš็™ปๅฝ•ๅ•†ๅŠก้€šใ€‚`backup` ไฝฟ็”จ SQLite `VACUUM INTO`๏ผŒไธ่ฆ็›ดๆŽฅๅคๅˆถๆดปๅŠจไธญ็š„ DB/WAL ๆ–‡ไปถใ€‚ +## ่ต„ๆ–™ไธŽๅˆ†็ฑป่ƒฝๅŠ›่พน็•Œ + +- GoChat ไบบๅทฅๆ”นๅๅชๅ‘้€ `sid/cid/cname`๏ผ›ๅœจ่ฟœ็จ‹ `cnote` ็ผบ็œ/็ฉบๅ€ผ่ฏญไน‰ๅฎŒๆˆๅ่ฎฎ้ชŒ่ฏๅ‰๏ผŒConnector ไธๅ‘้€็ฉบ `cnote`๏ผŒไนŸไธๆŠŠ GoChat Contact Note ๆ˜ ๅฐ„ๅˆฐๅ•†ๅŠก้€šๅค‡ๆณจใ€‚ +- ๆ”นๅ็ป“ๆžœ้€š่ฟ‡ๆŒไน…ๅŒ– operation ๅ›žๅ†™ `pending/succeeded/failed/uncertain`๏ผ›ๆฒกๆœ‰ CID ๆ—ถๆ“ไฝœไฟ็•™ๅœจ้˜Ÿๅˆ—ไธญ็ญ‰ๅพ…ๅ…ฅ็ซ™ CID๏ผŒCID ๅˆฐ่พพไผšๅ”ค้†’ๅŒไธ€ SID ็š„ๅพ…ๅค„็†ๆ“ไฝœ๏ผŒ24 ๅฐๆ—ถๅŽไปฅ `cid_wait_timeout` ๅคฑ่ดฅ๏ผŒไธ่ƒฝ้™้ป˜ไธขๅผƒใ€‚ +- `set_chat_kind` ไธŽ `set_customer_color` ็š„็ป“ๆžœ็Šถๆ€็‹ฌ็ซ‹ไฟๅญ˜๏ผ›ๅฎขๆˆท้ขœ่‰ฒๅชๅœจๅŒไธ€ GoChat account/inbox/contact-inbox๏ผˆๅŒไธ€่ฟœ็จ‹ CID ไฝœ็”จๅŸŸ๏ผ‰ไผ ๆ’ญ๏ผŒไธๆŒ‰่ฃธ CID ่ทจ inbox/account ๅˆๅนถใ€‚ +- ๅ•†ๅŠก้€šๅˆ†็ฑป/้ขœ่‰ฒไธๅˆ›ๅปบๆˆ–ไฟฎๆ”น GoChat Contact Labelใ€Conversation Labelใ€CRM ๆ ‡็ญพ๏ผ›GoChat ๅŽŸ็”Ÿ่”็ณปไบบๅค‡ๆณจไปไฝฟ็”จๆœฌๅœฐ `notes` ้“พ่ทฏใ€‚ +- kind=52 ๅฝ“ๅ‰ๅช็”จไบŽ CID/ๆ˜ ๅฐ„่ฏญไน‰๏ผŒๆœช้ชŒ่ฏ็š„ๅކๅฒๆญฃๆ–‡ใ€ไธปๅŠจๅކๅฒๆ‹‰ๅ–ใ€ๆถˆๆฏ็ผ–่พ‘ใ€ๅๅบ”ๅ’Œ RESET ๅ‡ไธๅฎฃ็งฐๆ”ฏๆŒใ€‚ + ## ๅฏ้ ๆ€ง่พน็•Œ - GoChat webhook ็š„ 2xx ๅช่กจ็คบ Connector ๅทฒๆŒไน…ๅŒ–๏ผŒไธ่กจ็คบๅ•†ๅŠก้€šๅทฒๅ‘้€ใ€‚ - ๅ•†ๅŠก้€šๆ˜Ž็กฎๆˆๅŠŸๅŽๅ›žๅ†™ `sent`๏ผ›ๆฐธไน…ๅคฑ่ดฅๅ›žๅ†™ `failed`๏ผ›่ฏทๆฑ‚ๅทฒ็ปๅ†™ๅ‡บไฝ†็ป“ๆžœๆœช็Ÿฅๆ—ถๅ›žๅ†™ `uncertain`ใ€‚ -- uncertain ้ป˜่ฎค่ง‚ๅฏŸ 5 ๅˆ†้’Ÿใ€‚ๅŒ่ดฆๅทๅŽ็ปญๅ‘้€ๅœจๆญคๆœŸ้—ด่ขซ้กบๅบๅฑ้šœ้˜ปๅกž๏ผ›่ถ…ๆ—ถ่ฝฌ failed ๅŽ้‡Šๆ”พใ€‚ๆ™šๅˆฐไธ”ๅ”ฏไธ€ๅŒน้…็š„ kind=3 ๅ›žๆ˜พไปๅฏ็บ ๆญฃไธบ sentใ€‚ +- uncertain ้ป˜่ฎค่ง‚ๅฏŸ 5 ๅˆ†้’Ÿใ€‚ๅŒ่ดฆๅทๅŽ็ปญๅ‘้€ๅœจๆญคๆœŸ้—ด่ขซ้กบๅบๅฑ้šœ้˜ปๅกž๏ผ›ๆถˆๆฏ่ถ…ๆ—ถ่ฝฌ failed ๅŽ้‡Šๆ”พ๏ผŒๅˆ†็ฑป/ๆ”นๅไปไฟ็•™ uncertain๏ผˆๅซ `uncertain_timeout`๏ผ‰ๅนถ็ญ‰ๅพ…ไบบๅทฅ/็ป“ๆžœ่กฅๅฟใ€‚ๆ™šๅˆฐไธ”ๅ”ฏไธ€ๅŒน้…็š„ kind=3 ๅ›žๆ˜พไปๅฏ็บ ๆญฃไธบ sentใ€‚ - `oc/send.aspx` ไธ่ฟ”ๅ›žๆถˆๆฏ IDใ€‚kind=2/3 ๅฟƒ่ทณไธญ็š„ๅŽŸๅง‹ `seq_id` ๆ‰ๆ˜ฏๅ•†ๅŠก้€šๆถˆๆฏ ID๏ผ›็ป„ๅˆ source ID ๅช็”จไบŽๅน‚็ญ‰๏ผŒไธ่ƒฝๅ†’ๅ……ๅค–้ƒจๆถˆๆฏ IDใ€‚ - kind=52 ๆฒกๆœ‰็œŸๅฎž็‰ˆๆœฌ fixture ๅ‰ไฟ็•™ raw-only๏ผŒไธไผช้€  child ๆถˆๆฏ IDใ€‚ -- ๅฏๅŠจๆ—ถๅ…ˆไปŽ SQLite ๆขๅค supervisor๏ผŒๅ†ๅผ‚ๆญฅๆ‹‰ๅ– GoChat ๅ…จ้‡้…็ฝฎ๏ผ›้ฆ–ๆฌกๅฎŒๆ•ดๅฟซ็…งๅคฑ่ดฅไผšไปŽ 1 ็ง’ๆŒ‡ๆ•ฐ้€€้ฟๅˆฐ 5 ๅˆ†้’ŸๆŒ็ปญ้‡่ฏ•๏ผŒไธไพ่ต– GoChat health ๆ‰ๅฏๅŠจ่ฟ›็จ‹ใ€‚ +- Connector ่”็ณปไบบๅ†™ๅ›žๆบๅธฆ origin/event ID๏ผ›GoChat ่ฟœ็จ‹ๆฅๆบไบ‹ไปถไธๅๅ‘็”Ÿๆˆ renameใ€‚่”็ณปไบบ็Šถๆ€ๅ’Œ webhook outbox ้‡‡็”จๅŒไธ€ไบ‹ๅŠก๏ผŒenqueue ๅคฑ่ดฅไผšๅ›žๆปš pendingใ€‚ +- ๅฏๅŠจๆ—ถๅ…ˆไปŽ SQLite ๆขๅค supervisor๏ผŒๅ†ๅผ‚ๆญฅๆ‹‰ๅ– GoChat ๅ…จ้‡้…็ฝฎ๏ผ›้ฆ–ๆฌกๅฎŒๆ•ดๅฟซ็…งๅคฑ่ดฅไผšไปŽ 1 ็ง’ๆŒ‡ๆ•ฐ้€€้ฟๅˆฐ 5 ๅˆ†้’ŸๆŒ็ปญ้‡่ฏ•๏ผŒไธไพ่ต– GoChat health ๆ‰ๅฏๅŠจ่ฟ›็จ‹ใ€‚ๅˆ†็ฑป catalog ๅŒๆญฅๅ…ˆๆŒไน…ๅŒ–ๆœฌๅœฐ `classification_sync_results`๏ผŒ่ฏปๅ–ๅคฑ่ดฅไธŽ GoChat ๅ›žไผ ๅคฑ่ดฅๅˆ†ๅผ€้‡่ฏ•๏ผ›้‡ๅฏๆขๅค `pending/syncing/failed`๏ผŒไธๆŠŠ 202 ๅฝ“ไฝœๅทฒๅฎŒๆˆใ€‚ - SIGTERM ไผšๅ…ˆๅœๆญข readiness ๅ’Œ HTTP ๆŽฅๆ”ถ๏ผŒๅ†ๅ–ๆถˆ workerใ€็ญ‰ๅพ… supervisorใ€checkpoint WAL๏ผ›ๅ‡็บงไธไผšไธปๅŠจๆ‰น้‡ logoutใ€‚ +- ็ป“ๆžœๅ›ž่ฐƒ่€—ๅฐฝๆ—ถไฝฟ็”จ `compensate-result --account-id ... --operation-id ... --actor ... --reason ...`๏ผ›่ฏฅๅ‘ฝไปคๅช้‡ๆ”พๅทฒไฟๅญ˜็ป“ๆžœ๏ผŒไธ้‡ๅคๅ•†ๅŠก้€šๆ“ไฝœ๏ผŒๅนถๅ†™ๅ…ฅๅฎก่ฎก่กจใ€‚Connector webhook ๆ˜Ž็กฎ 4xx ไผšๅฐ†ๅŒน้…็š„ๆœฌๅœฐ pending ๆ ‡ไธบ failed๏ผŒ่ถ…ๆ—ถ/่€—ๅฐฝๅชๆ ‡ไธบ uncertainใ€‚ + ่‡ชๅŠจๅŒ–ๆต‹่ฏ•้€š่ฟ‡ไธ็ญ‰ไบŽ็œŸๅฎžๅ•†ๅŠก้€š็”Ÿไบงๅฏ็”จใ€‚ๆœฌๅœฐ 500 ่ดฆๅทไธ€ๅฐๆ—ถ fake protocol soak ๅช่ฏๆ˜Ž่ฐƒๅบฆใ€SQLite ๅ’Œไบ‹ไปถๆŒไน…ๅŒ–๏ผ›็™ปๅฝ•ใ€ๆ”ถๅ‘ใ€presenceใ€ๅฏ†็ ๆ›ดๆ–ฐใ€่ดŸ kind cursorใ€kind=52ใ€็œŸๅฎžๅช’ไฝ“ URLใ€้ชŒ่ฏ็ ๆต็จ‹ๅ’Œ็œŸๅฎžๅ•†ๅŠก้€š้™ๆตๅฟ…้กปๆŒ‰่ฟ่กŒๆ‰‹ๅ†Œ็•™ๅญ˜่ฏๆฎใ€‚ diff --git a/channels/shangwutong/db/generated/classification_sync.sql.go b/channels/shangwutong/db/generated/classification_sync.sql.go new file mode 100644 index 00000000..714e9cd2 --- /dev/null +++ b/channels/shangwutong/db/generated/classification_sync.sql.go @@ -0,0 +1,283 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: classification_sync.sql + +package dbgen + +import ( + "context" + "time" +) + +const claimClassificationSyncResult = `-- name: ClaimClassificationSyncResult :one +UPDATE classification_sync_results SET + status = 'syncing', + attempts = attempts + 1, + next_attempt_at = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE id = ( + SELECT candidate.id + FROM classification_sync_results AS candidate + JOIN accounts AS account ON account.id = candidate.account_id + WHERE candidate.status IN ('pending', 'failed') + AND candidate.attempts < 10 + AND account.enabled = 1 + AND account.desired_presence <> 'offline' + AND account.deleted_at IS NULL + AND (candidate.next_attempt_at IS NULL OR julianday(candidate.next_attempt_at) IS NULL OR julianday(candidate.next_attempt_at) <= julianday('now')) + ORDER BY candidate.id + LIMIT 1 +) +AND status IN ('pending', 'failed') +AND attempts < 10 +RETURNING id, account_id, inbox_id, event_id, phase, status, conversation_kinds, customer_colors, attempts, next_attempt_at, last_error_code, last_error_message, created_at, updated_at +` + +func (q *Queries) ClaimClassificationSyncResult(ctx context.Context) (*ClassificationSyncResult, error) { + row := q.db.QueryRowContext(ctx, claimClassificationSyncResult) + var i ClassificationSyncResult + err := row.Scan( + &i.ID, + &i.AccountID, + &i.InboxID, + &i.EventID, + &i.Phase, + &i.Status, + &i.ConversationKinds, + &i.CustomerColors, + &i.Attempts, + &i.NextAttemptAt, + &i.LastErrorCode, + &i.LastErrorMessage, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} + +const claimClassificationSyncResultByID = `-- name: ClaimClassificationSyncResultByID :one +UPDATE classification_sync_results SET + status = 'syncing', + attempts = attempts + 1, + next_attempt_at = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE classification_sync_results.id = ? + AND classification_sync_results.status IN ('pending', 'failed') + AND classification_sync_results.attempts < 10 + AND EXISTS ( + SELECT 1 FROM accounts AS account + WHERE account.id = classification_sync_results.account_id + AND account.enabled = 1 + AND account.desired_presence <> 'offline' + AND account.deleted_at IS NULL + ) + AND (classification_sync_results.next_attempt_at IS NULL OR julianday(classification_sync_results.next_attempt_at) IS NULL OR julianday(classification_sync_results.next_attempt_at) <= julianday('now')) +RETURNING id, account_id, inbox_id, event_id, phase, status, conversation_kinds, customer_colors, attempts, next_attempt_at, last_error_code, last_error_message, created_at, updated_at +` + +func (q *Queries) ClaimClassificationSyncResultByID(ctx context.Context, id int64) (*ClassificationSyncResult, error) { + row := q.db.QueryRowContext(ctx, claimClassificationSyncResultByID, id) + var i ClassificationSyncResult + err := row.Scan( + &i.ID, + &i.AccountID, + &i.InboxID, + &i.EventID, + &i.Phase, + &i.Status, + &i.ConversationKinds, + &i.CustomerColors, + &i.Attempts, + &i.NextAttemptAt, + &i.LastErrorCode, + &i.LastErrorMessage, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} + +const completeClassificationSyncResult = `-- name: CompleteClassificationSyncResult :exec +UPDATE classification_sync_results SET + status = 'succeeded', + phase = 'report', + next_attempt_at = NULL, + last_error_code = NULL, + last_error_message = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? AND status = 'syncing' +` + +func (q *Queries) CompleteClassificationSyncResult(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, completeClassificationSyncResult, id) + return err +} + +const failClassificationSyncResult = `-- name: FailClassificationSyncResult :exec +UPDATE classification_sync_results SET + status = 'failed', + next_attempt_at = ?, + last_error_code = ?, + last_error_message = ?, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? AND status = 'syncing' +` + +type FailClassificationSyncResultParams struct { + NextAttemptAt *time.Time `json:"next_attempt_at"` + LastErrorCode *string `json:"last_error_code"` + LastErrorMessage *string `json:"last_error_message"` + ID int64 `json:"id"` +} + +func (q *Queries) FailClassificationSyncResult(ctx context.Context, arg FailClassificationSyncResultParams) error { + _, err := q.db.ExecContext(ctx, failClassificationSyncResult, + arg.NextAttemptAt, + arg.LastErrorCode, + arg.LastErrorMessage, + arg.ID, + ) + return err +} + +const getClassificationSyncResult = `-- name: GetClassificationSyncResult :one +SELECT id, account_id, inbox_id, event_id, phase, status, conversation_kinds, customer_colors, attempts, next_attempt_at, last_error_code, last_error_message, created_at, updated_at FROM classification_sync_results +WHERE account_id = ? AND event_id = ? +LIMIT 1 +` + +type GetClassificationSyncResultParams struct { + AccountID int64 `json:"account_id"` + EventID string `json:"event_id"` +} + +func (q *Queries) GetClassificationSyncResult(ctx context.Context, arg GetClassificationSyncResultParams) (*ClassificationSyncResult, error) { + row := q.db.QueryRowContext(ctx, getClassificationSyncResult, arg.AccountID, arg.EventID) + var i ClassificationSyncResult + err := row.Scan( + &i.ID, + &i.AccountID, + &i.InboxID, + &i.EventID, + &i.Phase, + &i.Status, + &i.ConversationKinds, + &i.CustomerColors, + &i.Attempts, + &i.NextAttemptAt, + &i.LastErrorCode, + &i.LastErrorMessage, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} + +const insertClassificationSyncResult = `-- name: InsertClassificationSyncResult :one +INSERT INTO classification_sync_results ( + account_id, inbox_id, event_id, phase, status +) VALUES (?, ?, ?, 'read', 'pending') +ON CONFLICT(account_id, event_id) DO NOTHING +RETURNING id, account_id, inbox_id, event_id, phase, status, conversation_kinds, customer_colors, attempts, next_attempt_at, last_error_code, last_error_message, created_at, updated_at +` + +type InsertClassificationSyncResultParams struct { + AccountID int64 `json:"account_id"` + InboxID int64 `json:"inbox_id"` + EventID string `json:"event_id"` +} + +func (q *Queries) InsertClassificationSyncResult(ctx context.Context, arg InsertClassificationSyncResultParams) (*ClassificationSyncResult, error) { + row := q.db.QueryRowContext(ctx, insertClassificationSyncResult, arg.AccountID, arg.InboxID, arg.EventID) + var i ClassificationSyncResult + err := row.Scan( + &i.ID, + &i.AccountID, + &i.InboxID, + &i.EventID, + &i.Phase, + &i.Status, + &i.ConversationKinds, + &i.CustomerColors, + &i.Attempts, + &i.NextAttemptAt, + &i.LastErrorCode, + &i.LastErrorMessage, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} + +const recoverClassificationSyncResult = `-- name: RecoverClassificationSyncResult :exec +UPDATE classification_sync_results SET + status = 'pending', + attempts = CASE WHEN attempts > 0 THEN attempts - 1 ELSE 0 END, + next_attempt_at = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? AND status = 'syncing' +` + +func (q *Queries) RecoverClassificationSyncResult(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, recoverClassificationSyncResult, id) + return err +} + +const recoverClassificationSyncResults = `-- name: RecoverClassificationSyncResults :execrows +UPDATE classification_sync_results SET + status = 'pending', + attempts = CASE WHEN attempts > 0 THEN attempts - 1 ELSE 0 END, + next_attempt_at = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE status = 'syncing' +` + +func (q *Queries) RecoverClassificationSyncResults(ctx context.Context) (int64, error) { + result, err := q.db.ExecContext(ctx, recoverClassificationSyncResults) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const resetClassificationSyncResult = `-- name: ResetClassificationSyncResult :exec +UPDATE classification_sync_results SET + status = 'pending', + attempts = 0, + next_attempt_at = NULL, + last_error_code = NULL, + last_error_message = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE account_id = ? AND event_id = ? AND status = 'failed' +` + +type ResetClassificationSyncResultParams struct { + AccountID int64 `json:"account_id"` + EventID string `json:"event_id"` +} + +func (q *Queries) ResetClassificationSyncResult(ctx context.Context, arg ResetClassificationSyncResultParams) error { + _, err := q.db.ExecContext(ctx, resetClassificationSyncResult, arg.AccountID, arg.EventID) + return err +} + +const setClassificationSyncResultCatalog = `-- name: SetClassificationSyncResultCatalog :exec +UPDATE classification_sync_results SET + phase = 'report', + conversation_kinds = ?, + customer_colors = ?, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? AND status = 'syncing' +` + +type SetClassificationSyncResultCatalogParams struct { + ConversationKinds string `json:"conversation_kinds"` + CustomerColors string `json:"customer_colors"` + ID int64 `json:"id"` +} + +func (q *Queries) SetClassificationSyncResultCatalog(ctx context.Context, arg SetClassificationSyncResultCatalogParams) error { + _, err := q.db.ExecContext(ctx, setClassificationSyncResultCatalog, arg.ConversationKinds, arg.CustomerColors, arg.ID) + return err +} diff --git a/channels/shangwutong/db/generated/inbound.sql.go b/channels/shangwutong/db/generated/inbound.sql.go index 0d3801bc..acdc4320 100644 --- a/channels/shangwutong/db/generated/inbound.sql.go +++ b/channels/shangwutong/db/generated/inbound.sql.go @@ -219,6 +219,26 @@ func (q *Queries) GetLatestConversationState(ctx context.Context, arg GetLatestC return text, err } +const getLatestConversationStateIncludingClaimed = `-- name: GetLatestConversationStateIncludingClaimed :one +SELECT text FROM inbound_events +WHERE account_id = ? AND swt_sid = ? AND kind = 0 + AND delivery_status IN ('delivered', 'delivering') +ORDER BY id DESC +LIMIT 1 +` + +type GetLatestConversationStateIncludingClaimedParams struct { + AccountID int64 `json:"account_id"` + SwtSid string `json:"swt_sid"` +} + +func (q *Queries) GetLatestConversationStateIncludingClaimed(ctx context.Context, arg GetLatestConversationStateIncludingClaimedParams) (*string, error) { + row := q.db.QueryRowContext(ctx, getLatestConversationStateIncludingClaimed, arg.AccountID, arg.SwtSid) + var text *string + err := row.Scan(&text) + return text, err +} + const getLatestXSTOperator = `-- name: GetLatestXSTOperator :one SELECT text, seq_id FROM inbound_events WHERE account_id = ? AND swt_sid = ? AND kind = 6 diff --git a/channels/shangwutong/db/generated/metrics.sql.go b/channels/shangwutong/db/generated/metrics.sql.go index b82a4b9b..76f634e7 100644 --- a/channels/shangwutong/db/generated/metrics.sql.go +++ b/channels/shangwutong/db/generated/metrics.sql.go @@ -23,7 +23,10 @@ func (q *Queries) CountStatusSyncQueue(ctx context.Context) (int64, error) { } const listAccountMetricCounts = `-- name: ListAccountMetricCounts :many -SELECT connection_status, actual_presence, COUNT(*) AS count +SELECT + connection_status, + actual_presence, + COUNT(*) AS count FROM accounts WHERE deleted_at IS NULL GROUP BY connection_status, actual_presence @@ -59,8 +62,48 @@ func (q *Queries) ListAccountMetricCounts(ctx context.Context) ([]*ListAccountMe return items, nil } +const listClassificationSyncQueueMetricCounts = `-- name: ListClassificationSyncQueueMetricCounts :many +SELECT + status, + COUNT(*) AS count +FROM classification_sync_results +WHERE status IN ('pending', 'syncing', 'failed') +GROUP BY status +ORDER BY status +` + +type ListClassificationSyncQueueMetricCountsRow struct { + Status string `json:"status"` + Count int64 `json:"count"` +} + +func (q *Queries) ListClassificationSyncQueueMetricCounts(ctx context.Context) ([]*ListClassificationSyncQueueMetricCountsRow, error) { + rows, err := q.db.QueryContext(ctx, listClassificationSyncQueueMetricCounts) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*ListClassificationSyncQueueMetricCountsRow{} + for rows.Next() { + var i ListClassificationSyncQueueMetricCountsRow + if err := rows.Scan(&i.Status, &i.Count); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listInboundQueueMetricCounts = `-- name: ListInboundQueueMetricCounts :many -SELECT delivery_status, COUNT(*) AS count +SELECT + delivery_status, + COUNT(*) AS count FROM inbound_events GROUP BY delivery_status ORDER BY delivery_status @@ -95,7 +138,9 @@ func (q *Queries) ListInboundQueueMetricCounts(ctx context.Context) ([]*ListInbo } const listOutboundQueueMetricCounts = `-- name: ListOutboundQueueMetricCounts :many -SELECT delivery_status, COUNT(*) AS count +SELECT + delivery_status, + COUNT(*) AS count FROM outbound_messages GROUP BY delivery_status ORDER BY delivery_status diff --git a/channels/shangwutong/db/generated/models.go b/channels/shangwutong/db/generated/models.go index 59e2bcf8..59426467 100644 --- a/channels/shangwutong/db/generated/models.go +++ b/channels/shangwutong/db/generated/models.go @@ -45,6 +45,23 @@ type Account struct { UpdatedAt time.Time `json:"updated_at"` } +type ClassificationSyncResult struct { + ID int64 `json:"id"` + AccountID int64 `json:"account_id"` + InboxID int64 `json:"inbox_id"` + EventID string `json:"event_id"` + Phase string `json:"phase"` + Status string `json:"status"` + ConversationKinds string `json:"conversation_kinds"` + CustomerColors string `json:"customer_colors"` + Attempts int64 `json:"attempts"` + NextAttemptAt *time.Time `json:"next_attempt_at"` + LastErrorCode *string `json:"last_error_code"` + LastErrorMessage *string `json:"last_error_message"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + type ConversationMap struct { AccountID int64 `json:"account_id"` SwtSid string `json:"swt_sid"` @@ -101,6 +118,17 @@ type MessageMap struct { UpdatedAt time.Time `json:"updated_at"` } +type OperationResultCompensation struct { + ID int64 `json:"id"` + RequestID string `json:"request_id"` + OperationID int64 `json:"operation_id"` + AccountID int64 `json:"account_id"` + Actor string `json:"actor"` + Reason string `json:"reason"` + RequestedAt time.Time `json:"requested_at"` + Result string `json:"result"` +} + type OutboundMessage struct { ID int64 `json:"id"` AccountID int64 `json:"account_id"` @@ -152,6 +180,7 @@ type OutboundOperation struct { ResultErrorMessage *string `json:"result_error_message"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + UncertainSince *time.Time `json:"uncertain_since"` } type OutboundPart struct { diff --git a/channels/shangwutong/db/generated/operations.sql.go b/channels/shangwutong/db/generated/operations.sql.go index 9a334735..57b4d5de 100644 --- a/channels/shangwutong/db/generated/operations.sql.go +++ b/channels/shangwutong/db/generated/operations.sql.go @@ -55,7 +55,7 @@ WHERE id = ( LIMIT 1 ) AND delivery_status = 'pending' -RETURNING id, account_id, swt_sid, event_id, operation, payload, occurred_at, delivery_status, claimed_at, attempts, next_attempt_at, last_error, result_sync_status, result_sync_attempts, result_sync_next_at, result_reported_at, result_status, result_error_code, result_error_message, created_at, updated_at +RETURNING id, account_id, swt_sid, event_id, operation, payload, occurred_at, delivery_status, claimed_at, attempts, next_attempt_at, last_error, result_sync_status, result_sync_attempts, result_sync_next_at, result_reported_at, result_status, result_error_code, result_error_message, created_at, updated_at, uncertain_since ` func (q *Queries) ClaimOutboundOperation(ctx context.Context) (*OutboundOperation, error) { @@ -83,6 +83,7 @@ func (q *Queries) ClaimOutboundOperation(ctx context.Context) (*OutboundOperatio &i.ResultErrorMessage, &i.CreatedAt, &i.UpdatedAt, + &i.UncertainSince, ) return &i, err } @@ -96,7 +97,7 @@ UPDATE outbound_operations SET WHERE id = ( SELECT candidate.id FROM outbound_operations AS candidate - WHERE candidate.operation IN ('set_chat_kind', 'set_customer_color') + WHERE candidate.operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') AND candidate.delivery_status IN ('delivered', 'uncertain', 'failed') AND candidate.result_sync_status = 'pending' AND (candidate.result_sync_next_at IS NULL OR julianday(candidate.result_sync_next_at) IS NULL OR julianday(candidate.result_sync_next_at) <= julianday('now')) @@ -110,7 +111,7 @@ WHERE id = ( LIMIT 1 ) AND result_sync_status = 'pending' -RETURNING id, account_id, swt_sid, event_id, operation, payload, occurred_at, delivery_status, claimed_at, attempts, next_attempt_at, last_error, result_sync_status, result_sync_attempts, result_sync_next_at, result_reported_at, result_status, result_error_code, result_error_message, created_at, updated_at +RETURNING id, account_id, swt_sid, event_id, operation, payload, occurred_at, delivery_status, claimed_at, attempts, next_attempt_at, last_error, result_sync_status, result_sync_attempts, result_sync_next_at, result_reported_at, result_status, result_error_code, result_error_message, created_at, updated_at, uncertain_since ` func (q *Queries) ClaimOutboundOperationResult(ctx context.Context) (*OutboundOperation, error) { @@ -138,6 +139,7 @@ func (q *Queries) ClaimOutboundOperationResult(ctx context.Context) (*OutboundOp &i.ResultErrorMessage, &i.CreatedAt, &i.UpdatedAt, + &i.UncertainSince, ) return &i, err } @@ -146,22 +148,23 @@ const completeOutboundOperation = `-- name: CompleteOutboundOperation :exec UPDATE outbound_operations SET delivery_status = 'delivered', claimed_at = NULL, + uncertain_since = NULL, last_error = NULL, result_sync_status = CASE - WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'pending' + WHEN operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') THEN 'pending' ELSE result_sync_status END, result_sync_next_at = NULL, result_status = CASE - WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'succeeded' + WHEN operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') THEN 'succeeded' ELSE result_status END, result_error_code = CASE - WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN NULL + WHEN operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') THEN NULL ELSE result_error_code END, result_error_message = CASE - WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN NULL + WHEN operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') THEN NULL ELSE result_error_message END, updated_at = CURRENT_TIMESTAMP @@ -188,32 +191,61 @@ func (q *Queries) CompleteOutboundOperationResult(ctx context.Context, id int64) return err } +const deferContactNameOperation = `-- name: DeferContactNameOperation :exec +UPDATE outbound_operations SET + delivery_status = 'pending', + claimed_at = NULL, + uncertain_since = NULL, + attempts = CASE WHEN attempts > 0 THEN attempts - 1 ELSE 0 END, + next_attempt_at = ?, + last_error = ?, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? + AND operation = 'change_contact_name' + AND delivery_status = 'delivering' +` + +type DeferContactNameOperationParams struct { + NextAttemptAt *time.Time `json:"next_attempt_at"` + LastError *string `json:"last_error"` + ID int64 `json:"id"` +} + +func (q *Queries) DeferContactNameOperation(ctx context.Context, arg DeferContactNameOperationParams) error { + _, err := q.db.ExecContext(ctx, deferContactNameOperation, arg.NextAttemptAt, arg.LastError, arg.ID) + return err +} + const failExpiredUncertainOperations = `-- name: FailExpiredUncertainOperations :execrows UPDATE outbound_operations SET - delivery_status = 'failed', + delivery_status = CASE + WHEN operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') THEN 'uncertain' + ELSE 'failed' + END, claimed_at = NULL, last_error = 'operation result remained uncertain beyond the observation window', - result_sync_status = CASE - WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'pending' - ELSE result_sync_status - END, - result_sync_next_at = NULL, result_status = CASE - WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'failed' + WHEN operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') THEN 'uncertain' ELSE result_status END, result_error_code = CASE - WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'uncertain_timeout' + WHEN operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') THEN 'uncertain_timeout' ELSE result_error_code END, result_error_message = CASE - WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'operation result remained uncertain beyond the observation window' + WHEN operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') THEN 'operation result remained uncertain beyond the observation window' ELSE result_error_message END, + uncertain_since = CASE + WHEN operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') THEN uncertain_since + ELSE NULL + END, updated_at = CURRENT_TIMESTAMP WHERE delivery_status = 'uncertain' - AND result_sync_status <> 'syncing' - AND julianday(updated_at) <= julianday(?) + AND uncertain_since IS NOT NULL + AND result_sync_status NOT IN ('syncing', 'synced') + AND (result_error_code IS NULL OR result_error_code <> 'uncertain_timeout') + AND julianday(uncertain_since) <= julianday(?) ` func (q *Queries) FailExpiredUncertainOperations(ctx context.Context, julianday interface{}) (int64, error) { @@ -228,22 +260,23 @@ const failOutboundOperation = `-- name: FailOutboundOperation :exec UPDATE outbound_operations SET delivery_status = 'failed', claimed_at = NULL, + uncertain_since = NULL, last_error = ?, result_sync_status = CASE - WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'pending' + WHEN operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') THEN 'pending' ELSE result_sync_status END, result_sync_next_at = NULL, result_status = CASE - WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'failed' + WHEN operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') THEN 'failed' ELSE result_status END, result_error_code = CASE - WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN ? + WHEN operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') THEN ? ELSE result_error_code END, result_error_message = CASE - WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN ? + WHEN operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') THEN ? ELSE result_error_message END, updated_at = CURRENT_TIMESTAMP @@ -286,8 +319,54 @@ func (q *Queries) FailOutboundOperationResult(ctx context.Context, arg FailOutbo return err } +const getAcceptTransferOperationSince = `-- name: GetAcceptTransferOperationSince :one +SELECT id, account_id, swt_sid, event_id, operation, payload, occurred_at, delivery_status, claimed_at, attempts, next_attempt_at, last_error, result_sync_status, result_sync_attempts, result_sync_next_at, result_reported_at, result_status, result_error_code, result_error_message, created_at, updated_at, uncertain_since FROM outbound_operations +WHERE account_id = ? + AND swt_sid = ? + AND operation = 'accept_transfer' + AND occurred_at >= ? +ORDER BY id DESC +LIMIT 1 +` + +type GetAcceptTransferOperationSinceParams struct { + AccountID int64 `json:"account_id"` + SwtSid string `json:"swt_sid"` + OccurredAt time.Time `json:"occurred_at"` +} + +func (q *Queries) GetAcceptTransferOperationSince(ctx context.Context, arg GetAcceptTransferOperationSinceParams) (*OutboundOperation, error) { + row := q.db.QueryRowContext(ctx, getAcceptTransferOperationSince, arg.AccountID, arg.SwtSid, arg.OccurredAt) + var i OutboundOperation + err := row.Scan( + &i.ID, + &i.AccountID, + &i.SwtSid, + &i.EventID, + &i.Operation, + &i.Payload, + &i.OccurredAt, + &i.DeliveryStatus, + &i.ClaimedAt, + &i.Attempts, + &i.NextAttemptAt, + &i.LastError, + &i.ResultSyncStatus, + &i.ResultSyncAttempts, + &i.ResultSyncNextAt, + &i.ResultReportedAt, + &i.ResultStatus, + &i.ResultErrorCode, + &i.ResultErrorMessage, + &i.CreatedAt, + &i.UpdatedAt, + &i.UncertainSince, + ) + return &i, err +} + const getOutboundOperationByEventID = `-- name: GetOutboundOperationByEventID :one -SELECT id, account_id, swt_sid, event_id, operation, payload, occurred_at, delivery_status, claimed_at, attempts, next_attempt_at, last_error, result_sync_status, result_sync_attempts, result_sync_next_at, result_reported_at, result_status, result_error_code, result_error_message, created_at, updated_at FROM outbound_operations +SELECT id, account_id, swt_sid, event_id, operation, payload, occurred_at, delivery_status, claimed_at, attempts, next_attempt_at, last_error, result_sync_status, result_sync_attempts, result_sync_next_at, result_reported_at, result_status, result_error_code, result_error_message, created_at, updated_at, uncertain_since FROM outbound_operations WHERE event_id = ? LIMIT 1 ` @@ -317,6 +396,7 @@ func (q *Queries) GetOutboundOperationByEventID(ctx context.Context, eventID str &i.ResultErrorMessage, &i.CreatedAt, &i.UpdatedAt, + &i.UncertainSince, ) return &i, err } @@ -326,7 +406,7 @@ INSERT INTO outbound_operations ( account_id, swt_sid, event_id, operation, payload, occurred_at ) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(event_id) DO NOTHING -RETURNING id, account_id, swt_sid, event_id, operation, payload, occurred_at, delivery_status, claimed_at, attempts, next_attempt_at, last_error, result_sync_status, result_sync_attempts, result_sync_next_at, result_reported_at, result_status, result_error_code, result_error_message, created_at, updated_at +RETURNING id, account_id, swt_sid, event_id, operation, payload, occurred_at, delivery_status, claimed_at, attempts, next_attempt_at, last_error, result_sync_status, result_sync_attempts, result_sync_next_at, result_reported_at, result_status, result_error_code, result_error_message, created_at, updated_at, uncertain_since ` type InsertOutboundOperationParams struct { @@ -370,6 +450,7 @@ func (q *Queries) InsertOutboundOperation(ctx context.Context, arg InsertOutboun &i.ResultErrorMessage, &i.CreatedAt, &i.UpdatedAt, + &i.UncertainSince, ) return &i, err } @@ -378,22 +459,23 @@ const markOutboundOperationUncertain = `-- name: MarkOutboundOperationUncertain UPDATE outbound_operations SET delivery_status = 'uncertain', claimed_at = NULL, + uncertain_since = CASE WHEN delivery_status = 'delivering' THEN CURRENT_TIMESTAMP ELSE uncertain_since END, last_error = ?, result_sync_status = CASE - WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'pending' + WHEN operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') THEN 'pending' ELSE result_sync_status END, result_sync_next_at = NULL, result_status = CASE - WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'uncertain' + WHEN operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') THEN 'uncertain' ELSE result_status END, result_error_code = CASE - WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN ? + WHEN operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') THEN ? ELSE result_error_code END, result_error_message = CASE - WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN ? + WHEN operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') THEN ? ELSE result_error_message END, updated_at = CURRENT_TIMESTAMP @@ -417,6 +499,35 @@ func (q *Queries) MarkOutboundOperationUncertain(ctx context.Context, arg MarkOu return err } +const recordOperationResultCompensation = `-- name: RecordOperationResultCompensation :execrows +INSERT INTO operation_result_compensations ( + request_id, operation_id, account_id, actor, reason, result +) VALUES (?, ?, ?, ?, ?, 'accepted') +ON CONFLICT(request_id) DO NOTHING +` + +type RecordOperationResultCompensationParams struct { + RequestID string `json:"request_id"` + OperationID int64 `json:"operation_id"` + AccountID int64 `json:"account_id"` + Actor string `json:"actor"` + Reason string `json:"reason"` +} + +func (q *Queries) RecordOperationResultCompensation(ctx context.Context, arg RecordOperationResultCompensationParams) (int64, error) { + result, err := q.db.ExecContext(ctx, recordOperationResultCompensation, + arg.RequestID, + arg.OperationID, + arg.AccountID, + arg.Actor, + arg.Reason, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const recoverOutboundOperationResults = `-- name: RecoverOutboundOperationResults :execrows UPDATE outbound_operations SET result_sync_status = 'pending', @@ -437,22 +548,23 @@ const recoverOutboundOperationsAsUncertain = `-- name: RecoverOutboundOperations UPDATE outbound_operations SET delivery_status = 'uncertain', claimed_at = NULL, + uncertain_since = CURRENT_TIMESTAMP, last_error = 'connector restarted while operation was delivering', result_sync_status = CASE - WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'pending' + WHEN operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') THEN 'pending' ELSE result_sync_status END, result_sync_next_at = NULL, result_status = CASE - WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'uncertain' + WHEN operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') THEN 'uncertain' ELSE result_status END, result_error_code = CASE - WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'connector_restart_uncertain' + WHEN operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') THEN 'connector_restart_uncertain' ELSE result_error_code END, result_error_message = CASE - WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'connector restarted while operation was delivering' + WHEN operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') THEN 'connector restarted while operation was delivering' ELSE result_error_message END, updated_at = CURRENT_TIMESTAMP @@ -467,10 +579,38 @@ func (q *Queries) RecoverOutboundOperationsAsUncertain(ctx context.Context) (int return result.RowsAffected() } +const requeueOutboundOperationResult = `-- name: RequeueOutboundOperationResult :execrows +UPDATE outbound_operations SET + result_sync_status = 'pending', + result_sync_next_at = NULL, + last_error = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? + AND account_id = ? + AND operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') + AND delivery_status IN ('delivered', 'uncertain', 'failed') + AND result_status IS NOT NULL + AND result_sync_status = 'failed' +` + +type RequeueOutboundOperationResultParams struct { + ID int64 `json:"id"` + AccountID int64 `json:"account_id"` +} + +func (q *Queries) RequeueOutboundOperationResult(ctx context.Context, arg RequeueOutboundOperationResultParams) (int64, error) { + result, err := q.db.ExecContext(ctx, requeueOutboundOperationResult, arg.ID, arg.AccountID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const retryOutboundOperation = `-- name: RetryOutboundOperation :exec UPDATE outbound_operations SET delivery_status = 'pending', claimed_at = NULL, + uncertain_since = NULL, next_attempt_at = ?, last_error = ?, updated_at = CURRENT_TIMESTAMP @@ -507,3 +647,28 @@ func (q *Queries) RetryOutboundOperationResult(ctx context.Context, arg RetryOut _, err := q.db.ExecContext(ctx, retryOutboundOperationResult, arg.ResultSyncNextAt, arg.LastError, arg.ID) return err } + +const wakeContactNameOperations = `-- name: WakeContactNameOperations :execrows +UPDATE outbound_operations SET + next_attempt_at = NULL, + last_error = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE account_id = ? + AND swt_sid = ? + AND operation = 'change_contact_name' + AND delivery_status = 'pending' + AND last_error LIKE '%cid_unavailable%' +` + +type WakeContactNameOperationsParams struct { + AccountID int64 `json:"account_id"` + SwtSid string `json:"swt_sid"` +} + +func (q *Queries) WakeContactNameOperations(ctx context.Context, arg WakeContactNameOperationsParams) (int64, error) { + result, err := q.db.ExecContext(ctx, wakeContactNameOperations, arg.AccountID, arg.SwtSid) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/channels/shangwutong/db/generated/outbound.sql.go b/channels/shangwutong/db/generated/outbound.sql.go index ae794be4..5a5706b6 100644 --- a/channels/shangwutong/db/generated/outbound.sql.go +++ b/channels/shangwutong/db/generated/outbound.sql.go @@ -1030,3 +1030,27 @@ func (q *Queries) RetryStatusSync(ctx context.Context, arg RetryStatusSyncParams _, err := q.db.ExecContext(ctx, retryStatusSync, arg.StatusSyncNextAt, arg.LastError, arg.ID) return err } + +const wakePendingOutboundMessagesForSession = `-- name: WakePendingOutboundMessagesForSession :execrows +UPDATE outbound_messages SET + next_attempt_at = NULL, + last_error = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE account_id = ? + AND swt_sid = ? + AND delivery_status = 'pending' + AND (next_attempt_at IS NOT NULL OR last_error IS NOT NULL) +` + +type WakePendingOutboundMessagesForSessionParams struct { + AccountID int64 `json:"account_id"` + SwtSid string `json:"swt_sid"` +} + +func (q *Queries) WakePendingOutboundMessagesForSession(ctx context.Context, arg WakePendingOutboundMessagesForSessionParams) (int64, error) { + result, err := q.db.ExecContext(ctx, wakePendingOutboundMessagesForSession, arg.AccountID, arg.SwtSid) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/channels/shangwutong/db/migrations/009_add_operation_result_compensations.down.sql b/channels/shangwutong/db/migrations/009_add_operation_result_compensations.down.sql new file mode 100644 index 00000000..904a8728 --- /dev/null +++ b/channels/shangwutong/db/migrations/009_add_operation_result_compensations.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_operation_result_compensations_operation; +DROP TABLE IF EXISTS operation_result_compensations; diff --git a/channels/shangwutong/db/migrations/009_add_operation_result_compensations.up.sql b/channels/shangwutong/db/migrations/009_add_operation_result_compensations.up.sql new file mode 100644 index 00000000..b024aaef --- /dev/null +++ b/channels/shangwutong/db/migrations/009_add_operation_result_compensations.up.sql @@ -0,0 +1,13 @@ +CREATE TABLE operation_result_compensations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_id TEXT NOT NULL UNIQUE, + operation_id INTEGER NOT NULL REFERENCES outbound_operations(id), + account_id INTEGER NOT NULL REFERENCES accounts(id), + actor TEXT NOT NULL, + reason TEXT NOT NULL, + requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + result TEXT NOT NULL +); + +CREATE INDEX idx_operation_result_compensations_operation + ON operation_result_compensations(operation_id, requested_at); diff --git a/channels/shangwutong/db/migrations/010_add_uncertain_since.down.sql b/channels/shangwutong/db/migrations/010_add_uncertain_since.down.sql new file mode 100644 index 00000000..ee9ddecb --- /dev/null +++ b/channels/shangwutong/db/migrations/010_add_uncertain_since.down.sql @@ -0,0 +1,11 @@ +-- Do not silently discard observation evidence during rollback. +DROP TABLE IF EXISTS outbound_operations_uncertain_since_rollback_guard; +CREATE TABLE outbound_operations_uncertain_since_rollback_guard ( + value DATETIME CHECK (value IS NULL) +); +INSERT INTO outbound_operations_uncertain_since_rollback_guard (value) +SELECT uncertain_since +FROM outbound_operations +WHERE uncertain_since IS NOT NULL; +DROP TABLE outbound_operations_uncertain_since_rollback_guard; +ALTER TABLE outbound_operations DROP COLUMN uncertain_since; diff --git a/channels/shangwutong/db/migrations/010_add_uncertain_since.up.sql b/channels/shangwutong/db/migrations/010_add_uncertain_since.up.sql new file mode 100644 index 00000000..d2de1be9 --- /dev/null +++ b/channels/shangwutong/db/migrations/010_add_uncertain_since.up.sql @@ -0,0 +1,5 @@ +ALTER TABLE outbound_operations ADD COLUMN uncertain_since DATETIME; + +UPDATE outbound_operations +SET uncertain_since = updated_at +WHERE delivery_status = 'uncertain' AND uncertain_since IS NULL; diff --git a/channels/shangwutong/db/migrations/011_add_classification_sync_results.down.sql b/channels/shangwutong/db/migrations/011_add_classification_sync_results.down.sql new file mode 100644 index 00000000..7b8be729 --- /dev/null +++ b/channels/shangwutong/db/migrations/011_add_classification_sync_results.down.sql @@ -0,0 +1,10 @@ +-- Keep pending/failed/succeeded result evidence instead of deleting it on rollback. +DROP TABLE IF EXISTS classification_sync_results_rollback_guard; +CREATE TABLE classification_sync_results_rollback_guard ( + id INTEGER CHECK (id IS NULL) +); +INSERT INTO classification_sync_results_rollback_guard (id) +SELECT id FROM classification_sync_results; +DROP TABLE classification_sync_results_rollback_guard; +DROP INDEX IF EXISTS idx_classification_sync_results_due; +DROP TABLE classification_sync_results; diff --git a/channels/shangwutong/db/migrations/011_add_classification_sync_results.up.sql b/channels/shangwutong/db/migrations/011_add_classification_sync_results.up.sql new file mode 100644 index 00000000..15cd6795 --- /dev/null +++ b/channels/shangwutong/db/migrations/011_add_classification_sync_results.up.sql @@ -0,0 +1,20 @@ +CREATE TABLE classification_sync_results ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL REFERENCES accounts(id), + inbox_id INTEGER NOT NULL, + event_id TEXT NOT NULL, + phase TEXT NOT NULL DEFAULT 'read', + status TEXT NOT NULL DEFAULT 'pending', + conversation_kinds TEXT NOT NULL DEFAULT '[]', + customer_colors TEXT NOT NULL DEFAULT '[]', + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt_at DATETIME, + last_error_code TEXT, + last_error_message TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(account_id, event_id) +); + +CREATE INDEX idx_classification_sync_results_due + ON classification_sync_results(status, next_attempt_at, id); diff --git a/channels/shangwutong/db/queries/classification_sync.sql b/channels/shangwutong/db/queries/classification_sync.sql new file mode 100644 index 00000000..b77904c9 --- /dev/null +++ b/channels/shangwutong/db/queries/classification_sync.sql @@ -0,0 +1,106 @@ +-- name: InsertClassificationSyncResult :one +INSERT INTO classification_sync_results ( + account_id, inbox_id, event_id, phase, status +) VALUES (?, ?, ?, 'read', 'pending') +ON CONFLICT(account_id, event_id) DO NOTHING +RETURNING *; + +-- name: GetClassificationSyncResult :one +SELECT * FROM classification_sync_results +WHERE account_id = ? AND event_id = ? +LIMIT 1; + +-- name: ClaimClassificationSyncResult :one +UPDATE classification_sync_results SET + status = 'syncing', + attempts = attempts + 1, + next_attempt_at = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE id = ( + SELECT candidate.id + FROM classification_sync_results AS candidate + JOIN accounts AS account ON account.id = candidate.account_id + WHERE candidate.status IN ('pending', 'failed') + AND candidate.attempts < 10 + AND account.enabled = 1 + AND account.desired_presence <> 'offline' + AND account.deleted_at IS NULL + AND (candidate.next_attempt_at IS NULL OR julianday(candidate.next_attempt_at) IS NULL OR julianday(candidate.next_attempt_at) <= julianday('now')) + ORDER BY candidate.id + LIMIT 1 +) +AND status IN ('pending', 'failed') +AND attempts < 10 +RETURNING *; + +-- name: ClaimClassificationSyncResultByID :one +UPDATE classification_sync_results SET + status = 'syncing', + attempts = attempts + 1, + next_attempt_at = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE classification_sync_results.id = ? + AND classification_sync_results.status IN ('pending', 'failed') + AND classification_sync_results.attempts < 10 + AND EXISTS ( + SELECT 1 FROM accounts AS account + WHERE account.id = classification_sync_results.account_id + AND account.enabled = 1 + AND account.desired_presence <> 'offline' + AND account.deleted_at IS NULL + ) + AND (classification_sync_results.next_attempt_at IS NULL OR julianday(classification_sync_results.next_attempt_at) IS NULL OR julianday(classification_sync_results.next_attempt_at) <= julianday('now')) +RETURNING *; + +-- name: ResetClassificationSyncResult :exec +UPDATE classification_sync_results SET + status = 'pending', + attempts = 0, + next_attempt_at = NULL, + last_error_code = NULL, + last_error_message = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE account_id = ? AND event_id = ? AND status = 'failed'; + +-- name: SetClassificationSyncResultCatalog :exec +UPDATE classification_sync_results SET + phase = 'report', + conversation_kinds = ?, + customer_colors = ?, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? AND status = 'syncing'; + +-- name: CompleteClassificationSyncResult :exec +UPDATE classification_sync_results SET + status = 'succeeded', + phase = 'report', + next_attempt_at = NULL, + last_error_code = NULL, + last_error_message = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? AND status = 'syncing'; + +-- name: FailClassificationSyncResult :exec +UPDATE classification_sync_results SET + status = 'failed', + next_attempt_at = ?, + last_error_code = ?, + last_error_message = ?, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? AND status = 'syncing'; + +-- name: RecoverClassificationSyncResult :exec +UPDATE classification_sync_results SET + status = 'pending', + attempts = CASE WHEN attempts > 0 THEN attempts - 1 ELSE 0 END, + next_attempt_at = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? AND status = 'syncing'; + +-- name: RecoverClassificationSyncResults :execrows +UPDATE classification_sync_results SET + status = 'pending', + attempts = CASE WHEN attempts > 0 THEN attempts - 1 ELSE 0 END, + next_attempt_at = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE status = 'syncing'; diff --git a/channels/shangwutong/db/queries/inbound.sql b/channels/shangwutong/db/queries/inbound.sql index e8606406..6c201831 100644 --- a/channels/shangwutong/db/queries/inbound.sql +++ b/channels/shangwutong/db/queries/inbound.sql @@ -94,6 +94,13 @@ WHERE account_id = ? AND swt_sid = ? AND kind = 6 ORDER BY id DESC LIMIT 1; +-- name: GetLatestConversationStateIncludingClaimed :one +SELECT text FROM inbound_events +WHERE account_id = ? AND swt_sid = ? AND kind = 0 + AND delivery_status IN ('delivered', 'delivering') +ORDER BY id DESC +LIMIT 1; + -- name: UpsertConversationXSTRoute :one INSERT INTO conversation_maps ( account_id, swt_sid, gochat_contact_source_id, diff --git a/channels/shangwutong/db/queries/metrics.sql b/channels/shangwutong/db/queries/metrics.sql index 940d730f..117a9b87 100644 --- a/channels/shangwutong/db/queries/metrics.sql +++ b/channels/shangwutong/db/queries/metrics.sql @@ -1,18 +1,25 @@ -- name: ListAccountMetricCounts :many -SELECT connection_status, actual_presence, COUNT(*) AS count +SELECT + connection_status, + actual_presence, + COUNT(*) AS count FROM accounts WHERE deleted_at IS NULL GROUP BY connection_status, actual_presence ORDER BY connection_status, actual_presence; -- name: ListInboundQueueMetricCounts :many -SELECT delivery_status, COUNT(*) AS count +SELECT + delivery_status, + COUNT(*) AS count FROM inbound_events GROUP BY delivery_status ORDER BY delivery_status; -- name: ListOutboundQueueMetricCounts :many -SELECT delivery_status, COUNT(*) AS count +SELECT + delivery_status, + COUNT(*) AS count FROM outbound_messages GROUP BY delivery_status ORDER BY delivery_status; @@ -21,3 +28,12 @@ ORDER BY delivery_status; SELECT COUNT(*) FROM outbound_messages WHERE status_sync_status IN ('pending', 'syncing'); + +-- name: ListClassificationSyncQueueMetricCounts :many +SELECT + status, + COUNT(*) AS count +FROM classification_sync_results +WHERE status IN ('pending', 'syncing', 'failed') +GROUP BY status +ORDER BY status; diff --git a/channels/shangwutong/db/queries/operations.sql b/channels/shangwutong/db/queries/operations.sql index d408131c..44bc3a7e 100644 --- a/channels/shangwutong/db/queries/operations.sql +++ b/channels/shangwutong/db/queries/operations.sql @@ -10,6 +10,15 @@ SELECT * FROM outbound_operations WHERE event_id = ? LIMIT 1; +-- name: GetAcceptTransferOperationSince :one +SELECT * FROM outbound_operations +WHERE account_id = ? + AND swt_sid = ? + AND operation = 'accept_transfer' + AND occurred_at >= ? +ORDER BY id DESC +LIMIT 1; + -- name: ClaimOutboundOperation :one UPDATE outbound_operations SET delivery_status = 'delivering', @@ -61,22 +70,23 @@ RETURNING *; UPDATE outbound_operations SET delivery_status = 'delivered', claimed_at = NULL, + uncertain_since = NULL, last_error = NULL, result_sync_status = CASE - WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'pending' + WHEN operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') THEN 'pending' ELSE result_sync_status END, result_sync_next_at = NULL, result_status = CASE - WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'succeeded' + WHEN operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') THEN 'succeeded' ELSE result_status END, result_error_code = CASE - WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN NULL + WHEN operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') THEN NULL ELSE result_error_code END, result_error_message = CASE - WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN NULL + WHEN operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') THEN NULL ELSE result_error_message END, updated_at = CURRENT_TIMESTAMP @@ -86,11 +96,36 @@ WHERE id = ? AND delivery_status = 'delivering'; UPDATE outbound_operations SET delivery_status = 'pending', claimed_at = NULL, + uncertain_since = NULL, next_attempt_at = ?, last_error = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND delivery_status = 'delivering'; +-- name: WakeContactNameOperations :execrows +UPDATE outbound_operations SET + next_attempt_at = NULL, + last_error = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE account_id = ? + AND swt_sid = ? + AND operation = 'change_contact_name' + AND delivery_status = 'pending' + AND last_error LIKE '%cid_unavailable%'; + +-- name: DeferContactNameOperation :exec +UPDATE outbound_operations SET + delivery_status = 'pending', + claimed_at = NULL, + uncertain_since = NULL, + attempts = CASE WHEN attempts > 0 THEN attempts - 1 ELSE 0 END, + next_attempt_at = ?, + last_error = ?, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? + AND operation = 'change_contact_name' + AND delivery_status = 'delivering'; + -- name: ClaimOutboundOperationResult :one UPDATE outbound_operations SET result_sync_status = 'syncing', @@ -100,7 +135,7 @@ UPDATE outbound_operations SET WHERE id = ( SELECT candidate.id FROM outbound_operations AS candidate - WHERE candidate.operation IN ('set_chat_kind', 'set_customer_color') + WHERE candidate.operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') AND candidate.delivery_status IN ('delivered', 'uncertain', 'failed') AND candidate.result_sync_status = 'pending' AND (candidate.result_sync_next_at IS NULL OR julianday(candidate.result_sync_next_at) IS NULL OR julianday(candidate.result_sync_next_at) <= julianday('now')) @@ -148,26 +183,46 @@ UPDATE outbound_operations SET updated_at = CURRENT_TIMESTAMP WHERE result_sync_status = 'syncing'; +-- name: RequeueOutboundOperationResult :execrows +UPDATE outbound_operations SET + result_sync_status = 'pending', + result_sync_next_at = NULL, + last_error = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE id = ? + AND account_id = ? + AND operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') + AND delivery_status IN ('delivered', 'uncertain', 'failed') + AND result_status IS NOT NULL + AND result_sync_status = 'failed'; + +-- name: RecordOperationResultCompensation :execrows +INSERT INTO operation_result_compensations ( + request_id, operation_id, account_id, actor, reason, result +) VALUES (?, ?, ?, ?, ?, 'accepted') +ON CONFLICT(request_id) DO NOTHING; + -- name: MarkOutboundOperationUncertain :exec UPDATE outbound_operations SET delivery_status = 'uncertain', claimed_at = NULL, + uncertain_since = CASE WHEN delivery_status = 'delivering' THEN CURRENT_TIMESTAMP ELSE uncertain_since END, last_error = ?, result_sync_status = CASE - WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'pending' + WHEN operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') THEN 'pending' ELSE result_sync_status END, result_sync_next_at = NULL, result_status = CASE - WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'uncertain' + WHEN operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') THEN 'uncertain' ELSE result_status END, result_error_code = CASE - WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN ? + WHEN operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') THEN ? ELSE result_error_code END, result_error_message = CASE - WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN ? + WHEN operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') THEN ? ELSE result_error_message END, updated_at = CURRENT_TIMESTAMP @@ -177,22 +232,23 @@ WHERE id = ? AND delivery_status = 'delivering'; UPDATE outbound_operations SET delivery_status = 'failed', claimed_at = NULL, + uncertain_since = NULL, last_error = ?, result_sync_status = CASE - WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'pending' + WHEN operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') THEN 'pending' ELSE result_sync_status END, result_sync_next_at = NULL, result_status = CASE - WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'failed' + WHEN operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') THEN 'failed' ELSE result_status END, result_error_code = CASE - WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN ? + WHEN operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') THEN ? ELSE result_error_code END, result_error_message = CASE - WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN ? + WHEN operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') THEN ? ELSE result_error_message END, updated_at = CURRENT_TIMESTAMP @@ -202,22 +258,23 @@ WHERE id = ? AND delivery_status IN ('delivering', 'uncertain'); UPDATE outbound_operations SET delivery_status = 'uncertain', claimed_at = NULL, + uncertain_since = CURRENT_TIMESTAMP, last_error = 'connector restarted while operation was delivering', result_sync_status = CASE - WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'pending' + WHEN operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') THEN 'pending' ELSE result_sync_status END, result_sync_next_at = NULL, result_status = CASE - WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'uncertain' + WHEN operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') THEN 'uncertain' ELSE result_status END, result_error_code = CASE - WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'connector_restart_uncertain' + WHEN operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') THEN 'connector_restart_uncertain' ELSE result_error_code END, result_error_message = CASE - WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'connector restarted while operation was delivering' + WHEN operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') THEN 'connector restarted while operation was delivering' ELSE result_error_message END, updated_at = CURRENT_TIMESTAMP @@ -225,27 +282,31 @@ WHERE delivery_status = 'delivering'; -- name: FailExpiredUncertainOperations :execrows UPDATE outbound_operations SET - delivery_status = 'failed', + delivery_status = CASE + WHEN operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') THEN 'uncertain' + ELSE 'failed' + END, claimed_at = NULL, last_error = 'operation result remained uncertain beyond the observation window', - result_sync_status = CASE - WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'pending' - ELSE result_sync_status - END, - result_sync_next_at = NULL, result_status = CASE - WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'failed' + WHEN operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') THEN 'uncertain' ELSE result_status END, result_error_code = CASE - WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'uncertain_timeout' + WHEN operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') THEN 'uncertain_timeout' ELSE result_error_code END, result_error_message = CASE - WHEN operation IN ('set_chat_kind', 'set_customer_color') THEN 'operation result remained uncertain beyond the observation window' + WHEN operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') THEN 'operation result remained uncertain beyond the observation window' ELSE result_error_message END, + uncertain_since = CASE + WHEN operation IN ('set_chat_kind', 'set_customer_color', 'change_contact_name') THEN uncertain_since + ELSE NULL + END, updated_at = CURRENT_TIMESTAMP WHERE delivery_status = 'uncertain' - AND result_sync_status <> 'syncing' - AND julianday(updated_at) <= julianday(?); + AND uncertain_since IS NOT NULL + AND result_sync_status NOT IN ('syncing', 'synced') + AND (result_error_code IS NULL OR result_error_code <> 'uncertain_timeout') + AND julianday(uncertain_since) <= julianday(?); diff --git a/channels/shangwutong/db/queries/outbound.sql b/channels/shangwutong/db/queries/outbound.sql index f2dba60d..b2638a3d 100644 --- a/channels/shangwutong/db/queries/outbound.sql +++ b/channels/shangwutong/db/queries/outbound.sql @@ -251,6 +251,16 @@ UPDATE outbound_messages SET updated_at = CURRENT_TIMESTAMP WHERE id = ? AND delivery_status = 'delivering'; +-- name: WakePendingOutboundMessagesForSession :execrows +UPDATE outbound_messages SET + next_attempt_at = NULL, + last_error = NULL, + updated_at = CURRENT_TIMESTAMP +WHERE account_id = ? + AND swt_sid = ? + AND delivery_status = 'pending' + AND (next_attempt_at IS NOT NULL OR last_error IS NOT NULL); + -- name: FailOutboundMessage :exec UPDATE outbound_messages SET delivery_status = 'failed', diff --git a/channels/shangwutong/internal/account/manager.go b/channels/shangwutong/internal/account/manager.go index b735d29e..f079f327 100644 --- a/channels/shangwutong/internal/account/manager.go +++ b/channels/shangwutong/internal/account/manager.go @@ -2,8 +2,11 @@ package account import ( "context" + "database/sql" + "encoding/json" "errors" "fmt" + "strings" "sync" "time" @@ -37,16 +40,22 @@ type ClassificationSyncStatusReporter interface { UpdateClassificationSyncStatus(context.Context, int64, string, string, string, string) error } +const ( + classificationSyncMaxAttempts = 10 + classificationSyncInterval = 5 * time.Second +) + type Manager struct { - store *store.Store - protocol ProtocolClient - reporter StatusReporter - logger *logrus.Entry - heartbeatSlots chan struct{} - heartbeatInterval time.Duration - statusRefreshInterval time.Duration - initialDelay func(time.Duration) time.Duration - metrics *observability.Metrics + store *store.Store + protocol ProtocolClient + reporter StatusReporter + logger *logrus.Entry + heartbeatSlots chan struct{} + heartbeatInterval time.Duration + statusRefreshInterval time.Duration + classificationSyncInterval time.Duration + initialDelay func(time.Duration) time.Duration + metrics *observability.Metrics mu sync.Mutex started bool @@ -72,7 +81,9 @@ func NewManager(database *store.Store, protocol ProtocolClient, reporter StatusR store: database, protocol: protocol, reporter: reporter, logger: logger, heartbeatSlots: make(chan struct{}, maxInflightHeartbeats), heartbeatInterval: 2500 * time.Millisecond, statusRefreshInterval: 30 * time.Second, - initialDelay: randomDelay, supervisors: make(map[int64]*supervisor), statusQueue: make(chan int64, 2048), + classificationSyncInterval: classificationSyncInterval, initialDelay: randomDelay, + supervisors: make(map[int64]*supervisor), statusQueue: make(chan int64, 2048), + metrics: observability.NewMetrics(), }, nil } @@ -99,6 +110,16 @@ func (m *Manager) Start(ctx context.Context) error { m.wg.Add(1) go m.statusRefresher() } + if _, ok := m.protocol.(ClassificationProtocol); ok { + if _, ok := m.reporter.(ClassificationReporter); ok { + if _, err := m.store.Writer().RecoverClassificationSyncResults(m.ctx); err != nil { + m.Stop() + return fmt.Errorf("recover classification sync results: %w", err) + } + m.wg.Add(1) + go m.classificationSyncWorker() + } + } for _, account := range accounts { if store.AccountRunnable(account) { m.ensure(account) @@ -159,42 +180,215 @@ func (m *Manager) WithSession(ctx context.Context, accountID int64, operation fu } func (m *Manager) SyncClassifications(ctx context.Context, accountID int64, eventID string) error { - protocol, ok := m.protocol.(ClassificationProtocol) - if !ok { + if _, ok := m.protocol.(ClassificationProtocol); !ok { return errors.New("classification protocol is unavailable") } - reporter, ok := m.reporter.(ClassificationReporter) - if !ok { + if _, ok := m.reporter.(ClassificationReporter); !ok { return errors.New("classification reporter is unavailable") } - account, err := m.store.Reader().GetAccountByID(ctx, accountID) + eventID = strings.TrimSpace(eventID) + if eventID == "" { + return errors.New("classification event ID is required") + } + connectorAccount, err := m.store.Reader().GetAccountByID(ctx, accountID) if err != nil { return fmt.Errorf("load connector account: %w", err) } - if account.GochatInboxID <= 0 { + if connectorAccount.GochatInboxID <= 0 { return errors.New("GoChat inbox mapping is invalid") } - inboxID := account.GochatInboxID - var catalog swt.ClassificationCatalog - if err := m.WithSession(ctx, accountID, func(session swt.Session) error { - var err error - catalog, err = protocol.FetchClassificationCatalog(ctx, session) + result, err := m.ensureClassificationSyncResult(ctx, accountID, connectorAccount.GochatInboxID, eventID) + if err != nil { + return fmt.Errorf("persist classification sync result: %w", err) + } + if result.InboxID != connectorAccount.GochatInboxID { + return errors.New("classification event is bound to a different GoChat inbox") + } + if result.Status == "succeeded" || result.Status == "syncing" { + return nil + } + if result.Status == "failed" { + if err := m.store.Writer().ResetClassificationSyncResult(ctx, dbgen.ResetClassificationSyncResultParams{ + AccountID: accountID, EventID: eventID, + }); err != nil { + return fmt.Errorf("requeue classification sync result: %w", err) + } + } + return m.processClassificationSyncResult(ctx, result.ID) +} + +func (m *Manager) ensureClassificationSyncResult(ctx context.Context, accountID, inboxID int64, eventID string) (*dbgen.ClassificationSyncResult, error) { + result, err := m.store.Writer().InsertClassificationSyncResult(ctx, dbgen.InsertClassificationSyncResultParams{ + AccountID: accountID, InboxID: inboxID, EventID: eventID, + }) + if err == nil { + return result, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return nil, err + } + result, err = m.store.Reader().GetClassificationSyncResult(ctx, dbgen.GetClassificationSyncResultParams{ + AccountID: accountID, EventID: eventID, + }) + if err != nil { + return nil, err + } + return result, nil +} + +func (m *Manager) processClassificationSyncResult(ctx context.Context, resultID int64) error { + result, err := m.store.Writer().ClaimClassificationSyncResultByID(ctx, resultID) + if errors.Is(err, sql.ErrNoRows) { + return nil + } + if err != nil { return err - }); err != nil { - if statusReporter, ok := m.reporter.(ClassificationSyncStatusReporter); ok { - code := "classification_sync_failed" - var protocolErr *swt.Error - if errors.As(err, &protocolErr) && protocolErr.Code != "" { - code = protocolErr.Code - } - _ = statusReporter.UpdateClassificationSyncStatus(ctx, inboxID, eventID, "failed", code, err.Error()) + } + if err := m.processClaimedClassificationSyncResult(ctx, result); err != nil { + if releaseErr := m.store.Writer().RecoverClassificationSyncResult(ctx, result.ID); releaseErr != nil { + return errors.Join(err, releaseErr) } return err } - return reporter.UpdateClassificationCatalog(ctx, inboxID, eventID, gochat.ClassificationCatalog{ - ConversationKinds: convertConversationKinds(catalog.ConversationKinds), - CustomerColors: convertCustomerColors(catalog.CustomerColors), - }) + return nil +} + +func (m *Manager) processNextClassificationSyncResult(ctx context.Context) (bool, error) { + result, err := m.store.Writer().ClaimClassificationSyncResult(ctx) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, err + } + err = m.processClaimedClassificationSyncResult(ctx, result) + if err != nil { + if releaseErr := m.store.Writer().RecoverClassificationSyncResult(ctx, result.ID); releaseErr != nil { + return true, errors.Join(err, releaseErr) + } + } + return true, err +} + +func (m *Manager) processClaimedClassificationSyncResult(ctx context.Context, result *dbgen.ClassificationSyncResult) error { + protocol, _ := m.protocol.(ClassificationProtocol) + reporter, _ := m.reporter.(ClassificationReporter) + if result.Phase == "read" { + var catalog swt.ClassificationCatalog + var sessionNeedsLogin bool + if err := m.WithSession(ctx, result.AccountID, func(session swt.Session) error { + sessionNeedsLogin = !session.ClassificationCatalogLoaded + return nil + }); err != nil { + return m.failClassificationSyncResult(ctx, result, "classification_sync_failed", err) + } + if sessionNeedsLogin { + if err := m.refreshSessionForClassification(ctx, result.AccountID); err != nil { + return m.failClassificationSyncResult(ctx, result, "classification_sync_failed", err) + } + } + if err := m.WithSession(ctx, result.AccountID, func(session swt.Session) error { + var err error + catalog, err = protocol.FetchClassificationCatalog(ctx, session) + return err + }); err != nil { + return m.failClassificationSyncResult(ctx, result, "classification_sync_failed", err) + } + conversationKinds, err := json.Marshal(convertConversationKinds(catalog.ConversationKinds)) + if err != nil { + return m.failClassificationSyncResult(ctx, result, "classification_sync_failed", err) + } + customerColors, err := json.Marshal(convertCustomerColors(catalog.CustomerColors)) + if err != nil { + return m.failClassificationSyncResult(ctx, result, "classification_sync_failed", err) + } + if err := m.store.Writer().SetClassificationSyncResultCatalog(ctx, dbgen.SetClassificationSyncResultCatalogParams{ + ConversationKinds: string(conversationKinds), CustomerColors: string(customerColors), ID: result.ID, + }); err != nil { + return m.failClassificationSyncResult(ctx, result, "classification_sync_persistence_failed", err) + } + result.Phase = "report" + result.ConversationKinds, result.CustomerColors = string(conversationKinds), string(customerColors) + } + if result.Phase != "report" { + return m.failClassificationSyncResult(ctx, result, "classification_sync_persistence_failed", errors.New("invalid classification sync phase")) + } + if err := reporter.UpdateClassificationCatalog(ctx, result.InboxID, result.EventID, gochat.ClassificationCatalog{ + ConversationKinds: decodeClassificationKinds(result.ConversationKinds), + CustomerColors: decodeCustomerColors(result.CustomerColors), + }); err != nil { + return m.failClassificationSyncResult(ctx, result, "gochat_result_unavailable", fmt.Errorf("report classification catalog: %w", err)) + } + if err := m.store.Writer().CompleteClassificationSyncResult(ctx, result.ID); err != nil { + return err + } + if m.metrics != nil { + m.metrics.ClassificationSync("success") + } + return nil +} + +func (m *Manager) failClassificationSyncResult(ctx context.Context, result *dbgen.ClassificationSyncResult, code string, cause error) error { + message := cause.Error() + var nextAttemptAt *time.Time + if result.Attempts < classificationSyncMaxAttempts { + next := time.Now().UTC().Add(retryDelay(int(result.Attempts), 5*time.Minute)) + nextAttemptAt = &next + } + if err := m.store.Writer().FailClassificationSyncResult(ctx, dbgen.FailClassificationSyncResultParams{ + NextAttemptAt: nextAttemptAt, LastErrorCode: &code, LastErrorMessage: &message, ID: result.ID, + }); err != nil { + return err + } + if m.metrics != nil { + m.metrics.ClassificationSync("failed") + } + if result.Attempts >= classificationSyncMaxAttempts { + if statusReporter, ok := m.reporter.(ClassificationSyncStatusReporter); ok { + if err := statusReporter.UpdateClassificationSyncStatus(ctx, result.InboxID, result.EventID, "failed", code, message); err != nil { + m.logger.WithError(err).WithField("event_id", result.EventID).Warn("classification sync failure status report failed") + } + } + } + return cause +} + +func decodeClassificationKinds(value string) []gochat.ClassificationKind { + var result []gochat.ClassificationKind + if json.Unmarshal([]byte(value), &result) != nil { + return nil + } + return result +} + +func decodeCustomerColors(value string) []gochat.CustomerColorKind { + var result []gochat.CustomerColorKind + if json.Unmarshal([]byte(value), &result) != nil { + return nil + } + return result +} + +func (m *Manager) classificationSyncWorker() { + defer m.wg.Done() + ticker := time.NewTicker(m.classificationSyncInterval) + defer ticker.Stop() + for { + select { + case <-m.ctx.Done(): + return + case <-ticker.C: + for range 16 { + processed, err := m.processNextClassificationSyncResult(m.ctx) + if err != nil { + m.logger.WithError(err).Warn("classification sync result retry failed") + } + if !processed { + break + } + } + } + } } func convertConversationKinds(kinds []swt.ConversationKind) []gochat.ClassificationKind { @@ -213,6 +407,23 @@ func convertCustomerColors(colors []swt.CustomerColorKind) []gochat.CustomerColo return result } +func (m *Manager) refreshSessionForClassification(ctx context.Context, accountID int64) error { + account, err := m.store.Reader().GetAccountByID(ctx, accountID) + if err != nil { + return err + } + if account.PendingPassword != nil { + return errors.New("account credential update is pending") + } + m.mu.Lock() + supervisor := m.supervisors[accountID] + m.mu.Unlock() + if supervisor == nil { + return errors.New("account supervisor is not running") + } + return supervisor.login(ctx, account, account.Password, false) +} + func (m *Manager) InvalidateSession(ctx context.Context, accountID int64) error { m.mu.Lock() supervisor := m.supervisors[accountID] diff --git a/channels/shangwutong/internal/account/manager_test.go b/channels/shangwutong/internal/account/manager_test.go index 4a50687e..9e5fbba6 100644 --- a/channels/shangwutong/internal/account/manager_test.go +++ b/channels/shangwutong/internal/account/manager_test.go @@ -152,6 +152,121 @@ func TestManagerClassificationCallbacksUseGoChatInboxID(t *testing.T) { } } +func TestManagerRetriesPersistedClassificationReadAfterFailure(t *testing.T) { + ctx := context.Background() + database, err := store.Open(ctx, filepath.Join(t.TempDir(), "connector.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + account := createRunnableAccount(t, ctx, database) + protocol := newFakeProtocol() + protocol.classificationErr = errors.New("remote catalog unavailable") + reporter := &classificationReporter{} + manager, err := NewManager(database, protocol, reporter, nil, 1) + if err != nil { + t.Fatal(err) + } + manager.initialDelay = func(time.Duration) time.Duration { return 0 } + manager.heartbeatInterval = time.Hour + if err := manager.Start(ctx); err != nil { + t.Fatal(err) + } + defer func() { + manager.Stop() + manager.Wait() + }() + waitSignal(t, protocol.logins, "classification login") + + if err := manager.SyncClassifications(ctx, account.ID, "classification:read-retry"); err == nil { + t.Fatal("catalog read unexpectedly succeeded") + } + result, err := database.Reader().GetClassificationSyncResult(ctx, dbgen.GetClassificationSyncResultParams{ + AccountID: account.ID, EventID: "classification:read-retry", + }) + if err != nil { + t.Fatal(err) + } + if result.Status != "failed" || result.Phase != "read" || result.Attempts != 1 { + t.Fatalf("persisted read result = %#v", result) + } + protocol.mu.Lock() + protocol.classificationErr = nil + protocol.mu.Unlock() + if err := manager.SyncClassifications(ctx, account.ID, "classification:read-retry"); err != nil { + t.Fatal(err) + } + result, err = database.Reader().GetClassificationSyncResult(ctx, dbgen.GetClassificationSyncResultParams{ + AccountID: account.ID, EventID: "classification:read-retry", + }) + if err != nil { + t.Fatal(err) + } + if result.Status != "succeeded" || protocol.classificationFetches() != 2 || reporter.catalogCalls() != 1 { + t.Fatalf("read retry result = %#v fetches=%d reports=%d", result, protocol.classificationFetches(), reporter.catalogCalls()) + } +} + +func TestManagerRetriesPersistedClassificationResultWithoutRefetching(t *testing.T) { + ctx := context.Background() + database, err := store.Open(ctx, filepath.Join(t.TempDir(), "connector.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + account := createRunnableAccount(t, ctx, database) + protocol := newFakeProtocol() + reporter := &classificationReporter{catalogErrors: 1} + manager, err := NewManager(database, protocol, reporter, nil, 1) + if err != nil { + t.Fatal(err) + } + manager.initialDelay = func(time.Duration) time.Duration { return 0 } + manager.heartbeatInterval = time.Hour + if err := manager.Start(ctx); err != nil { + t.Fatal(err) + } + defer func() { + manager.Stop() + manager.Wait() + }() + waitSignal(t, protocol.logins, "classification login") + + if err := manager.SyncClassifications(ctx, account.ID, "classification:retry"); err == nil { + t.Fatal("first classification report unexpectedly succeeded") + } + result, err := database.Reader().GetClassificationSyncResult(ctx, dbgen.GetClassificationSyncResultParams{ + AccountID: account.ID, EventID: "classification:retry", + }) + if err != nil { + t.Fatal(err) + } + if result.Status != "failed" || result.Phase != "report" || result.Attempts != 1 { + t.Fatalf("persisted classification result = %#v", result) + } + if protocol.classificationFetches() != 1 { + t.Fatalf("classification fetches after report failure = %d", protocol.classificationFetches()) + } + if err := manager.SyncClassifications(ctx, account.ID, "classification:retry"); err != nil { + t.Fatal(err) + } + result, err = database.Reader().GetClassificationSyncResult(ctx, dbgen.GetClassificationSyncResultParams{ + AccountID: account.ID, EventID: "classification:retry", + }) + if err != nil { + t.Fatal(err) + } + if result.Status != "succeeded" { + t.Fatalf("classification result status = %q", result.Status) + } + if protocol.classificationFetches() != 1 { + t.Fatalf("classification was refetched = %d", protocol.classificationFetches()) + } + if reporter.catalogCalls() != 2 { + t.Fatalf("catalog report calls = %d", reporter.catalogCalls()) + } +} + func TestManagerMergesTypingIntoNextHeartbeat(t *testing.T) { ctx := context.Background() database, err := store.Open(ctx, filepath.Join(t.TempDir(), "connector.db")) @@ -249,13 +364,15 @@ func createRunnableAccount(t *testing.T, ctx context.Context, database *store.St } type fakeProtocol struct { - mu sync.Mutex - loginErr error - logins chan struct{} - heartbeats chan heartbeatCall - presences chan swt.Presence - offline int - heartbeatPanics int + mu sync.Mutex + loginErr error + logins chan struct{} + heartbeats chan heartbeatCall + presences chan swt.Presence + offline int + heartbeatPanics int + classificationFetchCount int + classificationErr error } type heartbeatCall struct { @@ -292,9 +409,22 @@ func (f *fakeProtocol) Heartbeat(_ context.Context, session swt.Session, cursor } func (f *fakeProtocol) FetchClassificationCatalog(context.Context, swt.Session) (swt.ClassificationCatalog, error) { + f.mu.Lock() + f.classificationFetchCount++ + err := f.classificationErr + f.mu.Unlock() + if err != nil { + return swt.ClassificationCatalog{}, err + } return swt.ClassificationCatalog{ConversationKinds: []swt.ConversationKind{{ID: "kind-1", Name: "Normal"}}}, nil } +func (f *fakeProtocol) classificationFetches() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.classificationFetchCount +} + func (f *fakeProtocol) SetPresence(_ context.Context, _ swt.Session, presence swt.Presence) error { f.mu.Lock() if presence == swt.PresenceOffline { @@ -312,7 +442,10 @@ func (f *fakeProtocol) offlineCalls() int { } type classificationReporter struct { + mu sync.Mutex catalogInboxID int64 + catalogErrors int + catalogReports int } func (r *classificationReporter) UpdateInboxStatus(context.Context, int64, gochat.InboxStatus) error { @@ -320,10 +453,23 @@ func (r *classificationReporter) UpdateInboxStatus(context.Context, int64, gocha } func (r *classificationReporter) UpdateClassificationCatalog(_ context.Context, inboxID int64, _ string, _ gochat.ClassificationCatalog) error { + r.mu.Lock() + defer r.mu.Unlock() r.catalogInboxID = inboxID + r.catalogReports++ + if r.catalogErrors > 0 { + r.catalogErrors-- + return errors.New("catalog report unavailable") + } return nil } +func (r *classificationReporter) catalogCalls() int { + r.mu.Lock() + defer r.mu.Unlock() + return r.catalogReports +} + func (r *classificationReporter) UpdateClassificationSyncStatus(context.Context, int64, string, string, string, string) error { return nil } diff --git a/channels/shangwutong/internal/command/root.go b/channels/shangwutong/internal/command/root.go index 3e6d7173..92d0d981 100644 --- a/channels/shangwutong/internal/command/root.go +++ b/channels/shangwutong/internal/command/root.go @@ -12,6 +12,7 @@ import ( "syscall" "time" + dbgen "github.com/gochat/gochat/channels/shangwutong/db/generated" "github.com/gochat/gochat/channels/shangwutong/internal/account" "github.com/gochat/gochat/channels/shangwutong/internal/config" "github.com/gochat/gochat/channels/shangwutong/internal/delivery" @@ -20,6 +21,7 @@ import ( "github.com/gochat/gochat/channels/shangwutong/internal/observability" "github.com/gochat/gochat/channels/shangwutong/internal/store" "github.com/gochat/gochat/channels/shangwutong/internal/swt" + "github.com/google/uuid" "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) @@ -31,7 +33,7 @@ func NewRootCommand() *cobra.Command { SilenceUsage: true, SilenceErrors: true, } - root.AddCommand(newServeCommand(), newMigrateCommand(), newReconcileCommand(), newBackupCommand(), newDoctorCommand()) + root.AddCommand(newServeCommand(), newMigrateCommand(), newReconcileCommand(), newCompensateResultCommand(), newBackupCommand(), newDoctorCommand()) return root } @@ -243,6 +245,60 @@ func newReconcileCommand() *cobra.Command { } } +func newCompensateResultCommand() *cobra.Command { + var accountID, operationID int64 + var actor, reason string + command := &cobra.Command{ + Use: "compensate-result", + Short: "้‡ๆ–ฐๅฎ‰ๆŽ’ๅทฒไฟๅญ˜ไฝ†็ป“ๆžœๅŒๆญฅๅคฑ่ดฅ็š„ๆ“ไฝœ็ป“ๆžœ", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if accountID <= 0 || operationID <= 0 || strings.TrimSpace(actor) == "" || strings.TrimSpace(reason) == "" { + return errors.New("--account-id, --operation-id, --actor and --reason are required") + } + path, err := config.LoadDBPath() + if err != nil { + return err + } + database, err := store.Open(cmd.Context(), path) + if err != nil { + return err + } + defer database.Close() + requestID := uuid.NewString() + err = database.WithTx(cmd.Context(), func(queries *dbgen.Queries) error { + rows, err := queries.RequeueOutboundOperationResult(cmd.Context(), dbgen.RequeueOutboundOperationResultParams{ID: operationID, AccountID: accountID}) + if err != nil { + return err + } + if rows != 1 { + return fmt.Errorf("operation %d is not eligible for result compensation", operationID) + } + audited, err := queries.RecordOperationResultCompensation(cmd.Context(), dbgen.RecordOperationResultCompensationParams{ + RequestID: requestID, OperationID: operationID, AccountID: accountID, Actor: strings.TrimSpace(actor), Reason: strings.TrimSpace(reason), + }) + if err != nil { + return err + } + if audited != 1 { + return errors.New("result compensation audit was not recorded") + } + return nil + }) + if err != nil { + return err + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "result compensation accepted: request_id=%s account_id=%d operation_id=%d requested_at=%s\n", requestID, accountID, operationID, time.Now().UTC().Format(time.RFC3339Nano)) + return nil + }, + } + command.Flags().Int64Var(&accountID, "account-id", 0, "tenant account ID") + command.Flags().Int64Var(&operationID, "operation-id", 0, "outbound operation ID") + command.Flags().StringVar(&actor, "actor", "", "operator identity") + command.Flags().StringVar(&reason, "reason", "", "reason for compensation") + return command +} + func newBackupCommand() *cobra.Command { var output string command := &cobra.Command{ diff --git a/channels/shangwutong/internal/command/root_test.go b/channels/shangwutong/internal/command/root_test.go index 91ae1fa9..e3ab5add 100644 --- a/channels/shangwutong/internal/command/root_test.go +++ b/channels/shangwutong/internal/command/root_test.go @@ -87,7 +87,7 @@ func TestOperationalCommands(t *testing.T) { t.Fatalf("%v produced no output", args) } } - if version, err := store.InspectDatabase(context.Background(), backupPath); err != nil || version != 8 { + if version, err := store.InspectDatabase(context.Background(), backupPath); err != nil || version != 11 { t.Fatalf("backup version = %d, %v", version, err) } diff --git a/channels/shangwutong/internal/delivery/inbound.go b/channels/shangwutong/internal/delivery/inbound.go index 90445166..19dd7db6 100644 --- a/channels/shangwutong/internal/delivery/inbound.go +++ b/channels/shangwutong/internal/delivery/inbound.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "database/sql" "encoding/hex" + "encoding/json" "errors" "fmt" "strings" @@ -117,6 +118,11 @@ func (i *Inbound) processInbound(ctx context.Context) (bool, error) { if err := i.client.UpdateContactChannelMetadata(ctx, account.GochatInboxID, event.SwtSid, mapped.ContactCID); err != nil { return true, i.retryOrFail(event, mapped, err) } + if _, err := i.store.Writer().WakeContactNameOperations(ctx, dbgen.WakeContactNameOperationsParams{ + AccountID: account.ID, SwtSid: event.SwtSid, + }); err != nil { + return true, i.retryOrFail(event, mapped, err) + } return true, i.complete(event, mapped, nil) } if mapped.RawOnly && !mapped.RequiresContact && !mapped.RequiresConversation && mapped.Message == nil { @@ -138,9 +144,38 @@ func (i *Inbound) processInbound(ctx context.Context) (bool, error) { if err != nil { return true, i.retryOrFail(event, mapped, err) } + if err := i.prepareXSTOwnership(ctx, account, event, state); err != nil { + return true, i.retryOrFail(event, mapped, err) + } return true, i.persistDelivered(event, mapped, state, imported) } +func (i *Inbound) prepareXSTOwnership(ctx context.Context, account *dbgen.Account, event *dbgen.InboundEvent, state inboundState) error { + text := strings.TrimSpace(value(event.Text)) + if event.Kind == 0 && text == "7" { + if state.conversationID <= 0 { + return store.ErrOutboundSessionNotFound + } + payload, err := json.Marshal(map[string]int64{"conversation_id": state.conversationID}) + if err != nil { + return err + } + _, _, err = i.store.EnqueueAcceptTransfer(ctx, store.OutboundOperationInput{ + AccountID: account.ID, SWTSessionID: event.SwtSid, + EventID: fmt.Sprintf("auto-accept-transfer:%d", event.ID), Operation: "accept_transfer", + Payload: string(payload), OccurredAt: event.CreatedAt, + }, state.conversationID) + return err + } + if (event.Kind == 0 && text == "8") || (event.Kind == 6 && strings.EqualFold(strings.TrimSpace(value(event.OpName)), xstAccountLoginName(account))) { + _, err := i.store.Writer().WakePendingOutboundMessagesForSession(ctx, dbgen.WakePendingOutboundMessagesForSessionParams{ + AccountID: account.ID, SwtSid: event.SwtSid, + }) + return err + } + return nil +} + type inboundState struct { contactID int64 conversationID int64 @@ -177,6 +212,7 @@ func (i *Inbound) ensureResources(ctx context.Context, account *dbgen.Account, e contactRequest := gochat.ContactRequest{ SourceID: event.SwtSid, Name: mapped.ContactName, PhoneNumber: mapped.ContactPhone, CustomAttributes: contactAttributes, AdditionalAttributes: contactAdditional, + Origin: gochat.ContactUpdateOriginSWTConnector, OriginEventID: fmt.Sprintf("swt-inbound:%d", event.ID), } needsContactWrite := state.contactID == 0 || mapped.ContactName != "" || mapped.ContactPhone != "" || len(mapped.ContactAttributes) > 0 if mapped.RequiresContact && needsContactWrite { diff --git a/channels/shangwutong/internal/delivery/inbound_test.go b/channels/shangwutong/internal/delivery/inbound_test.go index 81c56d74..03faa24f 100644 --- a/channels/shangwutong/internal/delivery/inbound_test.go +++ b/channels/shangwutong/internal/delivery/inbound_test.go @@ -35,6 +35,9 @@ func TestInboundVisitorMessageCreatesResourcesAndPersistsMappings(t *testing.T) if len(client.imports) != 1 || client.imports[0].SourceID != "swt:10:visitor:2:42:0" || client.imports[0].Content != "ๆ‚จๅฅฝ\nๅ’จ่ฏข" || client.lastConversationID != 88 { t.Fatalf("imports = %#v", client.imports) } + if client.lastContactOrigin != gochat.ContactUpdateOriginSWTConnector || client.lastContactEventID != "swt-inbound:1" { + t.Fatalf("contact origin = %q/%q", client.lastContactOrigin, client.lastContactEventID) + } event, err := database.Reader().GetInboundEventByKey(ctx, "swt:10:visitor:2:42") if err != nil || event.DeliveryStatus != "delivered" || event.MappingStrategy == nil || *event.MappingStrategy != "native_message" { t.Fatalf("event = %#v, %v", event, err) @@ -647,6 +650,8 @@ type inboundRecorder struct { lastInboxID int64 lastSourceID string lastCID string + lastContactOrigin string + lastContactEventID string metadataErr error conversationAttrs map[string]any lastConversationID int64 @@ -656,10 +661,14 @@ func (r *inboundRecorder) EnsureContact(_ context.Context, _, _ string, request r.mu.Lock() defer r.mu.Unlock() r.metadataErr = nil + r.lastContactOrigin, r.lastContactEventID = request.Origin, request.OriginEventID return gochat.Contact{ID: 456, SourceID: request.SourceID, Name: request.Name}, nil } func (r *inboundRecorder) UpdateContact(_ context.Context, _, _, sourceID string, request gochat.ContactRequest) (gochat.Contact, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.lastContactOrigin, r.lastContactEventID = request.Origin, request.OriginEventID return gochat.Contact{ID: 456, SourceID: sourceID, Name: request.Name}, nil } diff --git a/channels/shangwutong/internal/delivery/mapping.go b/channels/shangwutong/internal/delivery/mapping.go index 697fb7d6..662e63f9 100644 --- a/channels/shangwutong/internal/delivery/mapping.go +++ b/channels/shangwutong/internal/delivery/mapping.go @@ -154,8 +154,12 @@ func mapInboundEvent(kind int64, seqID int64, text, operator, rawTimestamp, sour activity("่ฎฟๅฎข่ฏทๆฑ‚็”ต่ฏๅ›žๆ‹จ") mapped.Strategy = "activity" case 29: + colorID := cleanText(text) + if colorID == "" { + break + } mapped.Strategy, mapped.RawOnly, mapped.RequiresContact, mapped.RequiresConversation = "conversation_attributes", false, true, true - mapped.ConversationAttrs = map[string]any{"swt_label_color": cleanText(text)} + mapped.ConversationAttrs = map[string]any{"swt_label_color": colorID} case 30: activity(cleanText(text)) case 31: diff --git a/channels/shangwutong/internal/delivery/mapping_test.go b/channels/shangwutong/internal/delivery/mapping_test.go index dd167a8f..85ccdaa6 100644 --- a/channels/shangwutong/internal/delivery/mapping_test.go +++ b/channels/shangwutong/internal/delivery/mapping_test.go @@ -105,6 +105,20 @@ func TestKind26UpdatesCurrentContactPhone(t *testing.T) { } } +func TestKind29PreservesRemoteColorID(t *testing.T) { + mapped := mapInboundEvent(29, 42, "10", "", "", "source", 10, time.Now()) + if mapped.Strategy != "conversation_attributes" || mapped.ConversationAttrs["swt_label_color"] != "10" { + t.Fatalf("customer color mapping = %#v", mapped) + } +} + +func TestKind29EmptyColorRemainsRawOnly(t *testing.T) { + mapped := mapInboundEvent(29, 42, " ", "", "", "source", 10, time.Now()) + if !mapped.RawOnly || mapped.Strategy != "raw_only" || mapped.RequiresContact || mapped.RequiresConversation || len(mapped.ConversationAttrs) != 0 { + t.Fatalf("empty customer color mapping = %#v", mapped) + } +} + func TestKind41StoresConversationOutcomeWithoutOverwritingContactName(t *testing.T) { mapped := mapInboundEvent(41, 42, "็•™่”", "", "", "source", 10, time.Now()) if mapped.ContactName != "" || !mapped.RequiresConversation || mapped.ConversationAttrs["swt_outcome"] != "็•™่”" { diff --git a/channels/shangwutong/internal/delivery/outbound.go b/channels/shangwutong/internal/delivery/outbound.go index c2687a7c..e0fda3ee 100644 --- a/channels/shangwutong/internal/delivery/outbound.go +++ b/channels/shangwutong/internal/delivery/outbound.go @@ -23,8 +23,11 @@ import ( const ( uncertainObservationWindow = 5 * time.Minute uncertainSweepInterval = time.Second + contactNameCIDWaitTimeout = 24 * time.Hour ) +var errXSTOwnershipPending = errors.New("XST conversation ownership transition is pending") + type SessionProvider interface { WithSession(context.Context, int64, func(swt.Session) error) error InvalidateSession(context.Context, int64) error @@ -52,6 +55,10 @@ type ClassificationResultClient interface { UpdateClassificationStatus(context.Context, int64, uint, string, string, string, string, string, string, string) error } +type ContactNameResultClient interface { + UpdateContactNameStatus(context.Context, int64, string, gochat.ContactNameOperationStatus) error +} + type ResultClient interface { UpdateMessageStatus(context.Context, int64, int64, gochat.MessageResult) error } @@ -230,6 +237,9 @@ func (o *Outbound) processOutbound(ctx context.Context) (bool, error) { } xstRoute, err := o.xstRoute(ctx, message) if err != nil { + if errors.Is(err, errXSTOwnershipPending) { + return true, o.retryAfter(message, err.Error(), time.Second) + } return true, o.retry(message, err.Error()) } if xstRoute != nil { @@ -382,10 +392,38 @@ func (o *Outbound) xstRoute(ctx context.Context, message *dbgen.OutboundMessage) if err != nil { return nil, fmt.Errorf("load XST account: %w", err) } - if !strings.EqualFold(strings.TrimSpace(value(operator.Text)), strings.TrimSpace(account.Username)) { + loginName := xstAccountLoginName(account) + if !strings.EqualFold(strings.TrimSpace(value(operator.Text)), loginName) { + var permissionKnown, canJoin bool + if err := o.sessions.WithSession(ctx, account.ID, func(session swt.Session) error { + if session.Purview != nil { + permissionKnown = true + canJoin = session.AllowsJoiningOtherOperatorDialogue() + } + return nil + }); err != nil { + return nil, fmt.Errorf("load XST operator permissions: %w", err) + } + if permissionKnown && !canJoin { + // SWT can send a normal visitor reply even when this operator cannot join + // another operator's XST dialogue. XST is an optional mirror in that case. + return nil, nil + } + if strings.TrimSpace(value(state)) == "5" && message.ID > 0 && !message.OccurredAt.IsZero() && + mapping.GochatConversationID != nil && strings.TrimSpace(value(mapping.SwtAssigneeName)) != "" { + _, _, err := o.store.EnqueueTransferConversation(ctx, store.OutboundOperationInput{ + AccountID: account.ID, SWTSessionID: message.SwtSid, + EventID: fmt.Sprintf("auto-transfer:%d:%d", message.ID, operator.SeqID), + Operation: "transfer_conversation", OccurredAt: message.OccurredAt, + }, *mapping.GochatConversationID) + if err != nil { + return nil, fmt.Errorf("queue XST ownership transfer: %w", err) + } + return nil, fmt.Errorf("%w: assigned=%q current=%q", errXSTOwnershipPending, strings.TrimSpace(value(operator.Text)), loginName) + } return nil, errors.New("XST conversation is not assigned to the current operator") } - syncKey := fmt.Sprintf("%s\x1a%s\x1a%d", value(mapping.XstToken), account.Username, operator.SeqID) + syncKey := fmt.Sprintf("%s\x1a%s\x1a%d", value(mapping.XstToken), loginName, operator.SeqID) route := &xstDeliveryRoute{ XSTRoute: swt.XSTRoute{ SID: message.SwtSid, CID: value(mapping.XstCid), Token: value(mapping.XstToken), State: value(state), @@ -610,6 +648,36 @@ func (o *Outbound) processStatus(ctx context.Context) (bool, error) { return true, err } +func (o *Outbound) resolveContactNameCID(ctx context.Context, operation *dbgen.OutboundOperation) (string, error) { + var envelope struct { + Data struct { + CID string `json:"cid"` + Contact struct { + Name string `json:"name"` + } `json:"contact"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(operation.Payload), &envelope); err != nil || strings.TrimSpace(envelope.Data.Contact.Name) == "" { + return "", &swt.Error{Operation: operation.Operation, Code: "invalid_payload", Err: errors.New("cname is required")} + } + cid := strings.TrimSpace(envelope.Data.CID) + if cid == "" { + mapping, err := o.store.Reader().GetConversationMap(ctx, dbgen.GetConversationMapParams{AccountID: operation.AccountID, SwtSid: operation.SwtSid}) + if err == nil && mapping.XstCid != nil { + cid = strings.TrimSpace(*mapping.XstCid) + } else if err != nil && !errors.Is(err, sql.ErrNoRows) { + return "", err + } + } + if cid != "" { + return cid, nil + } + if !time.Now().UTC().Before(operation.OccurredAt.Add(contactNameCIDWaitTimeout)) { + return "", &swt.Error{Operation: operation.Operation, Code: "cid_wait_timeout", Err: errors.New("cid was not learned within 24 hours")} + } + return "", &swt.Error{Operation: operation.Operation, Code: "cid_unavailable", Retryable: true, Err: errors.New("waiting for cid mapping")} +} + func (o *Outbound) processOperation(ctx context.Context) (bool, error) { operation, err := o.store.Writer().ClaimOutboundOperation(ctx) if errors.Is(err, sql.ErrNoRows) { @@ -619,58 +687,77 @@ func (o *Outbound) processOperation(ctx context.Context) (bool, error) { return false, err } classificationSender, classificationSupported := o.sender.(ClassificationSender) - err = o.sessions.WithSession(ctx, operation.AccountID, func(session swt.Session) error { - switch operation.Operation { - case "set_chat_kind": - if !classificationSupported { - return &swt.Error{Operation: operation.Operation, Code: "unsupported_operation", Err: errors.New("classification sender is unavailable")} - } - payload, err := decodeClassificationOperation(operation.Payload) - if err != nil || strings.TrimSpace(payload.ChatKindID) == "" { - return &swt.Error{Operation: operation.Operation, Code: "invalid_payload", Err: errors.New("chat_kind_id is required")} - } - return classificationSender.SetConversationKind(ctx, session, operation.SwtSid, payload.ChatKindID) - case "set_customer_color": - if !classificationSupported { - return &swt.Error{Operation: operation.Operation, Code: "unsupported_operation", Err: errors.New("classification sender is unavailable")} - } - payload, err := decodeClassificationOperation(operation.Payload) - if err != nil || strings.TrimSpace(payload.CustomerColorID) == "" || strings.TrimSpace(payload.CID) == "" { - return &swt.Error{Operation: operation.Operation, Code: "invalid_payload", Err: errors.New("customer_color_id and cid are required")} - } - return classificationSender.ChangeCustomerColor(ctx, session, operation.SwtSid, payload.CustomerColorID, payload.CustomerColorName, payload.CID) - case "end_conversation": - return o.sender.EndConversation(ctx, session, operation.SwtSid) - case "accept_transfer": - return o.sender.AcceptTransfer(ctx, session, operation.SwtSid) - case "transfer_conversation": - var payload struct { - OtherLoginName string `json:"other_login_name"` - } - if err := json.Unmarshal([]byte(operation.Payload), &payload); err != nil || strings.TrimSpace(payload.OtherLoginName) == "" { - return &swt.Error{Operation: operation.Operation, Code: "invalid_payload", Err: errors.New("other_login_name is required")} - } - if !session.AllowsJoiningOtherOperatorDialogue() { - return &swt.Error{Operation: operation.Operation, Code: "permission_denied", Err: errors.New("operator may not join another operator's dialogue")} - } - return o.sender.TransferConversation(ctx, session, operation.SwtSid, payload.OtherLoginName) - case "change_contact_name": - var envelope struct { - Data struct { - CID string `json:"cid"` - Contact struct { - Name string `json:"name"` - } `json:"contact"` - } `json:"data"` - } - if err := json.Unmarshal([]byte(operation.Payload), &envelope); err != nil || envelope.Data.CID == "" || envelope.Data.Contact.Name == "" { - return &swt.Error{Operation: operation.Operation, Code: "invalid_payload", Err: errors.New("cid and cname are required")} - } - return o.sender.ChangeContactName(ctx, session, operation.SwtSid, envelope.Data.CID, envelope.Data.Contact.Name) - default: - return fmt.Errorf("unsupported outbound operation %q", operation.Operation) + var contactNameCID string + if operation.Operation == "change_contact_name" { + contactNameCID, err = o.resolveContactNameCID(ctx, operation) + if err == nil { + err = o.sessions.WithSession(ctx, operation.AccountID, func(session swt.Session) error { + var envelope struct { + Data struct { + Contact struct { + Name string `json:"name"` + } `json:"contact"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(operation.Payload), &envelope); err != nil || strings.TrimSpace(envelope.Data.Contact.Name) == "" { + return &swt.Error{Operation: operation.Operation, Code: "invalid_payload", Err: errors.New("cname is required")} + } + return o.sender.ChangeContactName(ctx, session, operation.SwtSid, contactNameCID, strings.TrimSpace(envelope.Data.Contact.Name)) + }) } - }) + } else { + err = o.sessions.WithSession(ctx, operation.AccountID, func(session swt.Session) error { + switch operation.Operation { + case "set_chat_kind": + if !classificationSupported { + return &swt.Error{Operation: operation.Operation, Code: "unsupported_operation", Err: errors.New("classification sender is unavailable")} + } + payload, err := decodeClassificationOperation(operation.Payload) + if err != nil || strings.TrimSpace(payload.ChatKindID) == "" { + return &swt.Error{Operation: operation.Operation, Code: "invalid_payload", Err: errors.New("chat_kind_id is required")} + } + return classificationSender.SetConversationKind(ctx, session, operation.SwtSid, payload.ChatKindID) + case "set_customer_color": + if !classificationSupported { + return &swt.Error{Operation: operation.Operation, Code: "unsupported_operation", Err: errors.New("classification sender is unavailable")} + } + payload, err := decodeClassificationOperation(operation.Payload) + if err != nil || strings.TrimSpace(payload.CustomerColorID) == "" || strings.TrimSpace(payload.CID) == "" { + return &swt.Error{Operation: operation.Operation, Code: "invalid_payload", Err: errors.New("customer_color_id and cid are required")} + } + return classificationSender.ChangeCustomerColor(ctx, session, operation.SwtSid, payload.CustomerColorID, payload.CustomerColorName, payload.CID) + case "end_conversation": + return o.sender.EndConversation(ctx, session, operation.SwtSid) + case "accept_transfer": + return o.sender.AcceptTransfer(ctx, session, operation.SwtSid) + case "transfer_conversation": + var payload struct { + OtherLoginName string `json:"other_login_name"` + } + if err := json.Unmarshal([]byte(operation.Payload), &payload); err != nil || strings.TrimSpace(payload.OtherLoginName) == "" { + return &swt.Error{Operation: operation.Operation, Code: "invalid_payload", Err: errors.New("other_login_name is required")} + } + if !session.AllowsJoiningOtherOperatorDialogue() { + return &swt.Error{Operation: operation.Operation, Code: "permission_denied", Err: errors.New("operator may not join another operator's dialogue")} + } + return o.sender.TransferConversation(ctx, session, operation.SwtSid, payload.OtherLoginName) + case "change_contact_name": + var envelope struct { + Data struct { + Contact struct { + Name string `json:"name"` + } `json:"contact"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(operation.Payload), &envelope); err != nil || strings.TrimSpace(envelope.Data.Contact.Name) == "" { + return &swt.Error{Operation: operation.Operation, Code: "invalid_payload", Err: errors.New("cname is required")} + } + return o.sender.ChangeContactName(ctx, session, operation.SwtSid, contactNameCID, strings.TrimSpace(envelope.Data.Contact.Name)) + default: + return fmt.Errorf("unsupported outbound operation %q", operation.Operation) + } + }) + } if err == nil { return true, o.store.Writer().CompleteOutboundOperation(ctx, operation.ID) } @@ -686,6 +773,9 @@ func (o *Outbound) processOperation(ctx context.Context) (bool, error) { _ = o.sessions.InvalidateSession(ctx, operation.AccountID) return true, o.retryOperation(operation, protocolErr.Error()) } + if protocolErr.Code == "cid_unavailable" { + return true, o.retryContactNameCID(operation, protocolErr.Error()) + } if protocolErr.Retryable && operation.Attempts < 10 { return true, o.retryOperation(operation, protocolErr.Error()) } @@ -725,14 +815,32 @@ func (o *Outbound) processOperationResult(ctx context.Context) (bool, error) { if err != nil { return false, err } - if err := o.reportClassificationStatus(ctx, operation); err != nil { - detail := "classification result callback failed: " + err.Error() - if operation.ResultSyncAttempts < 10 { - return true, o.retryOperationResult(operation, detail) - } - return true, o.failOperationResult(operation, detail) + var reportErr error + switch operation.Operation { + case "set_chat_kind", "set_customer_color": + reportErr = o.reportClassificationStatus(ctx, operation) + case "change_contact_name": + reportErr = o.reportContactNameStatus(ctx, operation) + default: + reportErr = fmt.Errorf("unsupported result operation %q", operation.Operation) } - return true, o.store.Writer().CompleteOutboundOperationResult(ctx, operation.ID) + if reportErr != nil { + detail := "operation result callback failed: " + reportErr.Error() + status := value(operation.ResultStatus) + if operation.ResultSyncAttempts < 10 { + err := o.retryOperationResult(operation, detail) + o.metrics.OperationResultSync("retry", status) + return true, err + } + err = o.failOperationResult(operation, detail) + o.metrics.OperationResultSync("failed", status) + return true, err + } + err = o.store.Writer().CompleteOutboundOperationResult(ctx, operation.ID) + if err == nil { + o.metrics.OperationResultSync("success", value(operation.ResultStatus)) + } + return true, err } func (o *Outbound) reportClassificationStatus(ctx context.Context, operation *dbgen.OutboundOperation) error { @@ -764,8 +872,46 @@ func (o *Outbound) reportClassificationStatus(ctx context.Context, operation *db return reporter.UpdateClassificationStatus(ctx, account.GochatInboxID, data.ConversationID, operation.EventID, operation.Operation, status, data.ChatKindID, data.CustomerColorID, value(operation.ResultErrorCode), value(operation.ResultErrorMessage)) } +func (o *Outbound) reportContactNameStatus(ctx context.Context, operation *dbgen.OutboundOperation) error { + reporter, ok := o.results.(ContactNameResultClient) + if !ok { + return errors.New("contact name result reporter is unavailable") + } + status := value(operation.ResultStatus) + if status != "succeeded" && status != "failed" && status != "uncertain" { + return errors.New("contact name result status is invalid") + } + var envelope struct { + Data struct { + Contact struct { + ID uint `json:"id"` + Name string `json:"name"` + } `json:"contact"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(operation.Payload), &envelope); err != nil || envelope.Data.Contact.ID == 0 || strings.TrimSpace(envelope.Data.Contact.Name) == "" { + return errors.New("contact name result payload is invalid") + } + account, err := o.store.Reader().GetAccountByID(ctx, operation.AccountID) + if err != nil { + return fmt.Errorf("load GoChat inbox mapping: %w", err) + } + if account.GochatInboxID <= 0 { + return errors.New("GoChat inbox mapping is invalid") + } + return reporter.UpdateContactNameStatus(ctx, account.GochatInboxID, operation.SwtSid, gochat.ContactNameOperationStatus{ + EventID: operation.EventID, Operation: operation.Operation, ContactID: envelope.Data.Contact.ID, + Name: envelope.Data.Contact.Name, Status: status, + ErrorCode: value(operation.ResultErrorCode), ErrorMessage: value(operation.ResultErrorMessage), + }) +} + func (o *Outbound) retry(message *dbgen.OutboundMessage, detail string) error { - next := time.Now().Add(backoff(message.Attempts, 5*time.Minute)) + return o.retryAfter(message, detail, backoff(message.Attempts, 5*time.Minute)) +} + +func (o *Outbound) retryAfter(message *dbgen.OutboundMessage, detail string, delay time.Duration) error { + next := time.Now().Add(delay) err := o.store.Writer().RetryOutboundDelivery(context.Background(), dbgen.RetryOutboundDeliveryParams{ NextAttemptAt: &next, LastError: &detail, ID: message.ID, }) @@ -775,6 +921,13 @@ func (o *Outbound) retry(message *dbgen.OutboundMessage, detail string) error { return err } +func xstAccountLoginName(account *dbgen.Account) string { + if account.LoginName != nil && strings.TrimSpace(*account.LoginName) != "" { + return strings.TrimSpace(*account.LoginName) + } + return strings.TrimSpace(account.Username) +} + func (o *Outbound) fail(message *dbgen.OutboundMessage, code, detail string) error { err := o.store.Writer().FailOutboundMessage(context.Background(), dbgen.FailOutboundMessageParams{ ExternalErrorCode: &code, LastError: &detail, ID: message.ID, @@ -822,6 +975,21 @@ func (o *Outbound) retryOperation(operation *dbgen.OutboundOperation, detail str }) } +func (o *Outbound) retryContactNameCID(operation *dbgen.OutboundOperation, detail string) error { + now := time.Now().UTC() + deadline := operation.OccurredAt.Add(contactNameCIDWaitTimeout) + if !now.Before(deadline) { + return o.failOperation(operation, "cid_wait_timeout", "cid was not learned within 24 hours") + } + next := now.Add(backoff(operation.Attempts, 5*time.Minute)) + if next.After(deadline) { + next = deadline + } + return o.store.Writer().DeferContactNameOperation(context.Background(), dbgen.DeferContactNameOperationParams{ + NextAttemptAt: &next, LastError: &detail, ID: operation.ID, + }) +} + func (o *Outbound) failOperation(operation *dbgen.OutboundOperation, code, detail string) error { return o.store.Writer().FailOutboundOperation(context.Background(), dbgen.FailOutboundOperationParams{ LastError: &detail, ResultErrorCode: &code, ResultErrorMessage: &detail, ID: operation.ID, diff --git a/channels/shangwutong/internal/delivery/outbound_test.go b/channels/shangwutong/internal/delivery/outbound_test.go index 9724d7bb..957b6894 100644 --- a/channels/shangwutong/internal/delivery/outbound_test.go +++ b/channels/shangwutong/internal/delivery/outbound_test.go @@ -272,6 +272,23 @@ func TestXSTRouteInvalidatesReceptionSyncWhenRouteIdentityChanges(t *testing.T) } } +func TestXSTRouteSkipsOptionalMirrorWithoutJoinPermission(t *testing.T) { + ctx := context.Background() + database, account := deliveryDatabase(t, ctx) + seedXSTRoute(t, database, account) + if _, err := database.PersistHeartbeat(ctx, account, []swt.HeartbeatEvent{{ + SessionID: "visitor", Kind: 6, Text: "ๅ…ถไป–ๅฎขๆœ", SeqID: 101, + Timestamp: time.Now().Format(time.RFC3339Nano), RawLine: "visitor 6 |ๅ…ถไป–ๅฎขๆœ 101 timestamp", + }}); err != nil { + t.Fatal(err) + } + outbound, _ := NewOutbound(database, deniedJoinSessionStub{}, senderStub{}, &resultRecorder{}, nil, 1) + route, err := outbound.xstRoute(ctx, &dbgen.OutboundMessage{AccountID: account.ID, SwtSid: "visitor"}) + if err != nil || route != nil { + t.Fatalf("route without join permission = %#v, %v", route, err) + } +} + func TestXSTRejectThenEchoRetriesOnlyXST(t *testing.T) { ctx := context.Background() database, account := deliveryDatabase(t, ctx) @@ -684,6 +701,166 @@ func TestClassificationOperationsUseNestedPayloadAndReportWithInboxID(t *testing } } +func TestContactNameOperationResolvesCIDAndReportsResult(t *testing.T) { + ctx := context.Background() + database, account := deliveryDatabase(t, ctx) + cid := "cid-from-inbound" + if _, err := database.Writer().UpsertConversationXSTRoute(ctx, dbgen.UpsertConversationXSTRouteParams{ + AccountID: account.ID, SwtSid: "visitor", GochatContactSourceID: "visitor", XstCid: &cid, + }); err != nil { + t.Fatal(err) + } + operation, _, err := database.EnqueueOutboundOperation(ctx, store.OutboundOperationInput{ + AccountID: account.ID, SWTSessionID: "visitor", EventID: "contact:rename:1", Operation: "change_contact_name", + Payload: `{"data":{"contact":{"id":77,"source_id":"visitor","name":"New name"}}}`, OccurredAt: time.Now(), + }) + if err != nil { + t.Fatal(err) + } + sender := &operationSender{} + results := &classificationResultRecorder{} + worker, err := NewOutbound(database, sessionStub{}, sender, results, nil, 1) + if err != nil { + t.Fatal(err) + } + if worked, err := worker.processOperation(ctx); err != nil || !worked { + t.Fatalf("operation = %v, %v", worked, err) + } + loaded, err := database.Reader().GetOutboundOperationByEventID(ctx, operation.EventID) + if err != nil || loaded.DeliveryStatus != "delivered" || loaded.ResultSyncStatus != "pending" || sender.contactCalls != 1 || sender.contactCID != cid || sender.contactName != "New name" { + t.Fatalf("loaded=%#v sender=%#v err=%v", loaded, sender, err) + } + if worked, err := worker.processOperationResult(ctx); err != nil || !worked { + t.Fatalf("result = %v, %v", worked, err) + } + if len(results.contactCalls) != 1 || results.contactCalls[0].inboxID != account.GochatInboxID || results.contactCalls[0].sourceID != "visitor" || results.contactCalls[0].result.Status != "succeeded" { + t.Fatalf("contact result = %#v", results.contactCalls) + } +} + +func TestContactNameOperationWithoutCIDStaysPending(t *testing.T) { + ctx := context.Background() + database, account := deliveryDatabase(t, ctx) + operation, _, err := database.EnqueueOutboundOperation(ctx, store.OutboundOperationInput{ + AccountID: account.ID, SWTSessionID: "visitor", EventID: "contact:rename:missing-cid", Operation: "change_contact_name", + Payload: `{"data":{"contact":{"id":77,"source_id":"visitor","name":"New name"}}}`, OccurredAt: time.Now(), + }) + if err != nil { + t.Fatal(err) + } + sender := &operationSender{} + worker, err := NewOutbound(database, sessionStub{}, sender, &resultRecorder{}, nil, 1) + if err != nil { + t.Fatal(err) + } + if worked, err := worker.processOperation(ctx); err != nil || !worked { + t.Fatalf("operation = %v, %v", worked, err) + } + loaded, err := database.Reader().GetOutboundOperationByEventID(ctx, operation.EventID) + if err != nil || loaded.DeliveryStatus != "pending" || sender.contactCalls != 0 || loaded.LastError == nil || !strings.Contains(*loaded.LastError, "waiting for cid") { + t.Fatalf("loaded=%#v sender=%#v err=%v", loaded, sender, err) + } +} + +func TestContactNameOperationCIDWaitIsWokenWithoutGenericRetryLimit(t *testing.T) { + ctx := context.Background() + database, account := deliveryDatabase(t, ctx) + operation, _, err := database.EnqueueOutboundOperation(ctx, store.OutboundOperationInput{ + AccountID: account.ID, SWTSessionID: "visitor", EventID: "contact:rename:cid-wait", Operation: "change_contact_name", + Payload: `{"data":{"contact":{"id":77,"source_id":"visitor","name":"New name"}}}`, OccurredAt: time.Now(), + }) + if err != nil { + t.Fatal(err) + } + sender := &operationSender{} + worker, _ := NewOutbound(database, sessionStub{}, sender, &resultRecorder{}, nil, 1) + claimed, err := database.Writer().ClaimOutboundOperation(ctx) + if err != nil { + t.Fatal(err) + } + claimed.Attempts = 10 + if err := worker.retryContactNameCID(claimed, "change_contact_name: cid_unavailable: waiting for cid mapping"); err != nil { + t.Fatal(err) + } + waiting, err := database.Reader().GetOutboundOperationByEventID(ctx, operation.EventID) + if err != nil || waiting.DeliveryStatus != "pending" || waiting.LastError == nil || !strings.Contains(*waiting.LastError, "cid_unavailable") { + t.Fatalf("waiting operation = %#v, %v", waiting, err) + } + cid := "cid-after-wait" + if _, err := database.Writer().UpsertConversationXSTRoute(ctx, dbgen.UpsertConversationXSTRouteParams{ + AccountID: account.ID, SwtSid: "visitor", GochatContactSourceID: "visitor", XstCid: &cid, + }); err != nil { + t.Fatal(err) + } + if _, err := database.Writer().WakeContactNameOperations(ctx, dbgen.WakeContactNameOperationsParams{AccountID: account.ID, SwtSid: "visitor"}); err != nil { + t.Fatal(err) + } + if worked, err := worker.processOperation(ctx); err != nil || !worked { + t.Fatalf("woken operation = %v, %v", worked, err) + } + loaded, err := database.Reader().GetOutboundOperationByEventID(ctx, operation.EventID) + if err != nil || loaded.DeliveryStatus != "delivered" || sender.contactCalls != 1 || sender.contactCID != cid { + t.Fatalf("loaded=%#v sender=%#v err=%v", loaded, sender, err) + } +} + +func TestContactNameOperationCIDWaitExpiresWithFailedResult(t *testing.T) { + ctx := context.Background() + database, account := deliveryDatabase(t, ctx) + operation, _, err := database.EnqueueOutboundOperation(ctx, store.OutboundOperationInput{ + AccountID: account.ID, SWTSessionID: "visitor", EventID: "contact:rename:cid-timeout", Operation: "change_contact_name", + Payload: `{"data":{"contact":{"id":77,"source_id":"visitor","name":"New name"}}}`, OccurredAt: time.Now().Add(-25 * time.Hour), + }) + if err != nil { + t.Fatal(err) + } + results := &classificationResultRecorder{} + worker, _ := NewOutbound(database, sessionStub{}, &operationSender{}, results, nil, 1) + if worked, err := worker.processOperation(ctx); err != nil || !worked { + t.Fatalf("expired operation = %v, %v", worked, err) + } + loaded, err := database.Reader().GetOutboundOperationByEventID(ctx, operation.EventID) + if err != nil || loaded.DeliveryStatus != "failed" || loaded.ResultSyncStatus != "pending" || value(loaded.ResultStatus) != "failed" || value(loaded.ResultErrorCode) != "cid_wait_timeout" { + t.Fatalf("expired operation = %#v, %v", loaded, err) + } + if worked, err := worker.processOperationResult(ctx); err != nil || !worked { + t.Fatalf("expired result = %v, %v", worked, err) + } + if len(results.contactCalls) != 1 || results.contactCalls[0].result.Status != "failed" || results.contactCalls[0].result.ErrorCode != "cid_wait_timeout" { + t.Fatalf("expired callback = %#v", results.contactCalls) + } +} + +func TestContactNameOperationRestartReportsUncertainResult(t *testing.T) { + ctx := context.Background() + database, account := deliveryDatabase(t, ctx) + operation, _, err := database.EnqueueOutboundOperation(ctx, store.OutboundOperationInput{ + AccountID: account.ID, SWTSessionID: "visitor", EventID: "contact:rename:restart", Operation: "change_contact_name", + Payload: `{"data":{"contact":{"id":77,"source_id":"visitor","name":"New name"}}}`, OccurredAt: time.Now(), + }) + if err != nil { + t.Fatal(err) + } + if _, err := database.Writer().ClaimOutboundOperation(ctx); err != nil { + t.Fatal(err) + } + if _, err := database.Writer().RecoverOutboundOperationsAsUncertain(ctx); err != nil { + t.Fatal(err) + } + results := &classificationResultRecorder{} + worker, _ := NewOutbound(database, sessionStub{}, &operationSender{}, results, nil, 1) + loaded, err := database.Reader().GetOutboundOperationByEventID(ctx, operation.EventID) + if err != nil || loaded.DeliveryStatus != "uncertain" || value(loaded.ResultStatus) != "uncertain" || value(loaded.ResultErrorCode) != "connector_restart_uncertain" { + t.Fatalf("recovered operation = %#v, %v", loaded, err) + } + if worked, err := worker.processOperationResult(ctx); err != nil || !worked { + t.Fatalf("uncertain result = %v, %v", worked, err) + } + if len(results.contactCalls) != 1 || results.contactCalls[0].result.Status != "uncertain" || results.contactCalls[0].result.ErrorCode != "connector_restart_uncertain" { + t.Fatalf("uncertain callback = %#v", results.contactCalls) + } +} + func TestClassificationResultRetryDoesNotRepeatSWTOperation(t *testing.T) { ctx := context.Background() database, account := deliveryDatabase(t, ctx) @@ -1009,6 +1186,13 @@ func (allowedSessionStub) WithSession(ctx context.Context, _ int64, operation fu return operation(swt.Session{BaseURL: "http://example.test/", SiteID: "site", LoginName: "ๅฃ่…”ๅฎขๆœ2", MAToken: "token", Purview: &purview}) } +type deniedJoinSessionStub struct{ sessionStub } + +func (deniedJoinSessionStub) WithSession(ctx context.Context, _ int64, operation func(swt.Session) error) error { + purview := uint64(1 << 23) + return operation(swt.Session{BaseURL: "http://example.test/", SiteID: "site", LoginName: "ๅฃ่…”ๅฎขๆœ2", MAToken: "token", Purview: &purview}) +} + type sessionRecorder struct{ invalidations int } func (*sessionRecorder) WithSession(ctx context.Context, _ int64, operation func(swt.Session) error) error { @@ -1081,6 +1265,10 @@ type operationSender struct { colorCalls int colorID string colorCID string + contactCalls int + contactSID string + contactCID string + contactName string err error } @@ -1113,8 +1301,10 @@ func (s *operationSender) EndConversation(_ context.Context, _ swt.Session, sid s.sid = sid return nil } -func (*operationSender) ChangeContactName(context.Context, swt.Session, string, string, string) error { - return nil +func (s *operationSender) ChangeContactName(_ context.Context, _ swt.Session, sid, cid, name string) error { + s.contactCalls++ + s.contactSID, s.contactCID, s.contactName = sid, cid, name + return s.err } func (s *operationSender) AcceptTransfer(_ context.Context, _ swt.Session, sid string) error { @@ -1219,10 +1409,17 @@ type classificationResultCall struct { errorCode string } +type contactNameResultCall struct { + inboxID int64 + sourceID string + result gochat.ContactNameOperationStatus +} + type classificationResultRecorder struct { resultRecorder - calls []classificationResultCall - err error + calls []classificationResultCall + contactCalls []contactNameResultCall + err error } func (r *classificationResultRecorder) UpdateClassificationStatus(_ context.Context, inboxID int64, conversationID uint, eventID, operation, status, _, _, errorCode, _ string) error { @@ -1236,4 +1433,12 @@ func (r *classificationResultRecorder) UpdateClassificationStatus(_ context.Cont return nil } +func (r *classificationResultRecorder) UpdateContactNameStatus(_ context.Context, inboxID int64, sourceID string, result gochat.ContactNameOperationStatus) error { + if r.err != nil { + return r.err + } + r.contactCalls = append(r.contactCalls, contactNameResultCall{inboxID: inboxID, sourceID: sourceID, result: result}) + return nil +} + func stringPointer(value string) *string { return &value } diff --git a/channels/shangwutong/internal/gochat/contact_status.go b/channels/shangwutong/internal/gochat/contact_status.go new file mode 100644 index 00000000..0cb4f8f9 --- /dev/null +++ b/channels/shangwutong/internal/gochat/contact_status.go @@ -0,0 +1,32 @@ +package gochat + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strconv" +) + +// ContactNameOperationStatus is the durable result of a remote contact rename. +// It is deliberately separate from GoChat's local contact notes. +type ContactNameOperationStatus struct { + EventID string `json:"event_id"` + Operation string `json:"operation"` + ContactID uint `json:"contact_id"` + Name string `json:"name"` + Status string `json:"status"` + ErrorCode string `json:"error_code,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` +} + +func (c *Client) UpdateContactNameStatus(ctx context.Context, inboxID int64, sourceID string, result ContactNameOperationStatus) error { + if inboxID <= 0 || sourceID == "" || result.EventID == "" || result.Operation != "change_contact_name" { + return fmt.Errorf("inbox id, source id, event id and change_contact_name operation are required") + } + if result.Status != "succeeded" && result.Status != "failed" && result.Status != "uncertain" { + return fmt.Errorf("invalid contact operation status %q", result.Status) + } + path := "/api/v1/connector/shangwutong/inboxes/" + strconv.FormatInt(inboxID, 10) + "/contacts/" + url.PathEscape(sourceID) + "/status" + return c.doJSON(ctx, http.MethodPut, path, result, nil, fmt.Sprintf("swt-contact-operation:%d:%s", inboxID, result.EventID)) +} diff --git a/channels/shangwutong/internal/gochat/messaging.go b/channels/shangwutong/internal/gochat/messaging.go index f1b372c4..b2ec3fda 100644 --- a/channels/shangwutong/internal/gochat/messaging.go +++ b/channels/shangwutong/internal/gochat/messaging.go @@ -21,6 +21,8 @@ import ( "time" ) +const ContactUpdateOriginSWTConnector = "shangwutong.connector" + type ContactRequest struct { SourceID string `json:"source_id"` Identifier string `json:"identifier"` @@ -31,6 +33,8 @@ type ContactRequest struct { AvatarURL string `json:"avatar_url,omitempty"` CustomAttributes map[string]any `json:"custom_attributes,omitempty"` AdditionalAttributes map[string]any `json:"additional_attributes,omitempty"` + Origin string `json:"-"` + OriginEventID string `json:"-"` } func (c *Client) UpdateContactChannelMetadata(ctx context.Context, inboxID int64, sourceID, cid string) error { diff --git a/channels/shangwutong/internal/gochat/messaging_test.go b/channels/shangwutong/internal/gochat/messaging_test.go index c0c1a357..1fa45a75 100644 --- a/channels/shangwutong/internal/gochat/messaging_test.go +++ b/channels/shangwutong/internal/gochat/messaging_test.go @@ -23,6 +23,12 @@ func TestMessagingClientEnsuresIdentityAndImportsWithStableKey(t *testing.T) { if payload.IdentifierHash != contactIdentifierHash("hmac-token", "visitor") { t.Fatalf("identifier_hash = %q", payload.IdentifierHash) } + if origin := request.Header.Get("X-GoChat-Event-Origin"); origin != "" { + t.Fatalf("untrusted origin header was sent: %q", origin) + } + if eventID := request.Header.Get("X-GoChat-Event-ID"); eventID != "" { + t.Fatalf("untrusted event ID header was sent: %q", eventID) + } _ = json.NewEncoder(response).Encode(Contact{ID: 3, SourceID: "visitor", Name: "่ฎฟๅฎข"}) case "/public/api/v1/inboxes/inbox-token/contacts/visitor/conversations": if request.Method == http.MethodGet { @@ -47,7 +53,9 @@ func TestMessagingClientEnsuresIdentityAndImportsWithStableKey(t *testing.T) { if err != nil { t.Fatal(err) } - contact, err := client.EnsureContact(context.Background(), "inbox-token", "hmac-token", ContactRequest{SourceID: "visitor", Name: "่ฎฟๅฎข"}) + contact, err := client.EnsureContact(context.Background(), "inbox-token", "hmac-token", ContactRequest{ + SourceID: "visitor", Name: "่ฎฟๅฎข", Origin: ContactUpdateOriginSWTConnector, OriginEventID: "swt-inbound:42", + }) if err != nil || contact.ID != 3 { t.Fatalf("contact=%+v err=%v", contact, err) } diff --git a/channels/shangwutong/internal/httpapi/server.go b/channels/shangwutong/internal/httpapi/server.go index 66647596..9216ca28 100644 --- a/channels/shangwutong/internal/httpapi/server.go +++ b/channels/shangwutong/internal/httpapi/server.go @@ -98,7 +98,8 @@ func (s *Server) RefreshMetrics(ctx context.Context) error { s.metricMu.Lock() s.metricData = observability.MetricSnapshot{ Accounts: snapshot.Accounts, InboundQueue: snapshot.InboundQueue, - OutboundQueue: snapshot.OutboundQueue, StatusSyncQueue: snapshot.StatusSyncQueue, + OutboundQueue: snapshot.OutboundQueue, ClassificationSyncQueue: snapshot.ClassificationSyncQueue, + StatusSyncQueue: snapshot.StatusSyncQueue, } s.metricMu.Unlock() return nil @@ -408,7 +409,7 @@ func (s *Server) dispatchVerifiedWebhook(c fiber.Ctx, envelope gochat.WebhookEnv func (s *Server) acceptContactUpdate(c fiber.Ctx, envelope gochat.WebhookEnvelope, body []byte, deliveryID string, local *dbgen.Account) error { var data gochat.ContactUpdatedWebhookData - if err := json.Unmarshal(envelope.Data, &data); err != nil || data.Contact.ID <= 0 || strings.TrimSpace(data.Contact.SourceID) == "" || strings.TrimSpace(data.CID) == "" || strings.TrimSpace(data.Contact.Name) == "" { + if err := json.Unmarshal(envelope.Data, &data); err != nil || data.Contact.ID <= 0 || strings.TrimSpace(data.Contact.SourceID) == "" || strings.TrimSpace(data.Contact.Name) == "" { return s.writeError(c, http.StatusUnprocessableEntity, "invalid_contact_update", "contact update payload is invalid", false) } queued, duplicate, err := s.store.EnqueueOutboundOperation(c.Context(), store.OutboundOperationInput{ diff --git a/channels/shangwutong/internal/observability/metrics.go b/channels/shangwutong/internal/observability/metrics.go index b8949a98..82a9665c 100644 --- a/channels/shangwutong/internal/observability/metrics.go +++ b/channels/shangwutong/internal/observability/metrics.go @@ -13,11 +13,12 @@ import ( var durationBuckets = [...]float64{0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30} type MetricSnapshot struct { - Supervisors int - Accounts map[string]int64 - InboundQueue map[string]int64 - OutboundQueue map[string]int64 - StatusSyncQueue int64 + Supervisors int + Accounts map[string]int64 + InboundQueue map[string]int64 + OutboundQueue map[string]int64 + ClassificationSyncQueue map[string]int64 + StatusSyncQueue int64 } type histogramValue struct { @@ -63,6 +64,19 @@ func (m *Metrics) StatusSync(result, status string) { )) } +func (m *Metrics) OperationResultSync(result, status string) { + m.inc("swt_connector_operation_result_sync_total", labels( + "result", bounded(result, "success", "retry", "failed"), + "status", bounded(status, "succeeded", "failed", "uncertain", "unknown"), + )) +} + +func (m *Metrics) ClassificationSync(result string) { + m.inc("swt_connector_classification_sync_total", labels( + "result", bounded(result, "success", "failed", "retry"), + )) +} + func (m *Metrics) Mapping(kind int64, strategy, result string) { m.inc("swt_connector_event_mapping_total", labels( "kind", metricKind(kind), @@ -121,6 +135,10 @@ func (m *Metrics) Render(snapshot MetricSnapshot) []byte { } output.WriteString("# HELP swt_connector_status_sync_queue_depth Message results waiting for GoChat acknowledgement.\n# TYPE swt_connector_status_sync_queue_depth gauge\n") writeGauge("swt_connector_status_sync_queue_depth", "", snapshot.StatusSyncQueue) + output.WriteString("# HELP swt_connector_classification_sync_queue_depth Classification catalog sync results by state.\n# TYPE swt_connector_classification_sync_queue_depth gauge\n") + for _, key := range sortedKeys(snapshot.ClassificationSyncQueue) { + writeGauge("swt_connector_classification_sync_queue_depth", labels("status", safeLabel(key)), snapshot.ClassificationSyncQueue[key]) + } output.WriteString("# HELP swt_connector_supervisors Running account supervisors.\n# TYPE swt_connector_supervisors gauge\n") writeGauge("swt_connector_supervisors", "", int64(snapshot.Supervisors)) diff --git a/channels/shangwutong/internal/observability/metrics_test.go b/channels/shangwutong/internal/observability/metrics_test.go index d7f579b0..7e2c2648 100644 --- a/channels/shangwutong/internal/observability/metrics_test.go +++ b/channels/shangwutong/internal/observability/metrics_test.go @@ -15,6 +15,8 @@ func TestMetricsRenderProductionSeriesWithoutHighCardinalityLabels(t *testing.T) metrics.Delivery("inbound", "delivered") metrics.Delivery("outbound", "accepted") metrics.StatusSync("success", "sent") + metrics.OperationResultSync("retry", "uncertain") + metrics.ClassificationSync("success") metrics.Mapping(2, "native_message", "delivered") metrics.Mapping(24, "contact_attributes", "delivered") metrics.Unknown(999) @@ -22,11 +24,12 @@ func TestMetricsRenderProductionSeriesWithoutHighCardinalityLabels(t *testing.T) metrics.ContractError("gochat_to_connector", "invalid_signature") metrics.SQLiteWrite(3 * time.Millisecond) payload := string(metrics.Render(MetricSnapshot{ - Supervisors: 1, - Accounts: map[string]int64{"connected\x00online": 1}, - InboundQueue: map[string]int64{"pending": 2}, - OutboundQueue: map[string]int64{"uncertain": 1}, - StatusSyncQueue: 1, + Supervisors: 1, + Accounts: map[string]int64{"connected\x00online": 1}, + InboundQueue: map[string]int64{"pending": 2}, + OutboundQueue: map[string]int64{"uncertain": 1}, + ClassificationSyncQueue: map[string]int64{"failed": 1}, + StatusSyncQueue: 1, })) for _, name := range []string{ "swt_connector_accounts", "swt_connector_supervisors", "swt_connector_heartbeat_total", @@ -36,7 +39,8 @@ func TestMetricsRenderProductionSeriesWithoutHighCardinalityLabels(t *testing.T) "swt_connector_status_sync_total", "swt_connector_status_sync_queue_depth", "swt_connector_event_mapping_total", "swt_connector_unknown_event_total", "swt_connector_unmapped_retraction_total", "swt_connector_contract_error_total", - "swt_connector_sqlite_write_duration_seconds", + "swt_connector_sqlite_write_duration_seconds", "swt_connector_operation_result_sync_total", + "swt_connector_classification_sync_total", "swt_connector_classification_sync_queue_depth", } { if !strings.Contains(payload, name) { t.Fatalf("metric %s missing from:\n%s", name, payload) diff --git a/channels/shangwutong/internal/store/outbound.go b/channels/shangwutong/internal/store/outbound.go index f80b76cd..653ab94a 100644 --- a/channels/shangwutong/internal/store/outbound.go +++ b/channels/shangwutong/internal/store/outbound.go @@ -193,13 +193,31 @@ func (s *Store) EnqueueAcceptTransfer(ctx context.Context, input OutboundOperati if err != nil { return err } - state, err := queries.GetLatestConversationState(ctx, dbgen.GetLatestConversationStateParams{AccountID: input.AccountID, SwtSid: input.SWTSessionID}) + var state *string + if strings.HasPrefix(input.EventID, "auto-accept-transfer:") { + state, err = queries.GetLatestConversationStateIncludingClaimed(ctx, dbgen.GetLatestConversationStateIncludingClaimedParams{AccountID: input.AccountID, SwtSid: input.SWTSessionID}) + } else { + state, err = queries.GetLatestConversationState(ctx, dbgen.GetLatestConversationStateParams{AccountID: input.AccountID, SwtSid: input.SWTSessionID}) + } if errors.Is(err, sql.ErrNoRows) || err == nil && (state == nil || strings.TrimSpace(*state) != "7") { return ErrOutboundSessionState } if err != nil { return err } + active, activeErr := queries.GetAcceptTransferOperationSince(ctx, dbgen.GetAcceptTransferOperationSinceParams{AccountID: input.AccountID, SwtSid: input.SWTSessionID, OccurredAt: input.OccurredAt}) + if activeErr == nil { + var activePayload struct { + ConversationID int64 `json:"conversation_id"` + } + if json.Unmarshal([]byte(active.Payload), &activePayload) == nil && (activePayload.ConversationID == 0 || activePayload.ConversationID == conversationID) { + queued, duplicate = active, true + return nil + } + } + if activeErr != nil && !errors.Is(activeErr, sql.ErrNoRows) { + return activeErr + } queued, err = queries.InsertOutboundOperation(ctx, dbgen.InsertOutboundOperationParams{ AccountID: input.AccountID, SwtSid: input.SWTSessionID, EventID: input.EventID, Operation: input.Operation, Payload: input.Payload, OccurredAt: input.OccurredAt, diff --git a/channels/shangwutong/internal/store/store.go b/channels/shangwutong/internal/store/store.go index 746f2023..ec9d6988 100644 --- a/channels/shangwutong/internal/store/store.go +++ b/channels/shangwutong/internal/store/store.go @@ -132,15 +132,17 @@ func (s *Store) ReadyCheck(ctx context.Context) error { } type MetricSnapshot struct { - Accounts map[string]int64 - InboundQueue map[string]int64 - OutboundQueue map[string]int64 - StatusSyncQueue int64 + Accounts map[string]int64 + InboundQueue map[string]int64 + OutboundQueue map[string]int64 + ClassificationSyncQueue map[string]int64 + StatusSyncQueue int64 } func (s *Store) MetricSnapshot(ctx context.Context) (MetricSnapshot, error) { snapshot := MetricSnapshot{ Accounts: make(map[string]int64), InboundQueue: make(map[string]int64), OutboundQueue: make(map[string]int64), + ClassificationSyncQueue: make(map[string]int64), } accounts, err := s.readerQueries.ListAccountMetricCounts(ctx) if err != nil { @@ -164,7 +166,17 @@ func (s *Store) MetricSnapshot(ctx context.Context) (MetricSnapshot, error) { snapshot.OutboundQueue[row.DeliveryStatus] = row.Count } snapshot.StatusSyncQueue, err = s.readerQueries.CountStatusSyncQueue(ctx) - return snapshot, err + if err != nil { + return snapshot, err + } + classificationSync, err := s.readerQueries.ListClassificationSyncQueueMetricCounts(ctx) + if err != nil { + return snapshot, err + } + for _, row := range classificationSync { + snapshot.ClassificationSyncQueue[row.Status] = row.Count + } + return snapshot, nil } func (s *Store) Close() error { diff --git a/channels/shangwutong/internal/store/store_test.go b/channels/shangwutong/internal/store/store_test.go index 54d031e2..b20dc849 100644 --- a/channels/shangwutong/internal/store/store_test.go +++ b/channels/shangwutong/internal/store/store_test.go @@ -102,6 +102,128 @@ func TestMigrationVersionParser(t *testing.T) { } } +func TestClassificationSyncMigrationsRoundTrip(t *testing.T) { + ctx := context.Background() + database, err := Open(ctx, filepath.Join(t.TempDir(), "migrations.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + + account, err := database.Writer().CreateAccount(ctx, dbgen.CreateAccountParams{ + GochatAccountID: 1, GochatInboxID: 2, GochatInboxIdentifier: "id", ConfigVersion: 1, + SessionID: "BYT99917999", Username: "agent", Password: "password", Enabled: 1, + DesiredPresence: "online", GochatHmacToken: "hmac", GochatWebhookSecret: "secret", + }) + if err != nil { + t.Fatal(err) + } + operation, err := database.Writer().InsertOutboundOperation(ctx, dbgen.InsertOutboundOperationParams{ + AccountID: account.ID, SwtSid: "sid", EventID: "conversation:1:status:1", Operation: "end_conversation", + Payload: `{}`, OccurredAt: time.Now(), + }) + if err != nil { + t.Fatal(err) + } + runMigration := func(name string) { + body, readErr := os.ReadFile(filepath.Join("..", "..", "db", "migrations", name)) + if readErr != nil { + t.Fatal(readErr) + } + if _, execErr := database.writer.ExecContext(ctx, string(body)); execErr != nil { + t.Fatalf("migration %s: %v", name, execErr) + } + } + expectMigrationFailure := func(name string) { + body, readErr := os.ReadFile(filepath.Join("..", "..", "db", "migrations", name)) + if readErr != nil { + t.Fatal(readErr) + } + if _, execErr := database.writer.ExecContext(ctx, string(body)); execErr == nil { + t.Fatalf("migration %s unexpectedly discarded data", name) + } + } + hasClassificationTable := func() bool { + var count int + if err := database.writer.QueryRowContext(ctx, "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'classification_sync_results'").Scan(&count); err != nil { + t.Fatal(err) + } + return count == 1 + } + hasUncertainSince := func() bool { + rows, err := database.writer.QueryContext(ctx, "PRAGMA table_info(outbound_operations)") + if err != nil { + t.Fatal(err) + } + defer rows.Close() + for rows.Next() { + var cid, notNull, primaryKey int + var name, columnType string + var defaultValue sql.NullString + if err := rows.Scan(&cid, &name, &columnType, ¬Null, &defaultValue, &primaryKey); err != nil { + t.Fatal(err) + } + if name == "uncertain_since" { + return true + } + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + return false + } + + if !hasClassificationTable() || !hasUncertainSince() { + t.Fatal("latest migrations were not applied") + } + runMigration("011_add_classification_sync_results.down.sql") + if _, err := database.writer.ExecContext(ctx, "DELETE FROM schema_migrations WHERE version = 11"); err != nil { + t.Fatal(err) + } + runMigration("010_add_uncertain_since.down.sql") + if _, err := database.writer.ExecContext(ctx, "DELETE FROM schema_migrations WHERE version = 10"); err != nil { + t.Fatal(err) + } + if hasClassificationTable() || hasUncertainSince() { + t.Fatal("migration down did not remove the new schema") + } + if _, err := database.writer.ExecContext(ctx, "UPDATE outbound_operations SET delivery_status = 'uncertain'"); err != nil { + t.Fatal(err) + } + + runMigration("010_add_uncertain_since.up.sql") + runMigration("011_add_classification_sync_results.up.sql") + if _, err := database.writer.ExecContext(ctx, "INSERT INTO schema_migrations(version, name) VALUES (10, '010_add_uncertain_since.up.sql'), (11, '011_add_classification_sync_results.up.sql')"); err != nil { + t.Fatal(err) + } + if !hasClassificationTable() || !hasUncertainSince() { + t.Fatal("migration up did not restore the new schema") + } + var backfilled int + if err := database.writer.QueryRowContext(ctx, "SELECT COUNT(*) FROM outbound_operations WHERE id = ? AND uncertain_since IS NOT NULL", operation.ID).Scan(&backfilled); err != nil { + t.Fatal(err) + } + if backfilled != 1 { + t.Fatal("uncertain_since was not backfilled") + } + if version, err := database.MigrationVersion(ctx); err != nil || version != 11 { + t.Fatalf("migration version = %d, %v", version, err) + } + if _, err := database.Writer().InsertClassificationSyncResult(ctx, dbgen.InsertClassificationSyncResultParams{ + AccountID: account.ID, InboxID: account.GochatInboxID, EventID: "classification:rollback-guard", + }); err != nil { + t.Fatal(err) + } + expectMigrationFailure("011_add_classification_sync_results.down.sql") + if !hasClassificationTable() { + t.Fatal("classification results were lost during failed rollback") + } + expectMigrationFailure("010_add_uncertain_since.down.sql") + if !hasUncertainSince() { + t.Fatal("uncertain observation evidence was lost during failed rollback") + } +} + func TestDeferredContactMetadataMigrationOnlyRetriesMatchingFailures(t *testing.T) { db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "migration.db")) if err != nil { @@ -484,6 +606,44 @@ func TestRecoverOutboundStatusSyncMakesClaimRetryable(t *testing.T) { } } +func TestRecoverClassificationSyncResultDoesNotConsumeAttempt(t *testing.T) { + ctx := context.Background() + database, err := Open(ctx, filepath.Join(t.TempDir(), "connector.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + account, err := database.Writer().CreateAccount(ctx, dbgen.CreateAccountParams{ + GochatAccountID: 1, GochatInboxID: 2, GochatInboxIdentifier: "id", ConfigVersion: 1, + SessionID: "BYT99917999", Username: "agent", Password: "password", Enabled: 1, + DesiredPresence: "online", GochatHmacToken: "hmac", GochatWebhookSecret: "secret", + }) + if err != nil { + t.Fatal(err) + } + if _, err := database.Writer().InsertClassificationSyncResult(ctx, dbgen.InsertClassificationSyncResultParams{ + AccountID: account.ID, InboxID: account.GochatInboxID, EventID: "classification:recover", + }); err != nil { + t.Fatal(err) + } + claimed, err := database.Writer().ClaimClassificationSyncResult(ctx) + if err != nil || claimed.Status != "syncing" || claimed.Attempts != 1 { + t.Fatalf("claimed result = %#v, %v", claimed, err) + } + if recovered, err := database.Writer().RecoverClassificationSyncResults(ctx); err != nil || recovered != 1 { + t.Fatalf("recovered results = %d, %v", recovered, err) + } + recovered, err := database.Reader().GetClassificationSyncResult(ctx, dbgen.GetClassificationSyncResultParams{ + AccountID: account.ID, EventID: "classification:recover", + }) + if err != nil { + t.Fatal(err) + } + if recovered.Status != "pending" || recovered.Attempts != 0 { + t.Fatalf("recovered result = %#v", recovered) + } +} + func TestRecoverOutboundDeliveringMarksOnlyPossiblePartUncertain(t *testing.T) { ctx := context.Background() database, err := Open(ctx, filepath.Join(t.TempDir(), "connector.db")) @@ -620,7 +780,7 @@ func TestOnlineBackupCanBeOpenedReadOnly(t *testing.T) { t.Fatalf("backup mode = %v", info.Mode().Perm()) } version, err := InspectDatabase(ctx, backup) - if err != nil || version != 8 { + if err != nil || version != 11 { t.Fatalf("backup version = %d, %v", version, err) } } diff --git a/channels/shangwutong/internal/swt/classifications.go b/channels/shangwutong/internal/swt/classifications.go index 32efbf89..5da99423 100644 --- a/channels/shangwutong/internal/swt/classifications.go +++ b/channels/shangwutong/internal/swt/classifications.go @@ -30,6 +30,9 @@ func (c *Client) FetchClassificationCatalog(ctx context.Context, session Session if err := session.Validate(); err != nil { return ClassificationCatalog{}, err } + if session.ClassificationCatalogLoaded { + return session.ClassificationCatalog, nil + } target, err := resolveEndpoint(session.BaseURL, "oc/SiteSetting.aspx") if err != nil { return ClassificationCatalog{}, err @@ -77,12 +80,42 @@ func (c *Client) ChangeCustomerColor(ctx context.Context, session Session, sid, }, "change_customer_color") } +func decodeClassificationValue(raw string) (string, error) { + value := raw + for range 2 { + if !strings.ContainsAny(value, "%+") { + return value, nil + } + decoded, err := url.QueryUnescape(value) + if err != nil { + return "", err + } + if decoded == value { + return value, nil + } + value = decoded + } + return value, nil +} + func parseClassificationCatalog(headers http.Header) (ClassificationCatalog, error) { - conversationKinds, err := parseConversationKinds(headers.Get("sidkind_share")) + rawKinds, err := decodeClassificationValue(headers.Get("sidkind_share")) + if err != nil { + return ClassificationCatalog{}, fmt.Errorf("decode conversation kinds: %w", err) + } + rawColorIDs, err := decodeClassificationValue(headers.Get("colorkind0_share")) + if err != nil { + return ClassificationCatalog{}, fmt.Errorf("decode customer color ids: %w", err) + } + rawColorNames, err := decodeClassificationValue(headers.Get("colorkind1_share")) + if err != nil { + return ClassificationCatalog{}, fmt.Errorf("decode customer color names: %w", err) + } + conversationKinds, err := parseConversationKinds(rawKinds) if err != nil { return ClassificationCatalog{}, err } - customerColors, err := parseCustomerColors(headers.Get("colorkind0_share"), headers.Get("colorkind1_share")) + customerColors, err := parseCustomerColors(rawColorIDs, rawColorNames) if err != nil { return ClassificationCatalog{}, err } @@ -94,6 +127,7 @@ func parseConversationKinds(raw string) ([]ConversationKind, error) { return []ConversationKind{}, nil } items := strings.Split(raw, "|") + items = trimTrailingEmpty(items) result := make([]ConversationKind, 0, len(items)) seen := make(map[string]struct{}, len(items)) for _, item := range items { @@ -126,7 +160,7 @@ func parseCustomerColors(rawIDs, rawNames string) ([]CustomerColorKind, error) { if strings.TrimSpace(rawIDs) == "" && strings.TrimSpace(rawNames) == "" { return []CustomerColorKind{}, nil } - ids, names := strings.Split(rawIDs, "|"), strings.Split(rawNames, "|") + ids, names := trimTrailingEmpty(strings.Split(rawIDs, "|")), trimTrailingEmpty(strings.Split(rawNames, "|")) if len(ids) != len(names) { return nil, fmt.Errorf("customer color ids and names have different lengths") } @@ -146,6 +180,13 @@ func parseCustomerColors(rawIDs, rawNames string) ([]CustomerColorKind, error) { return result, nil } +func trimTrailingEmpty(values []string) []string { + for len(values) > 0 && strings.TrimSpace(values[len(values)-1]) == "" { + values = values[:len(values)-1] + } + return values +} + func classificationRequestError(operation string, response *http.Response, requestWritten bool, err error) error { if response != nil && (response.StatusCode < 200 || response.StatusCode >= 300) { return &Error{Operation: operation, Code: "http_error", Retryable: response.StatusCode >= 500, Err: err} diff --git a/channels/shangwutong/internal/swt/classifications_test.go b/channels/shangwutong/internal/swt/classifications_test.go index 0319f6eb..3b7f9efa 100644 --- a/channels/shangwutong/internal/swt/classifications_test.go +++ b/channels/shangwutong/internal/swt/classifications_test.go @@ -39,6 +39,26 @@ func TestFetchClassificationCatalogUsesSiteSettingHeaders(t *testing.T) { } } +func TestFetchClassificationCatalogUsesLoginSettings(t *testing.T) { + session := testSession() + session.ClassificationCatalogLoaded = true + session.ClassificationCatalog = ClassificationCatalog{ + CustomerColors: []CustomerColorKind{{ID: "10", Name: "ๆ™ฎ้€šๅฎขๆˆท"}}, + } + client := NewClient(&http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + t.Fatal("SiteSetting.aspx must not be requested when login supplied settings") + return nil, nil + })}) + + catalog, err := client.FetchClassificationCatalog(context.Background(), session) + if err != nil { + t.Fatal(err) + } + if len(catalog.CustomerColors) != 1 || catalog.CustomerColors[0].ID != "10" { + t.Fatalf("catalog = %#v", catalog) + } +} + func TestClassificationOperationsUseNativeEndpoints(t *testing.T) { var paths []string server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { diff --git a/channels/shangwutong/internal/swt/client.go b/channels/shangwutong/internal/swt/client.go index 57e1b176..30eb34ad 100644 --- a/channels/shangwutong/internal/swt/client.go +++ b/channels/shangwutong/internal/swt/client.go @@ -122,13 +122,16 @@ func (c *Client) Login(ctx context.Context, credentials Credentials, presence Pr if err != nil { return Session{}, &Error{Operation: "login", Code: "invalid_response", Err: err} } + catalog, catalogLoaded, _ := result.ClassificationCatalog() return Session{ - BaseURL: baseURL, - SessionID: credentials.SessionID, - SiteID: credentials.SessionID[3:], - LoginName: credentials.Username, - MAToken: result.MAToken(), - Purview: purview, + BaseURL: baseURL, + SessionID: credentials.SessionID, + SiteID: credentials.SessionID[3:], + LoginName: credentials.Username, + MAToken: result.MAToken(), + Purview: purview, + ClassificationCatalog: catalog, + ClassificationCatalogLoaded: catalogLoaded, }, nil } diff --git a/channels/shangwutong/internal/swt/client_test.go b/channels/shangwutong/internal/swt/client_test.go index 3e112b81..4b772f00 100644 --- a/channels/shangwutong/internal/swt/client_test.go +++ b/channels/shangwutong/internal/swt/client_test.go @@ -22,7 +22,7 @@ func TestClientLogin(t *testing.T) { if request.Form.Get("pwd") == "" || request.Form.Get("cid") == "" || request.Form.Get("t0") != "3" { t.Fatalf("invalid login form: %#v", request.Form) } - _, _ = response.Write([]byte("r|ok\nma|token\npurview|8388610")) + _, _ = response.Write([]byte("r|ok\nma|token\npurview|8388610\ncolorkind0|0%7C2%7C\ncolorkind1|%25E6%2599%25AE%25E9%2580%259A%25E5%25AE%25A2%25E6%2588%25B7%257CVIP%7C")) })) defer server.Close() @@ -34,6 +34,9 @@ func TestClientLogin(t *testing.T) { if session.MAToken != "token" || session.SessionID != "BYT99917999" || session.SiteID != "99917999" || session.Purview == nil || *session.Purview != 8388610 || !session.AllowsJoiningOtherOperatorDialogue() { t.Fatalf("unexpected session: %#v", session) } + if !session.ClassificationCatalogLoaded || len(session.ClassificationCatalog.CustomerColors) != 2 || session.ClassificationCatalog.CustomerColors[0].Name != "ๆ™ฎ้€šๅฎขๆˆท" { + t.Fatalf("login classification catalog = %#v", session.ClassificationCatalog) + } } func TestXSTSignatureKnownVector(t *testing.T) { diff --git a/channels/shangwutong/internal/swt/login.go b/channels/shangwutong/internal/swt/login.go index 8c581212..6bf6a2d6 100644 --- a/channels/shangwutong/internal/swt/login.go +++ b/channels/shangwutong/internal/swt/login.go @@ -20,6 +20,43 @@ type LoginResult struct { func (r LoginResult) MAToken() string { return r.Values["ma"] } +func (r LoginResult) ClassificationCatalog() (ClassificationCatalog, bool, error) { + read := func(key string) (string, bool) { + if value, ok := r.Values[key]; ok { + return value, true + } + value, ok := r.Values[key+"_share"] + return value, ok + } + + rawKinds, hasKinds := read("sidkind") + rawColorIDs, hasColorIDs := read("colorkind0") + rawColorNames, hasColorNames := read("colorkind1") + if !hasKinds && !hasColorIDs && !hasColorNames { + return ClassificationCatalog{}, false, nil + } + + var err error + if rawKinds, err = decodeClassificationValue(rawKinds); err != nil { + return ClassificationCatalog{}, true, fmt.Errorf("decode conversation kinds: %w", err) + } + if rawColorIDs, err = decodeClassificationValue(rawColorIDs); err != nil { + return ClassificationCatalog{}, true, fmt.Errorf("decode customer color ids: %w", err) + } + if rawColorNames, err = decodeClassificationValue(rawColorNames); err != nil { + return ClassificationCatalog{}, true, fmt.Errorf("decode customer color names: %w", err) + } + conversationKinds, err := parseConversationKinds(rawKinds) + if err != nil { + return ClassificationCatalog{}, true, err + } + customerColors, err := parseCustomerColors(rawColorIDs, rawColorNames) + if err != nil { + return ClassificationCatalog{}, true, err + } + return ClassificationCatalog{ConversationKinds: conversationKinds, CustomerColors: customerColors}, true, nil +} + func (r LoginResult) Purview() (*uint64, error) { raw := strings.TrimSpace(r.Values["purview"]) if raw == "" { diff --git a/channels/shangwutong/internal/swt/login_test.go b/channels/shangwutong/internal/swt/login_test.go index 36cecf03..3d3055e7 100644 --- a/channels/shangwutong/internal/swt/login_test.go +++ b/channels/shangwutong/internal/swt/login_test.go @@ -15,6 +15,20 @@ func TestParseLoginResponse(t *testing.T) { } } +func TestLoginResultClassificationCatalogDecodesDoubleEncodedValues(t *testing.T) { + result, err := ParseLoginResponse("r|ok\nma|token\nsidkind|1%2C%E5%9C%A8%E7%BA%BF%E5%92%A8%E8%AF%A2%2C0\ncolorkind0|0%7C2%7C\ncolorkind1|%25E6%2599%25AE%25E9%2580%259A%25E5%25AE%25A2%25E6%2588%25B7%257CVIP%7C") + if err != nil { + t.Fatal(err) + } + catalog, loaded, err := result.ClassificationCatalog() + if err != nil { + t.Fatal(err) + } + if !loaded || len(catalog.ConversationKinds) != 1 || catalog.ConversationKinds[0].Name != "ๅœจ็บฟๅ’จ่ฏข" || len(catalog.CustomerColors) != 2 || catalog.CustomerColors[0].Name != "ๆ™ฎ้€šๅฎขๆˆท" { + t.Fatalf("catalog = %#v, loaded = %v", catalog, loaded) + } +} + func TestParseLoginResponseClassifiesFailures(t *testing.T) { tests := []struct { body string diff --git a/channels/shangwutong/internal/swt/operations.go b/channels/shangwutong/internal/swt/operations.go index 0830a852..2f46e5cb 100644 --- a/channels/shangwutong/internal/swt/operations.go +++ b/channels/shangwutong/internal/swt/operations.go @@ -104,8 +104,11 @@ func (c *Client) ChangeContactName(ctx context.Context, session Session, sid, ci if strings.TrimSpace(cid) == "" || strings.TrimSpace(name) == "" { return errors.New("cid and name are required") } - return c.sessionOperation(ctx, session, "oc/changecname.aspx", map[string]string{ - "sid": sid, "cid": cid, "cname": name, "cnote": "", + // RenamedThread in the reference client sends cnote explicitly, even when + // the anonymous visitor has no remote note. GoChat notes stay independent. + return c.formOperation(ctx, session, "oc/changecname.aspx", url.Values{ + "sid": {strings.TrimSpace(sid)}, "oname": {session.LoginName}, "siteid": {session.SiteID}, + "cname": {strings.TrimSpace(name)}, "cnote": {""}, "cid": {strings.TrimSpace(cid)}, "sn": {session.MAToken}, }, "change_contact_name") } diff --git a/channels/shangwutong/internal/swt/operations_test.go b/channels/shangwutong/internal/swt/operations_test.go index 56c1ec3d..330ad7a2 100644 --- a/channels/shangwutong/internal/swt/operations_test.go +++ b/channels/shangwutong/internal/swt/operations_test.go @@ -100,23 +100,35 @@ func TestSessionOperationsUseDocumentedEndpoints(t *testing.T) { } } -func TestChangeContactNameUsesDocumentedForm(t *testing.T) { +func TestChangeContactNameUsesReferenceClientForm(t *testing.T) { + var requests atomic.Int64 server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + requests.Add(1) if request.URL.Path != "/oc/changecname.aspx" { t.Fatalf("path = %q", request.URL.Path) } if err := request.ParseForm(); err != nil { t.Fatal(err) } - if request.Form.Get("sid") != "visitor" || request.Form.Get("cid") != "cid-1" || request.Form.Get("cname") != "ๆ–ฐๅๅญ—" || request.Form.Get("cnote") != "" { - t.Fatalf("form = %#v", request.Form) + want := map[string]string{ + "sid": "visitor", "oname": "agent", "siteid": "99917999", "cname": "ๆ–ฐๅๅญ—", + "cnote": "", "cid": "cid-1", "sn": "token", + } + for key, value := range want { + if request.Form.Get(key) != value { + t.Fatalf("form[%q] = %q, want %q; form = %#v", key, request.Form.Get(key), value, request.Form) + } } response.Header().Set("r", "ok") })) defer server.Close() + if err := NewClient(rewriteTransportClient(server.URL)).ChangeContactName(context.Background(), testSession(), "visitor", "cid-1", "ๆ–ฐๅๅญ—"); err != nil { t.Fatal(err) } + if requests.Load() != 1 { + t.Fatalf("remote requests = %d", requests.Load()) + } } func TestTransferConversationUsesDocumentedForm(t *testing.T) { diff --git a/channels/shangwutong/internal/swt/types.go b/channels/shangwutong/internal/swt/types.go index 32ae9774..a9b784eb 100644 --- a/channels/shangwutong/internal/swt/types.go +++ b/channels/shangwutong/internal/swt/types.go @@ -90,6 +90,12 @@ type Session struct { LoginName string MAToken string Purview *uint64 + + // Some SWT servers return classification settings during login and omit + // them from SiteSetting.aspx. Keep that response so catalog reads remain + // usable on those servers. + ClassificationCatalog ClassificationCatalog + ClassificationCatalogLoaded bool } type XSTRoute struct { diff --git a/channels/shangwutong/sqlc.yaml b/channels/shangwutong/sqlc.yaml index f3165468..e9597571 100644 --- a/channels/shangwutong/sqlc.yaml +++ b/channels/shangwutong/sqlc.yaml @@ -6,6 +6,9 @@ sql: - "db/migrations/006_add_xst_outbound_stages.up.sql" - "db/migrations/007_add_xst_reception_sync.up.sql" - "db/migrations/008_add_classification_operation_results.up.sql" + - "db/migrations/009_add_operation_result_compensations.up.sql" + - "db/migrations/010_add_uncertain_since.up.sql" + - "db/migrations/011_add_classification_sync_results.up.sql" queries: "db/queries" gen: go: diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index 075884f0..3d5604d7 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -94,8 +94,9 @@ COPY backend/scripts/db_backup.sh backend/scripts/db_restore.sh deploy/docker/da ENV GOCHAT_FRONTEND_DIST=/app/frontend/dist -# Set ownership to non-root user -RUN mkdir -p /app/storage/uploads && chown -R gochat:gochat /app/storage +# Set ownership to non-root user and ensure migration files are readable. +RUN chmod -R a+rX /app/migrations && \ + mkdir -p /app/storage/uploads && chown -R gochat:gochat /app/storage # Switch to non-root user for security USER gochat diff --git a/docs/plans/2026-09-11-shangwutong-classification-sync-plan.md b/docs/plans/2026-09-11-shangwutong-classification-sync-plan.md index 5a12e423..85beeb98 100644 --- a/docs/plans/2026-09-11-shangwutong-classification-sync-plan.md +++ b/docs/plans/2026-09-11-shangwutong-classification-sync-plan.md @@ -1,7 +1,7 @@ # ๅ•†ๅŠก้€šๅˆ†็ฑปๅŒๆญฅไธŽไผš่ฏๅˆ†็ฑป้…็ฝฎๅฎžๆ–ฝ่ฎกๅˆ’ > ๆ—ฅๆœŸ๏ผš2026-09-11 -> ็Šถๆ€๏ผš้ฆ–็‰ˆๅฎž็ŽฐๅฎŒๆˆ๏ผŒๅพ…็œŸๅฎžๅ•†ๅŠก้€š่ดฆๅท็ฐๅบฆ้ชŒ่ฏ +> ็Šถๆ€๏ผš้ฆ–็‰ˆๆœฌๅœฐๅฎž็ŽฐๅฎŒๆˆ๏ผŒๅค–้ƒจๅ่ฎฎ/็œŸๅฎžๅ•†ๅŠก้€š่ดฆๅท็ฐๅบฆ้ชŒ่ฏไปๅพ…ๅฎŒๆˆ > ๅŽ็ปญ่ƒฝๅŠ›็ผบๅฃไฟฎๅค๏ผš[`2026-09-12-shangwutong-capability-gap-repair-plan.md`](2026-09-12-shangwutong-capability-gap-repair-plan.md) > ๅ…ณ่”่ฐƒ็ ”๏ผš[`docs/research/2026-09-11-shangwutong-pc-classification-protocol.md`](../research/2026-09-11-shangwutong-pc-classification-protocol.md) @@ -236,10 +236,10 @@ shangwutong_classification_caches - Connector ๅ›ž่ฐƒๅฎขๆˆท็ซฏ๏ผš`channels/shangwutong/internal/gochat/classifications.go`๏ผ› - Connector ไผš่ฏๆ“ไฝœ๏ผš`channels/shangwutong/internal/delivery/outbound.go`๏ผ› - Connector webhook ๅˆ†ๅ‘๏ผš`channels/shangwutong/internal/httpapi/server.go`๏ผ› -- GoChat ็ผ“ๅญ˜ๆจกๅž‹ไธŽ่ฟ็งป๏ผš`backend/internal/model/channel_shangwutong_classification_cache.go`ใ€`backend/migrations/000087_*`๏ผ› -- GoChat API ไธŽไปปๅŠก๏ผš`backend/internal/handler/api/v1/shangwutong_connector_handler.go`ใ€`backend/internal/service/shangwutong_webhook_delivery.go`๏ผ› +- GoChat ็ผ“ๅญ˜ๆจกๅž‹ไธŽ่ฟ็งป๏ผš`backend/internal/model/channel_shangwutong_classification_cache.go`ใ€`backend/migrations/000087_*`๏ผ›ๅŒๆญฅไบ‹ไปถไฝฟ็”จ `last_sync_event_id` ๅšไปฃ้™…ไฟๆŠค๏ผ› +- GoChat API ไธŽไปปๅŠก๏ผš`backend/internal/handler/api/v1/shangwutong_connector_handler.go`ใ€`backend/internal/service/shangwutong_webhook_delivery.go`๏ผ›ๅˆ†็ฑปๅ›ž่ฐƒๆŒ‰ operation ไฟๅญ˜็Šถๆ€ๅนถๅฏน customer color ๅšๅŒ contact-inbox ไผš่ฏไผ ๆ’ญ๏ผ› - ๅ‰็ซฏๅŒๆญฅๆŒ‰้’ฎ๏ผš`frontend/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/ShangwutongConfiguration.vue`๏ผ› -- ๅ‰็ซฏไผš่ฏไธ‹ๆ‹‰๏ผš`frontend/app/javascript/dashboard/routes/dashboard/conversation/ShangwutongClassifications.vue`ใ€‚ +- ๅ‰็ซฏไผš่ฏไธ‹ๆ‹‰๏ผš`frontend/app/javascript/dashboard/routes/dashboard/conversation/ShangwutongClassifications.vue`๏ผ›ๆŒ‰ไผš่ฏ/ๆ“ไฝœ generation ไธขๅผƒ่ฟ‡ๆœŸ่ฏทๆฑ‚๏ผŒๅˆ†ๅˆซๅฑ•็คบ chatkind/customer color ็š„ pendingใ€ๅคฑ่ดฅๅ’Œไธ็กฎๅฎš็Šถๆ€ใ€‚ ้ชŒ่ฏ็ป“ๆžœ๏ผš @@ -249,4 +249,4 @@ cd backend && GOCHAT_TEST_DB=sqlite go test ./... cd frontend && pnpm build ``` -ๅ‡ๅทฒ้€š่ฟ‡ใ€‚ๅ‰็ซฏไฟฎๆ”นๆ–‡ไปถ็š„ ESLint ๆ— ้”™่ฏฏ๏ผ›็Žฐๆœ‰่ฎพ็ฝฎ็ป„ไปถไฟ็•™ไธ€ๆกๆ—ขๆœ‰็š„ๅŠจๆ€ i18n key warningใ€‚็œŸๅฎžๅ•†ๅŠก้€š่ดฆๅท็š„็ฐๅบฆๅ่ฎฎ้ชŒ่ฏไปๅพ…ๅฎ‰ๆŽ’ใ€‚ +ๅ‡ๅทฒ้€š่ฟ‡ใ€‚ๅฆๅทฒ้€š่ฟ‡ๅŽ็ซฏ/Connector ็š„ๅˆ†็ฑปๅ›ž่ฐƒๅน‚็ญ‰ใ€ๆ—งไบ‹ไปถ้š”็ฆปใ€ๅฎขๆˆท้ขœ่‰ฒๅŒ contact-inbox ไผ ๆ’ญๅ’Œๆ— ๅ…ณๅฑžๆ€งไฟ็•™ๅ›žๅฝ’ๆต‹่ฏ•๏ผ›ๅ‰็ซฏไฟฎๆ”นๆ–‡ไปถ็š„ ESLint ๆ— ้”™่ฏฏใ€‚็œŸๅฎžๅ•†ๅŠก้€š่ดฆๅท็š„็ฐๅบฆๅ่ฎฎ้ชŒ่ฏไปๅพ…ๅฎ‰ๆŽ’๏ผŒRESETใ€็‹ฌ็ซ‹่ฟœ็จ‹่ต„ๆ–™ไบ‹ไปถๅŠ kind=52 ๅކๅฒ่ƒฝๅŠ›ไปไธๅผ€ๆ”พใ€‚ diff --git a/docs/plans/2026-09-12-shangwutong-capability-gap-repair-plan.md b/docs/plans/2026-09-12-shangwutong-capability-gap-repair-plan.md index fb33ab23..bb3af66b 100644 --- a/docs/plans/2026-09-12-shangwutong-capability-gap-repair-plan.md +++ b/docs/plans/2026-09-12-shangwutong-capability-gap-repair-plan.md @@ -1,9 +1,11 @@ # ๅ•†ๅŠก้€šๆถˆๆฏไธŽ่”็ณปไบบ/ไผš่ฏ่ต„ๆ–™่ƒฝๅŠ›็ผบๅฃไฟฎๅค่ฎกๅˆ’ > ๆ—ฅๆœŸ๏ผš2026-09-12 -> ็Šถๆ€๏ผšๅทฒๅฎŒๆˆๆ–‡ๆกฃ่กฅ็ผบๅฎกๆŸฅ๏ผ›ไฟฎๅคๅพ…ๅฎžๆ–ฝ๏ผŒ่Œƒๅ›ดๅ†ณ็ญ–ๅพ…็กฎ่ฎค๏ผ›็Žฐๆœ‰่‡ชๅŠจๅŒ–ๅŸบ็บฟ้€š่ฟ‡ไธไปฃ่กจๆ–ฐๅขž่ƒฝๅŠ›ๅฎŒๆˆใ€‚ +> ็Šถๆ€๏ผš**้ƒจๅˆ†ๅฎž็Žฐ๏ผŒๅฎžๆ–ฝๅŽๅฎกๆŸฅๆœช้€š่ฟ‡๏ผŒๅพ…ๆ•ดๆ”น**ใ€‚็Žฐๆœ‰ๆœฌๅœฐๆต‹่ฏ•้€š่ฟ‡ไธ่ฆ†็›–ๆ–ฐๅ‘็Žฐ็š„ๅ›ž่ฐƒใ€ไบ‹ๅŠกใ€CIDใ€ๆขๅคๅ’Œ UI ็ผบ้™ท๏ผŒไธๅฏไป…ไปฅโ€œๅพ…็ฐๅบฆโ€ๆ่ฟฐใ€‚ๆœชๅ†ป็ป“็š„่ƒฝๅŠ›็ปง็ปญๅ…ณ้—ญใ€‚ > -> ๅฎกๆŸฅๅŸบ็บฟ๏ผš`main@6e62f5094fa2381c5b283eeb9be3f94a95096f1c` ๅŠ  2026-09-12 ๅฝ“ๅ‰ๆœชๆไบคๅทฅไฝœๆ ‘๏ผˆๅŒ…ๆ‹ฌๅˆ†็ฑป็›ธๅ…ณๆ”นๅŠจ๏ผ‰๏ผŒไธๆ˜ฏ็บฏ HEAD ๆˆ–ๅทฒๅ‘ๅธƒ็‰ˆๆœฌใ€‚ +> ๆ•ดๆ”นๅ…ฅๅฃ๏ผš[`2026-09-13-shangwutong-review-remediation-plan.md`](2026-09-13-shangwutong-review-remediation-plan.md)๏ผŒๅˆ—ๅ‡บ F01โ€“F18 ็š„่งฆๅ‘ๅœบๆ™ฏใ€ไฟฎๅคๆ–นๅ‘ไธŽ้ชŒๆ”ถ้—จๆง›ใ€‚ๆœฌ่ฝฎๅช่ฝๅœฐๆ–‡ๆกฃ๏ผŒๅฐšๆœชไฟฎๅค่ฟ™ไบ›้—ฎ้ข˜ใ€‚ +> +> ๅˆๅง‹ๅฎกๆŸฅๅŸบ็บฟ๏ผš`main@6e62f5094fa2381c5b283eeb9be3f94a95096f1c` ๅŠ  2026-09-12 ๆœชๆไบคๅทฅไฝœๆ ‘ใ€‚ๅฎžๆ–ฝๅŽๅคๅฎกๅŸบ็บฟ๏ผš`main@0dd188c46f41e9d89644e25fe3c0b6a06ed9ae73` ๅŠ  2026-09-13 ๆœฌๆฌกๆœชๆไบคๅ˜ๆ›ด๏ผˆๅซๆœช่ทŸ่ธชๆ–‡ไปถ๏ผ‰๏ผŒๅ‡ไธๆ˜ฏๅทฒๅ‘ๅธƒ็‰ˆๆœฌใ€‚ > > ๆ€ป่ฎกๅˆ’๏ผš[`2026-07-31-shangwutong-connector-development-plan.md`](2026-07-31-shangwutong-connector-development-plan.md) > ๅˆ†็ฑป่ฎกๅˆ’๏ผš[`2026-09-11-shangwutong-classification-sync-plan.md`](2026-09-11-shangwutong-classification-sync-plan.md) @@ -46,41 +48,44 @@ | ID | ไผ˜ๅ…ˆ็บง | ็Šถๆ€ | ็ผบๅฃไธŽ็›ฎๆ ‡ | | --- | ---: | --- | --- | | SWT-R01 | P0 | ๅพ…ไบงๅ“็กฎ่ฎค | ๅ†ป็ป“ `cnote` ๆ˜ฏๅฆ็บณๅ…ฅ่Œƒๅ›ด๏ผŒไปฅๅŠๅฎƒไธŽ GoChat Contact Note ็š„ๅ…ณ็ณป๏ผ›่‹ฅไธๅš๏ผŒๅฟ…้กปๅœจ UI/API/ๆ–‡ๆกฃไธญๆ˜Ž็กฎโ€œไธๆ”ฏๆŒโ€ใ€‚ | -| SWT-R02 | P0 | ๅพ…ๅฎžๆ–ฝ | ๅ…ˆ้ชŒ่ฏๅนถ้˜ปๆ–ญ็Žฐๆœ‰ๆ”นๅๅ‘้€็ฉบ `cnote` ้€ ๆˆ่ฟœ็จ‹ๅค‡ๆณจไธขๅคฑ็š„้ฃŽ้™ฉ๏ผŒๆญคๅฎ‰ๅ…จ้กนไธไพ่ต– R01๏ผ›ๅŒๅ‘็ผ–่พ‘ไป…ๅœจ R01 ๆ‰นๅ‡†ๅŽ่กฅ้ฝ่พ“ๅ…ฅใ€ๆŒไน…ๅŒ–ใ€ๅ‡บๅ…ฅ็ซ™ๆ˜ ๅฐ„ใ€ๅ†ฒ็ชๅ’Œๅคฑ่ดฅๅค„็†ใ€‚ | -| SWT-R03 | P1 | ๅพ…ๅฎžๆ–ฝ | ่กฅ้ฝ่”็ณปไบบๅง“ๅไฟฎๆ”น็š„ๆœ€็ปˆ็Šถๆ€ๅ›žๅ†™ใ€‚่ฟœ็จ‹ rename ็š„ๆˆๅŠŸใ€ๅคฑ่ดฅใ€ไธ็กฎๅฎš็ป“ๆžœๅฟ…้กป่ƒฝๅ›žๅˆฐ GoChat๏ผŒๅนถๅœจ UI ไธญๅ‘ˆ็Žฐ pending/failed/uncertain๏ผ›ไธ่ƒฝๅœจ Connector ๆŽฅๅ— 202 ๅŽ่ง†ไธบ่ฟœ็จ‹ๆˆๅŠŸใ€‚ | -| SWT-R04 | P1 | ๅพ…ๅฎžๆ–ฝ | ๅค„็†ๅง“ๅไฟฎๆ”นไธŽ CID ่Žทๅ–ไน‹้—ด็š„็ซžๆ€ใ€‚็ผบๅฐ‘ CID ๆ—ถไธๅพ—้™้ป˜ไธขๅผƒ๏ผ›ๅบ”ๅปถ่ฟŸๅˆฐ CID ๅฏ็”จๅŽ้‡ๆ”พ๏ผŒๆˆ–ๅ‘็”จๆˆท่ฟ”ๅ›žๆ˜Ž็กฎไธๅฏๆ‰ง่กŒ็Šถๆ€ใ€‚ | +| SWT-R02 | P0 | ้ƒจๅˆ†ๅฎž็Žฐ๏ผˆๅ่ฎฎๅฎ‰ๅ…จ้—จ็ฆๆœช้—ญๅˆ๏ผ›F01๏ผ‰ | ๅ…ˆ้ชŒ่ฏๅนถ้˜ปๆ–ญ็Žฐๆœ‰ๆ”นๅๅ‘้€็ฉบ `cnote` ้€ ๆˆ่ฟœ็จ‹ๅค‡ๆณจไธขๅคฑ็š„้ฃŽ้™ฉ๏ผŒๆญคๅฎ‰ๅ…จ้กนไธไพ่ต– R01๏ผ›ๅŒๅ‘็ผ–่พ‘ไป…ๅœจ R01 ๆ‰นๅ‡†ๅŽ่กฅ้ฝ่พ“ๅ…ฅใ€ๆŒไน…ๅŒ–ใ€ๅ‡บๅ…ฅ็ซ™ๆ˜ ๅฐ„ใ€ๅ†ฒ็ชๅ’Œๅคฑ่ดฅๅค„็†ใ€‚ | +| SWT-R03 | P1 | ้ƒจๅˆ†ๅฎž็Žฐ๏ผˆ็Šถๆ€/็ป“ๆžœๆขๅค/UI ็ผบๅฃ๏ผ›F02โ€“F04/F09/F10/F14๏ผ‰ | ่กฅ้ฝ่”็ณปไบบๅง“ๅไฟฎๆ”น็š„ๆœ€็ปˆ็Šถๆ€ๅ›žๅ†™ใ€‚่ฟœ็จ‹ rename ็š„ๆˆๅŠŸใ€ๅคฑ่ดฅใ€ไธ็กฎๅฎš็ป“ๆžœๅฟ…้กป่ƒฝๅ›žๅˆฐ GoChat๏ผŒๅนถๅœจ UI ไธญๅ‘ˆ็Žฐ pending/failed/uncertain๏ผ›ไธ่ƒฝๅœจ Connector ๆŽฅๅ— 202 ๅŽ่ง†ไธบ่ฟœ็จ‹ๆˆๅŠŸใ€‚ | +| SWT-R04 | P1 | ้ƒจๅˆ†ๅฎž็Žฐ๏ผˆๆ–ญ่ฟž้‡่ฏ•/ๆ™šๅˆฐ CID ่ฟ‡ๆœŸ็ผบ้™ท๏ผ›F04/F08๏ผ‰ | ๅค„็†ๅง“ๅไฟฎๆ”นไธŽ CID ่Žทๅ–ไน‹้—ด็š„็ซžๆ€ใ€‚็ผบๅฐ‘ CID ๆ—ถ่ฟ›ๅ…ฅ durable operation ็ญ‰ๅพ…้˜Ÿๅˆ—๏ผ›CID ๅˆฐ่พพๅ”ค้†’ๅŒไธ€ `(account_id, SID)` ็š„ๅพ…ๅค„็† rename๏ผŒ่ถ…่ฟ‡ 24 ๅฐๆ—ถๅ›žๅ†™ `cid_wait_timeout`๏ผŒไธๅ—ๆ™ฎ้€š 10 ๆฌก้‡่ฏ•ไธŠ้™ๅฝฑๅ“ใ€‚ | | SWT-R05 | P1 | ๅพ…ๅฎžๆ–ฝ | ่กฅ้ฝๅ•†ๅŠก้€š่ฟœ็จ‹ `chatkind` โ†’ GoChat ็š„ๅ…ฅ็ซ™ๅŒๆญฅ๏ผŒๅนถๅŒบๅˆ†่ฟœ็จ‹ไบ‹ไปถไธŽ GoChat ๅ‘่ตทๆ“ไฝœ็š„็กฎ่ฎคๅ›ž่ฐƒใ€‚ | -| SWT-R06 | P1 | ๅพ…ๅฎžๆ–ฝ | ๆ˜Ž็กฎๅฎขๆˆท้ขœ่‰ฒ็š„ๆธ ้“ๅฎขๆˆท็บงไฝœ็”จๅŸŸใ€‚่‡ณๅฐ‘ไปฅ `(account_id, inbox_id, CID)` ้š”็ฆป๏ผŒๅ…ณ่” `(inbox_id, source_id)`๏ผ›ๅœจๅŒไธ€่ฟœ็จ‹่บซไปฝ่Œƒๅ›ดไผ ๆ’ญๅˆฐ็›ธๅ…ณไผš่ฏ๏ผŒ็ปŸไธ€ ID/ๅ็งฐ๏ผŒ็ฆๆญข่ฃธ CID ่ทจ็ซ™็‚นๅฝ’ๅนถใ€‚ | +| SWT-R06 | P1 | ้ƒจๅˆ†ๅฎž็Žฐ๏ผˆCID ่Œƒๅ›ด/ๅŽŸๅญไผ ๆ’ญๅพ…ไฟฎ๏ผ›F06/F07๏ผ‰ | ๆ˜Ž็กฎๅฎขๆˆท้ขœ่‰ฒ็š„ๆธ ้“ๅฎขๆˆท็บงไฝœ็”จๅŸŸใ€‚่‡ณๅฐ‘ไปฅ `(account_id, inbox_id, CID)` ้š”็ฆป๏ผŒๅ…ณ่” `(inbox_id, source_id)`๏ผ›ๅœจๅŒไธ€่ฟœ็จ‹่บซไปฝ่Œƒๅ›ดไผ ๆ’ญๅˆฐ็›ธๅ…ณไผš่ฏ๏ผŒ็ปŸไธ€ ID/ๅ็งฐ๏ผŒ็ฆๆญข่ฃธ CID ่ทจ็ซ™็‚นๅฝ’ๅนถใ€‚ | | SWT-R07 | P1 | ๅพ…็กฎ่ฎค | ๅฎกๆ ธไผš่ฏๅˆ†็ฑปไฟฎๆ”น็š„่ง’่‰ฒๆƒ้™๏ผ›Connector ๆœๅŠก้‰ดๆƒๅ’Œ่ต„ๆบ่Œƒๅ›ดๅทฒๆœ‰๏ผŒไฝ†ๅˆ†็ฑปไฟฎๆ”นๆ–นๆณ•ๆœช่ง็‹ฌ็ซ‹่ง’่‰ฒ็บง้™ๅˆถใ€‚ | | SWT-R08 | P2 | ๅพ…ๅฎžๆ–ฝ | ๅœจๅ•†ๅŠก้€šไผš่ฏ่”็ณปไบบไพงๆ ่กฅๅ…… GoChat ๅŽŸ็”Ÿ Contact Label ็š„ๅ…ฅๅฃ๏ผˆๅฆ‚ไบงๅ“้œ€่ฆ๏ผ‰๏ผŒไฝ†ไฟๆŒๅ…ถไธŽๅ•†ๅŠก้€š้ขœ่‰ฒๅˆ†็ฑปๅฎŒๅ…จ็‹ฌ็ซ‹ใ€‚ | -| SWT-R09 | P2 | ๅพ…ๅฎžๆ–ฝ | ๅฝ“ๅ‰่”็ณปไบบๅค‡ๆณจ HTTP ่ทฏ็”ฑๅฎž้™…ไฝฟ็”จ `ContactService โ†’ NoteRepo โ†’ notes`๏ผ›ๅ…ˆ็›˜็‚น `contact_notes` ๅކๅฒๆ•ฐๆฎๅŠๅ†™ๅ…ฅๆ–น๏ผŒๆ— ่ฟ็งป/ๅ›žๆปš่ฏๆฎไธๅพ—ๅˆ ่กจ๏ผ›่กฅ้ฝ API/store ๅ’Œไผš่ฏไพงๆ ใ€่”็ณปไบบ่ฏฆๆƒ…ไธคๅค„็ผ–่พ‘ UIใ€‚ | +| SWT-R09 | P2 | ้ƒจๅˆ†ๅฎž็Žฐ๏ผˆ็ผ–่พ‘ๆ•ฐๆฎๅฎ‰ๅ…จ/่‰็จฟๅพ…ไฟฎ๏ผ›F12/F13๏ผ›่ฟ็งปๅพ…็›˜็‚น๏ผ‰ | ๅฝ“ๅ‰่”็ณปไบบๅค‡ๆณจ HTTP ่ทฏ็”ฑๅฎž้™…ไฝฟ็”จ `ContactService โ†’ NoteRepo โ†’ notes`๏ผ›ๅ…ˆ็›˜็‚น `contact_notes` ๅކๅฒๆ•ฐๆฎๅŠๅ†™ๅ…ฅๆ–น๏ผŒๆ— ่ฟ็งป/ๅ›žๆปš่ฏๆฎไธๅพ—ๅˆ ่กจ๏ผ›่กฅ้ฝ API/store ๅ’Œไผš่ฏไพงๆ ใ€่”็ณปไบบ่ฏฆๆƒ…ไธคๅค„็ผ–่พ‘ UIใ€‚ | | SWT-R10 | P2 | ๅพ…็กฎ่ฎค | ๅˆ†ๅˆซๅ†ป็ป“ kind=52 ่ขซๅŠจๅކๅฒๆญฃๆ–‡ๅฏผๅ…ฅใ€ไธปๅŠจๅކๅฒๆ‹‰ๅ–ใ€ๆถˆๆฏ็ผ–่พ‘ใ€ๅๅบ”ๅ››้กน่Œƒๅ›ด๏ผ›ๅฝ“ๅ‰ kind=52 ไป…ๆๅ– CID๏ผŒไธๅฏผๅ…ฅๅކๅฒๆญฃๆ–‡๏ผŒไบฆๆ— ๅฎŒๆ•ดไธปๅŠจๅˆ†้กตๅކๅฒ APIใ€‚ | | SWT-R11 | P1 | ๅพ…ๅฎžๆ–ฝ | ๅขžๅŠ ่ทจ็ณป็ปŸ็ซฏๅˆฐ็ซฏๆต‹่ฏ•ๅ’Œ็œŸๅฎž่ดฆๅท็ฐๅบฆ้ชŒ่ฏ๏ผŒ่ฆ†็›–ๅง“ๅใ€`cnote`ใ€CIDใ€ๅˆ†็ฑปใ€ๅคšไผš่ฏๅ’Œๅคฑ่ดฅ้‡่ฏ•ใ€‚ | -| SWT-R12 | P2 | ๅพ…ๅฎžๆ–ฝ | ่กฅๅ…… READMEใ€runbook ๅ’Œๅˆ†็ฑป่ฎกๅˆ’ไธญ็š„่ƒฝๅŠ›็Ÿฉ้˜ตใ€้”™่ฏฏ็Šถๆ€ใ€ๆœชๆ”ฏๆŒ้กนๅ’Œ็ฐๅบฆ้ชŒๆ”ถ่ฏๆฎใ€‚ | -| SWT-R13 | P1 | ๅพ…ๅฎžๆ–ฝ | ไฟฎๅคๅŽŸ็”Ÿไผš่ฏๆ ‡็ญพๆ›ดๆ–ฐ่ฟ”ๅ›žๅฏน่ฑกใ€ๅ‰็ซฏ้ข„ๆœŸๆ•ฐ็ป„็š„ๅฅ‘็บฆไธๅŒน้…๏ผ›้ชŒ่ฏๆ™ฎ้€šๆ›ดๆ–ฐใ€ๆ‰น้‡ๅขžๅˆ ใ€็ญ›้€‰ใ€GETใ€ๅบๅˆ—ๅŒ–็š„ๆ•ฐๆฎๆบไธ€่‡ดๆ€งใ€‚ | -| SWT-R14 | P1 | ๅพ…ๅฎžๆ–ฝ | ่ต„ๆ–™ๆ“ไฝœๅ’Œ catalog ๅŒๆญฅ่กฅๆŒไน…ๅŒ–ๅ…ณ่”ใ€้‡ๅค/ไนฑๅบไฟๆŠคใ€ๅŽŸๅญๆ›ดๆ–ฐๅŠๆœ€็ปˆ็Šถๆ€๏ผ›้ชŒ่ฏไบ‹ไปถ ID ไธ็ญ‰ไบŽ้ชŒ่ฏๆ“ไฝœ็œŸๅฎžๅญ˜ๅœจ๏ผŒๆ—งๅ›ž่ฐƒไธๅพ—่ฆ†็›–ๆ–ฐๅ€ผใ€‚ | -| SWT-R15 | P1 | ๅพ…ๅฎžๆ–ฝ | ่กฅ่”็ณปไบบๅˆๅนถ/่ฝฏๅˆ ้™คใ€inbox ๅˆ ้™ค/ๅœ็”จ/้‡็ป‘ๅฎšใ€้…็ฝฎ็‰ˆๆœฌๅ˜ๅŒ–ๆ—ถ๏ผŒ่ต„ๆ–™ๅฝ’ๅฑžไธŽ pending ๆ“ไฝœ/ๆ—งๅ›ž่ฐƒ็š„่ฟ็งปๆˆ–็ปˆๆญข่ง„ๅˆ™ใ€‚ | -| SWT-R16 | P1 | ๅพ…ๅฎžๆ–ฝ | ่กฅๅŽŸ็”Ÿๅง“ๅ/ๅค‡ๆณจ/ๆ ‡็ญพ/ๅˆๅนถ/ๅˆ ้™ค็š„็œŸๅฎž่ทฏ็”ฑๆƒ้™ใ€็ˆถ่”็ณปไบบๆœ‰ๆ•ˆๆ€งไธŽ่ทจ็งŸๆˆทๆ ก้ชŒ๏ผ›ๅค็”จ็Žฐๆœ‰ๅ…ญไธชไบŒๅ€ผๆƒ้™๏ผŒไธๆ–ฐๅขžไธ‰ๆ€ๆˆ– CE/EE ้™ๅˆถใ€‚ | -| SWT-R17 | P1 | ๅพ…ๅฎžๆ–ฝ | ่กฅๅˆ†็ฑป็ป„ไปถๅˆ‡ๆขไผš่ฏ็ซžๆ€ใ€็‹ฌ็ซ‹ๆ“ไฝœ็Šถๆ€ใ€ๅปถ่ฟŸ็กฎ่ฎคใ€catalog ๅคฑๆ•ˆๅŠๅˆทๆ–ฐๅŽๆขๅค๏ผ›ๆ—ง่ฏทๆฑ‚ไธๅพ—ๆ›ดๆ–ฐๆ–ฐไผš่ฏ UIใ€‚ | -| SWT-R18 | P1 | ๅพ…ๅฎžๆ–ฝ | ๅค็”จ็Žฐๆœ‰ๆ“ไฝœ็ป“ๆžœ้˜Ÿๅˆ—่กฅ renameใ€็ป“ๆžœ้‡่ฏ•่€—ๅฐฝ่กฅๅฟใ€catalog ๅ›žไผ ๅคฑ่ดฅๅฏๆขๅคๆ€ง๏ผ›ไฟ็•™ไธ็กฎๅฎš่ฏๆฎ๏ผŒ่กฅ่ดฆๅท็บง้˜Ÿๅคด้˜ปๅกž็›‘ๆŽงไธŽๆขๅค้ชŒๆ”ถใ€‚ | +| SWT-R12 | P2 | ้ƒจๅˆ†ๅฎž็Žฐ๏ผˆๅทฒ่ฝๅœฐๆ•ดๆ”นๆ–‡ๆกฃ๏ผ›F18๏ผ‰ | READMEใ€runbookใ€ๅˆ†็ฑป่ฎกๅˆ’ๅ’Œ่ƒฝๅŠ›็Ÿฉ้˜ต้œ€้šๆ•ดๆ”นๅŒๆญฅ๏ผ›็›‘ๆŽงๅฎž็ŽฐไธŽๅฎŒๆˆๅฃฐๆ˜Ž้กปๆ ธๅฎž๏ผŒ็œŸๅฎž็ฐๅบฆ่ฏๆฎไปๅพ…่กฅใ€‚ | +| SWT-R13 | P1 | ้ƒจๅˆ†ๅฎž็Žฐ๏ผˆๅ“ๅบ”ๆ•ฐ็ป„ๅทฒไฟฎ๏ผŒๅŒๆ•ฐๆฎๆบๆœช็ปŸไธ€๏ผ›F16๏ผ‰ | ไฟฎๅคๅŽŸ็”Ÿไผš่ฏๆ ‡็ญพๆ›ดๆ–ฐ่ฟ”ๅ›žๅฏน่ฑกใ€ๅ‰็ซฏ้ข„ๆœŸๆ•ฐ็ป„็š„ๅฅ‘็บฆไธๅŒน้…๏ผ›้ชŒ่ฏๆ™ฎ้€šๆ›ดๆ–ฐใ€ๆ‰น้‡ๅขžๅˆ ใ€็ญ›้€‰ใ€GETใ€ๅบๅˆ—ๅŒ–็š„ๆ•ฐๆฎๆบไธ€่‡ดๆ€งใ€‚ | +| SWT-R14 | P1 | ้ƒจๅˆ†ๅฎž็Žฐ๏ผˆๅ›ž่ฐƒ/ไปฃ้™…/ๅŽŸๅญๆไบค็ผบ้™ท๏ผ›F02โ€“F07/F11๏ผ‰ | ่ต„ๆ–™ๆ“ไฝœๅ’Œ catalog ๅŒๆญฅ่กฅๆŒไน…ๅŒ–ๅ…ณ่”ใ€้‡ๅค/ไนฑๅบไฟๆŠคใ€ๅŽŸๅญๆ›ดๆ–ฐๅŠๆœ€็ปˆ็Šถๆ€๏ผ›ๅˆ†็ฑปๅ›ž่ฐƒๅฟ…้กปๅŒน้…ๅทฒๆŒไน…ๅŒ– operation ็š„ eventใ€็›ฎๆ ‡ๅ’Œๅ€ผ๏ผŒๆœช็Ÿฅ/้”™็›ฎๆ ‡ๅ›ž่ฐƒๆ‹’็ปใ€‚ | +| SWT-R15 | P1 | ้ƒจๅˆ†ๅฎž็Žฐ๏ผˆๅพ…็”Ÿๅ‘ฝๅ‘จๆœŸ้ชŒๆ”ถ๏ผ‰ | ่กฅ่”็ณปไบบๅˆๅนถ/่ฝฏๅˆ ้™คใ€inbox ๅˆ ้™ค/ๅœ็”จ/้‡็ป‘ๅฎšใ€้…็ฝฎ็‰ˆๆœฌๅ˜ๅŒ–ๆ—ถ๏ผŒ่ต„ๆ–™ๅฝ’ๅฑžไธŽ pending ๆ“ไฝœ/ๆ—งๅ›ž่ฐƒ็š„่ฟ็งปๆˆ–็ปˆๆญข่ง„ๅˆ™ใ€‚ | +| SWT-R16 | P1 | ้ƒจๅˆ†ๅฎž็Žฐ๏ผˆๅพ…ๅ…จ้‡ๆƒ้™้ชŒๆ”ถ๏ผ‰ | ่กฅๅŽŸ็”Ÿๅง“ๅ/ๅค‡ๆณจ/ๆ ‡็ญพ/ๅˆๅนถ/ๅˆ ้™ค็š„็œŸๅฎž่ทฏ็”ฑๆƒ้™ใ€็ˆถ่”็ณปไบบๆœ‰ๆ•ˆๆ€งไธŽ่ทจ็งŸๆˆทๆ ก้ชŒ๏ผ›ๅค็”จ็Žฐๆœ‰ๅ…ญไธชไบŒๅ€ผๆƒ้™๏ผŒไธๆ–ฐๅขžไธ‰ๆ€ๆˆ– CE/EE ้™ๅˆถใ€‚ | +| SWT-R17 | P1 | ้ƒจๅˆ†ๅฎž็Žฐ๏ผˆๆŒไน…็Šถๆ€ๆขๅค/็‹ฌ็ซ‹ๅญ—ๆฎตๅพ…ไฟฎ๏ผ›F15๏ผ‰ | ่กฅๅˆ†็ฑป็ป„ไปถๅˆ‡ๆขไผš่ฏ็ซžๆ€ใ€็‹ฌ็ซ‹ๆ“ไฝœ็Šถๆ€ใ€ๅปถ่ฟŸ็กฎ่ฎคใ€catalog ๅคฑๆ•ˆๅŠๅˆทๆ–ฐๅŽๆขๅค๏ผ›ๆ—ง่ฏทๆฑ‚ไธๅพ—ๆ›ดๆ–ฐๆ–ฐไผš่ฏ UIใ€‚ | +| SWT-R18 | P1 | ้ƒจๅˆ†ๅฎž็Žฐ๏ผˆไธ็กฎๅฎš/่€—ๅฐฝ่กฅๅฟ/catalog ๆขๅคๅพ…ๅฎž็Žฐ๏ผ›F03/F08โ€“F11/F18๏ผ‰ | ๅค็”จ็Žฐๆœ‰ๆ“ไฝœ็ป“ๆžœ้˜Ÿๅˆ—่กฅ renameใ€็ป“ๆžœ้‡่ฏ•่€—ๅฐฝ่กฅๅฟใ€catalog ๅ›žไผ ๅคฑ่ดฅๅฏๆขๅคๆ€ง๏ผ›ไฟ็•™ไธ็กฎๅฎš่ฏๆฎ๏ผŒ่กฅ่ดฆๅท็บง้˜Ÿๅคด้˜ปๅกž็›‘ๆŽงๅ’Œไบบๅทฅ็ป“ๆžœ้‡ๆ”พ้ชŒๆ”ถใ€‚ | ### 3.1 ๅฎกๆŸฅ็กฎ่ฎค็š„ๅ…ทไฝ“็ผบๅฃ -ไปฅไธ‹ไธบ้™ๆ€่ฏๆฎ๏ผŒไธ่กจ็คบๅทฒ้€š่ฟ‡ๅคฑ่ดฅๅค็Žฐๆต‹่ฏ•๏ผ›ๅฎž็Žฐๆ—ถไปฅ็ฌฆๅทๅ’Œๅฎž้™…ๅทฅไฝœๆ ‘ๅคๆ ธ๏ผŒไธ่ƒฝๅฐ†ๆœฌ่Š‚็›ดๆŽฅ่ฎกไธบไฟฎๅคๅฎŒๆˆใ€‚ +ไปฅไธ‹ๆŒ‰ 2026-09-13 ๅฎžๆ–ฝๅŽๅคๅฎกๆ›ดๆ–ฐ๏ผ›้™คๅˆ†็ฑปๅŒ event ๅผ‚็›ฎๆ ‡ๅ€ผๅทฒ่ฟ่กŒๅค็Žฐๅค–๏ผŒๅ‡ไธบๆบ็ /่ฐƒ็”จ้“พ่ฏๆฎ๏ผŒไธ่กจ็คบๅทฒๆ‰ง่กŒๅฏนๅบ”ๅนถๅ‘ๆˆ–็ซฏๅˆฐ็ซฏๆต‹่ฏ•ใ€‚ไฟฎๅค็”จไพ‹ใ€้กบๅบไธŽ่ฏๆฎๅฃๅพ„่งๆ•ดๆ”น่ฎกๅˆ’๏ผ›ไธ่ƒฝๅฐ†ๆœฌ่Š‚็›ดๆŽฅ่ฎกไธบๅฎŒๆˆใ€‚ | ่ฏๆฎไฝ็ฝฎ | ๅฝ“ๅ‰ไบ‹ๅฎž | ่กฅๅ……็บฆๆŸ | | --- | --- | --- | -| `ConversationHandler.UpdateLabels`๏ผ›`dashboard/store/modules/conversationLabels.js`๏ผ›ไธŠๆธธ `conversations/labels/create.json.jbuilder` | ๆ›ดๆ–ฐ่ฟ”ๅ›ž `payload: {conversationId, labels}`๏ผŒstore ็›ดๆŽฅไฟๅญ˜ payload๏ผŒๆถˆ่ดน่€…้œ€่ฆๆ•ฐ็ป„๏ผ›ไธŠๆธธ่ฟ”ๅ›žๆ•ฐ็ป„ | R13 ไฟฎๅค็œŸๅฎžๅ“ๅบ”๏ผ›mock action ๆต‹่ฏ•้€š่ฟ‡ไธ่ถณไปฅ่ฏๆ˜Žๅ…ผๅฎน | -| `ShangwutongConnectorHandler.UpdateClassificationStatus` | ๆ ก้ชŒๅน‚็ญ‰ header ๅ’Œ event_id ็š„ๆ‹ผๆŽฅ๏ผŒไฝ†ๆœชๆ ธๅฏนๆŒไน…ๅŒ– pending ๆ“ไฝœ๏ผ›่ฏปๆ”นๅ†™ๆ•ดไปฝ `additional_attributes` | R14 ็ป‘ๅฎšๅฎž้™…ๆ“ไฝœใ€็›ฎๆ ‡ๅŠๅญ—ๆฎต๏ผŒ้˜ฒไผช้€ ๅ…ณ่”ใ€้‡ๅคใ€ไนฑๅบๅ’Œไธขๅคฑๅนถๅ‘ๅฑžๆ€ง | -| ๅŒไธŠ๏ผ›`UpdateClassificationCatalog/UpdateClassificationSyncStatus` | ไผš่ฏๅˆ†็ฑปไธŽ้ขœ่‰ฒๅ…ฑไบซไธ€ไธช็Šถๆ€ๆงฝ๏ผ›catalog ไป…่ฎฐๆœ€่ฟ‘ๆˆๅŠŸ event_id๏ผŒๅคฑ่ดฅๅ›ž่ฐƒๆ— ไปฃ้™…็บฆๆŸ | ไธๅŒๆ“ไฝœไธๅพ—ไบ’็›ธๅž็Šถๆ€๏ผŒๆ—งๅคฑ่ดฅไธ่ƒฝ่ฆ†็›–ๆ–ฐๅŒๆญฅๆˆๅŠŸ๏ผŒ้‡ๅคๅ›ž่ฐƒไธๅˆทๆ–ฐ็‰ˆๆœฌ | -| `ShangwutongClassifications.vue` ็š„ `waitForConfirmation/fetchClassifications` | ไฟๅญ˜ๅŽๆœ€ๅคš 10 ๆฌกใ€้—ด้š” 500ms ๆŸฅ่ฏข๏ผ›ๅผ‚ๆญฅๆต็จ‹ๆŒ็ปญ่ฏปๅ–ๅฝ“ๅ‰ props๏ผ›ๆœช็กฎ่ฎคๅŽไปๅฏๅ†ๆฌกๆ“ไฝœ | ๅ›บๅฎš่ฏทๆฑ‚็›ฎๆ ‡ๅนถๅฟฝ็•ฅๅคฑๆ•ˆๅ“ๅบ”๏ผ›ๅŒบๅˆ†ไป pendingใ€failedใ€uncertain๏ผŒๅˆทๆ–ฐๅŽๅฏๆขๅค๏ผŒไธ่ƒฝๅ› ๅ‰็ซฏ็ญ‰ๅพ…็ป“ๆŸๅˆคๅคฑ่ดฅ | -| `ContactLabels.vue` ็š„ mounted/watch | ๅˆๆฌกๅŠ ่ฝฝไพ่ต–่ทฏ็”ฑ contactId๏ผŒ่€Œไผš่ฏไพงๆ ๅฏ่ƒฝๅชไผ  prop | R08 ๅค็”จ็ป„ไปถๅ‰่กฅ้ฆ–ๆฌก prop ๅŠ ่ฝฝใ€ๅˆ‡ๆข้š”็ฆป๏ผ›ๅ…จ้‡ๆ›ฟๆขๆ ‡็ญพๅ‰ๅฟ…้กป่ฏปๅˆฐๅฎŒๆ•ดๆ—ง้›†ๅˆ | -| `ContactService.GetNote/UpdateNote/DeleteNote`๏ผ›ไธŠๆธธ contacts/base_controller | ๅ•ๆกๆ“ไฝœๆฃ€ๆŸฅๅค‡ๆณจๅฝ’ๅฑž๏ผŒไฝ†ๆœช็ปŸไธ€ๅŠ ่ฝฝๆœ‰ๆ•ˆ็ˆถ่”็ณปไบบ๏ผ›ไธŠๆธธๅ…ˆๅŠ ่ฝฝ่ดฆๆˆทๅ†…่”็ณปไบบ | R16 ่กฅ่ฝฏๅˆ ้™คๅŽ็š„ๅ•ๆก่ฎฟ้—ฎ่ดŸๅ‘็”จไพ‹๏ผŒไธŽๅˆ—่กจ/ๆ–ฐๅขž่ฏญไน‰ไธ€่‡ด | -| Connector `swt/operations.go:ChangeContactName`๏ผ›ๅ‚่€ƒ Android `RenamedThread.java` | Go ๅฎž็Žฐๆ˜พๅผๅ‘้€ `cnote: ""`๏ผŒAndroid ไผ ๅ…ฅ่ฐƒ็”จ่€…ๆไพ›็š„ๅค‡ๆณจ๏ผ›ๆœๅŠกๅ™จๆธ…็ฉบ่ฏญไน‰ๅฐšๆœชๅฎžๆต‹ | R02 ไธ่ƒฝๆ–ญ่จ€ไธ€ๅฎšๆธ…็ฉบๆˆ–่ฎคๅฎš็ฉบๅ€ผๅฎ‰ๅ…จ๏ผ›็ผบ็œ/็ฉบไธฒ/ๅŽŸๅ€ผไธ‰็งๅฝขๆ€ๅฟ…้กปๆœ‰ๅ่ฎฎ่ฏๆฎ | -| Connector `delivery/inbound.go`ใ€`db/queries/inbound.sql` | ๅทฒๆœ‰ CIDโ†’GoChat ็š„ๅปถๆœŸๅ›ž่ฐƒ๏ผŒ่”็ณปไบบๅฐšๆœชๅˆ›ๅปบๅฏ็ญ‰ๅพ…่‡ณไบ‹ไปถๅˆ›ๅปบๅŽ 24 ๅฐๆ—ถ๏ผ›ไธๆ˜ฏ็ผบ CID ๆ—ถ็š„ไบบๅทฅๆ”นๅๆ„ๅ›พ้˜Ÿๅˆ— | R04 ไฟ็•™ๅทฒๆœ‰ๆœบๅˆถ๏ผŒไป…่กฅ็›ธๅๆ–นๅ‘็ผบๅฃๅŠๅˆฐๆœŸๅค„็ฝฎ | -| `db/queries/operations.sql`ใ€`delivery/outbound.go` | ็ป“ๆžœ้˜Ÿๅˆ—็›ฎๅ‰ไป…่ฆ†็›–ๅˆ†็ฑป/้ขœ่‰ฒ๏ผ›ๅ›ž่ฐƒ่พพ 10 ๆฌกๅŽ่ฟ›ๅ…ฅ failed๏ผŒ้‡ๅฏไป…ๆขๅค syncing๏ผ›pending/syncing ็ป“ๆžœไผš้˜ปๆŒกๅŒ่ดฆๅทๅŽ็ปญๆ“ไฝœ | R18 ๆ‰ฉๅฑ•็Žฐๆœ‰ๆœบๅˆถ๏ผŒ่กฅไป…้‡ๆ”พ็ป“ๆžœ็š„ไบบๅทฅ่กฅๅฟ๏ผŒไธ้‡ๅค่ฟœ็จ‹ๅ†™ | -| ๅŒไธŠ | uncertain ่ง‚ๅฏŸ็ช—ๅฃไธบ 5 ๅˆ†้’Ÿ๏ผŒ่ถ…ๆ—ถๅ†™ failed/`uncertain_timeout`๏ผ›ไพ่ต–็š„ updated_at ไนŸไผš่ขซๅ›ž่ฐƒ้‡่ฏ•ๆ›ดๆ–ฐ | ่ถ…ๆ—ถไป้ž่ฟœ็จ‹ๆ‹’็ป่ฏๆฎ๏ผ›ๅ›บๅฎšๆˆชๆญข้œ€็‹ฌ็ซ‹ๆ—ถ้—ดๅญ—ๆฎต๏ผŒไธ่ƒฝ่ฎฉ้‡่ฏ•ๆ— ้™ๅปถๅŽ่ง‚ๅฏŸ็ช—ๅฃ | -| `account/manager.go:SyncClassifications`ใ€`httpapi/server.go` | catalog ๅŒๆญฅ็›ดๆŽฅๆ‰ง่กŒๅนถๅ›žไผ ๏ผŒๅคฑ่ดฅ็Šถๆ€ๅ›žไผ ้”™่ฏฏ่ขซๅฟฝ็•ฅ๏ผŒไธ่ตฐๅˆ†็ฑปๆ“ไฝœ็ป“ๆžœ้˜Ÿๅˆ— | R18 ๅ•็‹ฌ่กฅ catalog ๆ•…้šœๆขๅค๏ผŒไธ่ƒฝๅฅ—็”จไธšๅŠกๆ“ไฝœๆŒไน…ๅ›žไผ ไฟ่ฏ | +| `ConversationHandler.UpdateLabels`ใ€conversation label repositories/store | ๆ™ฎ้€šๅ“ๅบ”ๅทฒๆ”นไธบๆ•ฐ็ป„๏ผ›ๆ™ฎ้€šๆ›ดๆ–ฐไธŽๆ‰น้‡/ๅ…ณ่”ๅˆ ้™คไปไฝฟ็”จไธๅŒๆ•ฐๆฎๆบ | R13/F16 ่กฅๆททๅˆ็œŸๅฎž่ทฏ็”ฑไธ€่‡ดๆ€ง | +| `ShangwutongConnectorHandler.UpdateClassificationStatus`ใ€่”็ณปไบบ็ป“ๆžœ handler | ๅˆ†็ฑปๅทฒๆฃ€ๆŸฅไบ‹ไปถไฝ†ๆœชๆ‹’็ปๅŒ event ๅผ‚็›ฎๆ ‡ๅ€ผ๏ผ›่”็ณปไบบๆ— ๅฎž้™… operation ไนŸ่ƒฝๆŽฅๅ—็ป“ๆžœ | R14/F02 ้”ๅ†…็ป‘ๅฎš็œŸๅฎžๆ“ไฝœไธŽไธๅฏๅ˜็›ฎๆ ‡ | +| ๅˆ†็ฑป pending helperใ€`shangwutong_webhook_delivery.go` | ๅˆ†็ฑป็Šถๆ€ไธŽไปปๅŠกไปๅˆ†ๅผ€ๆไบค๏ผ›่ต„ๆ–™ webhook ๆŠ•้€’ๆฐธไน…ๅคฑ่ดฅ็ผบๅฐ‘ๅฏนๅบ”็Šถๆ€ๆ”ถๅฐพ | F03 ๅŒไบ‹ๅŠกๅ…ฅ้˜Ÿใ€ไบ‹ไปถๅŒน้…่กฅๅฟ๏ผŒๅŒบๅˆ†ๆ˜Ž็กฎๆ‹’็ปไธŽๆŠ•้€’ไธๆ˜Ž | +| `shangwutong_contact_listener.go` | ๆ”นๅ็Šถๆ€ไธŽๅ…ฅ้˜ŸๅทฒๅŒไบ‹ๅŠก๏ผ›ไฝ†็”จ่ฏทๆฑ‚ๆ—ถ้—ดไธŽๆ—งๅ›ž่ฐƒ updated_at ๆฏ”่พƒ๏ผŒๅฏ่ƒฝๅ‘้€ๆ–ฐๆ“ไฝœๅดไธๆŽฅ็บณๅ…ถ็Šถๆ€ | F04 ไฝฟ็”จไธๅฏๅ˜่ฏทๆฑ‚ไปฃ้™… | +| ๅˆ†็ฑป้ขœ่‰ฒไผ ๆ’ญใ€catalog handlers | ้ขœ่‰ฒไธป่ฆๆŒ‰ contact-inbox ่€Œ้žๅŒ inbox/CID ๅคš SID๏ผ›peer ๅ…ˆๅ†™ๅŽๅ‘็Žฐ stale ๅฏ้ƒจๅˆ†ๆไบค๏ผ›catalog ไปฃ้™…ๆฃ€ๆŸฅไธŽ Save ้žๅŽŸๅญ | F05โ€“F07 ่กฅๅŽŸๅญไปฃ้™…ใ€ไผ ๆ’ญไธŽ่บซไปฝ่Œƒๅ›ด | +| `ShangwutongClassifications.vue` | ๅทฒๆ•่Žท่ฏทๆฑ‚็›ฎๆ ‡ๅ’ŒๅŒบๅˆ†็Šถๆ€ๆงฝ๏ผ›ๅˆทๆ–ฐไธๆขๅคๆŒไน…็Šถๆ€๏ผŒไธ€ๅญ—ๆฎตๅคฑ่ดฅๅฏ้‡็ฝฎๅฆไธ€ๅญ—ๆฎต๏ผŒๆŸฅ่ฏข็ป“ๆŸๆŽจๆ–ญ uncertain | F15 ่กฅ็‹ฌ็ซ‹็Šถๆ€ไธŽ็ป„ไปถ็”จไพ‹ | +| `ContactLabels.vue`ใ€`ContactService.GetNote/UpdateNote/DeleteNote` | ๅทฒๆ”น่ฟ› prop ้ฆ–ๆฌกๅŠ ่ฝฝๅ’Œๆœ‰ๆ•ˆ็ˆถ่”็ณปไบบๆ ก้ชŒ | ไฟ็•™ไฟฎๅค๏ผ›R16 ๅฎŒๆ•ดๆƒ้™/็”Ÿๅ‘ฝๅ‘จๆœŸ้ชŒๆ”ถไปๅพ…ๅฎŒๆˆ | +| ไธคๅค„ `ContactNotes.vue`ใ€`Editor.vue` | ๆ–ฐ็ผ–่พ‘ๅผน็ช—็ปงๆ‰ฟ 200 ๅญ—็ฌฆๆˆชๆ–ญ๏ผ›ไผš่ฏ็ผ–่พ‘ๅฟซๆท้”ฎ่ตฐ create๏ผ›ๅˆ‡ๆข/ๅคฑ่ดฅ่‰็จฟไปๆœ‰็ผบ้™ท | F12/F13 ้˜ฒๆญขๆ•ฐๆฎๆŸๅคฑๅนถ่กฅ็ป„ไปถๆต‹่ฏ• | +| Connector `ChangeContactName`๏ผ›ๅ‚่€ƒ `RenamedThread.java` | ๅทฒ็œ็•ฅ็ฉบ cnote๏ผŒไฝ†็œ็•ฅ่ฏญไน‰ๆœช้ชŒ่ฏๆ—ถไปๅ‘้€่ฟœ็จ‹ๆ”นๅ | R02/F01 ๅฟ…้กปๆœ‰ๅฎž้™… fail-closed ้—จ็ฆ๏ผŒไธๆ–ญ่จ€ๅทฒ่ฏๅฎžๅค‡ๆณจๆŸๅ | +| Connector `processOperation`ใ€CID wake SQL | ๅทฒๆœ‰็ญ‰ๅพ…ไธŽๅ”ค้†’๏ผ›ๆฃ€ๆŸฅๅœจ WithSession ๅ†…๏ผŒๆ–ญ่ฟžๅฏๆๅ‰่€—ๅฐฝ๏ผ›ๆ™šๅˆฐ CID ๅฏ็ป•่ฟ‡ 24h ่ฟ‡ๆœŸ | F08 ๅˆ†็ฆป็ญ‰ๅพ…ไธŽๅ‘้€้ข„็ฎ—ๅนถๆŒไน…ๆ‰ง่กŒๆˆชๆญข | +| operation ็ป“ๆžœ SQLใ€`processOperationResult` | ็ป“ๆžœ้˜Ÿๅˆ—ๅทฒๆ‰ฉๅฑ• rename๏ผ›uncertain ่ถ…ๆ—ถไปๅ˜ failed๏ผŒๆˆชๆญขๅ— updated_at ๅฝฑๅ“๏ผ›่€—ๅฐฝๅŽๆ— ็ป“ๆžœ่กฅๅฟๅ…ฅๅฃ | F09/F10 ไฟ็•™่ฏๆฎใ€ๅ›บๅฎš่ง‚ๅฏŸๆˆชๆญขใ€ไป…็ป“ๆžœ่กฅๅฟ | +| `SyncClassifications`ใ€`httpapi/server.go` | catalog ๅŒๆญฅ็›ดๆŽฅๆ‰ง่กŒ๏ผŒๅคฑ่ดฅ็Šถๆ€ๅ›žไผ ้”™่ฏฏ่ขซๅฟฝ็•ฅ | F11 ่กฅๆŒไน…ๆขๅค๏ผŒไธๅฅ—็”จไธšๅŠกๆ“ไฝœ็ป“ๆžœไฟ่ฏ | +| ่”็ณปไบบ public APIใ€serializerใ€ๅ‰็ซฏๆ”นๅๅ…ฅๅฃ | ๆฅๆบ header ไธŽไบ‹ไปถๆต‹่ฏ•ๅฐšๆœช่ดฏ้€š็œŸๅฎž่ทฏๅพ„๏ผ›ๅทฒๅบๅˆ—ๅŒ–ๆ”นๅ็Šถๆ€ๆ— ๅ‰็ซฏๆถˆ่ดน่€… | F14/F17 ่กฅๅฎž้™… UI/่ทฏ็”ฑ่ฏๆฎ๏ผŒไธๅฎฃ็งฐ็Žฐๆœ‰่ทฏๅพ„ๅทฒๅ‘็”Ÿๅพช็Žฏ | +| Connector metrics ไธŽ runbook | ้ƒจๅˆ†่ง„ๅˆ’ๆŒ‡ๆ ‡ๆ— ็”Ÿไบง่€… | F18 ๅฆ‚ๅฎžๆ ‡ๆ˜Žๅพ…ๅฎž็Žฐ๏ผŒๅนถ่กฅๆ“ไฝœ้˜Ÿๅˆ—ๅ‘Š่ญฆ/่กฅๅฟ | ### 3.2 ๅฎžๆ–ฝๅ‰ๅฟ…้กปๅ†ป็ป“็š„ๅฅ‘็บฆ @@ -107,10 +112,10 @@ - [ ] ๅ…ˆไธบโ€œไป…ๆ”นๅง“ๅใ€ไธๆŸๅๅทฒๆœ‰่ฟœ็จ‹ๅค‡ๆณจโ€ๅปบ็ซ‹ๅ่ฎฎ้—จ็ฆ๏ผ›ๅ่ฎฎๆœช็Ÿฅๆ—ถ้˜ปๆญขๅฑ้™ฉ่ฏทๆฑ‚๏ผŒไธ่ƒฝ็ญ‰ๅพ…ๅŒๅ‘ๅค‡ๆณจ่Žทๆ‰นๅ†ๅค„็†ใ€‚ - [ ] ไป…ๅœจ R01 ๆ‰นๅ‡†ๅŽๆ‰ฉๅฑ• `contact_updated` ๅŠๆถˆ่ดน็ซฏ `cnote` ่งฃ็ ใ€ๅ…ฅ็ซ™ๆ˜ ๅฐ„ๅ’ŒๆŒไน…ๅŒ–๏ผ›ๅ•ๅŠ  webhook ๅญ—ๆฎตไธ่ถณไปฅๆ‰“้€š้“พ่ทฏใ€‚ -- [ ] ไธบ rename ๅปบ็ซ‹ๅฏๆŸฅ่ฏข็š„ GoChat ็ป“ๆžœ็Šถๆ€๏ผš`pending`ใ€`succeeded`ใ€`failed`ใ€`uncertain`๏ผ›ๅค็”จ `outbound_operations` ็ปˆๆ€/็ป“ๆžœๅ›žไผ ๏ผŒไธๆ–ฐๅปบไธ€ๅฅ—้€š็”จ่ฐƒๅบฆ็ณป็ปŸใ€‚ -- [ ] ็ป“ๆžœๅ›ž่ฐƒๅ…ทๆœ‰ๅน‚็ญ‰้”ฎๅ’ŒๆŒไน…้‡่ฏ•๏ผ›่ฟœ็จ‹ๅทฒๆ‰ง่กŒไฝ†ๅ“ๅบ”ไธขๅคฑ็ปดๆŒไธ็กฎๅฎšใ€็ฆๆญข่‡ชๅŠจ้‡ๅ‘ๅ‰ฏไฝœ็”จใ€‚่ถ…่ฟ‡็ป“ๆžœ่กฅไผ ไธŠ้™ๅฏๅ‘Š่ญฆๅนถไป…้‡ๆ”พๅŽŸ็ป“ๆžœ๏ผŒ้‡ๅฏไธ็ญ‰ไบŽๆญปไฟกๅทฒๆขๅคใ€‚ -- [ ] CID ็ผบๅคฑๆ—ถๅฐ†ๅง“ๅไฟฎๆ”นๆ”พๅ…ฅๅพ…ๅค„็†้˜Ÿๅˆ—๏ผ›CID ๅˆฐ่พพๅŽๆŒ‰่”็ณปไบบ/inbox/source ๅน‚็ญ‰้‡ๆ”พใ€‚ -- [ ] ๅฏน่ฟœ็จ‹ๅ…ฅ็ซ™ๅง“ๅ/ๅค‡ๆณจๆ›ดๆ–ฐๅขžๅŠ ๆฅๆบๆ ‡่ฎฐ๏ผŒ้ฟๅ…ๆŠŠ่ฟœ็จ‹ๅŒๆญฅ่ฏฏๅˆคไธบๆ–ฐ็š„ไบบๅทฅไฟฎๆ”นใ€‚ +- [ ] ไธบ rename ๅปบ็ซ‹ๅฏๆŸฅ่ฏขไธ” UI ๅฏ่ง็š„ `pending/succeeded/failed/uncertain`๏ผ›ๅทฒๆœ‰็ป“ๆžœ้˜Ÿๅˆ—ไป้œ€ไฟฎๅคๅ…ณ่”ใ€ไปฃ้™…ๅ’Œ UI๏ผˆF02/F04/F14๏ผ‰ใ€‚ +- [ ] ็ป“ๆžœๅ›ž่ฐƒๅน‚็ญ‰ไธ”ๆŒไน…้‡่ฏ•๏ผ›ๅ“ๅบ”ไธขๅคฑไฟๆŒไธ็กฎๅฎš๏ผŒไธ้‡ๅค่ฟœ็จ‹ๅ‰ฏไฝœ็”จ๏ผ›ๅฎž็Žฐ่€—ๅฐฝๅ‘Š่ญฆๅŠไป…็ป“ๆžœ่กฅๅฟ๏ผˆF09/F10/F18๏ผ‰๏ผŒไธๆ˜ฏไป…ๅพ…ๆผ”็ปƒใ€‚ +- [ ] CID ็ผบๅคฑไฟ็•™ๆ“ไฝœใ€ๅˆฐ่พพๅ”ค้†’ใ€24h ่ฟ‡ๆœŸ๏ผ›่กฅๆ–ญ่ฟž้‡่ฏ•/ๆ™šๅˆฐ CID/่ฟž็ปญๆ”นๅๅ’Œ้‡ๅฏ็”จไพ‹๏ผˆF04/F08๏ผ‰๏ผŒๆ—ขๆœ‰ helper ๆต‹่ฏ•ไธไปฃ่กจๅฎŒๆ•ด็ญ‰ๅพ…็ญ–็•ฅ้€š่ฟ‡ใ€‚ +- [ ] ๆฅๆบๆ ‡่ฎฐ้กป้€š่ฟ‡็œŸๅฎž Connector public API ๅˆฐไฟๅญ˜/ไบ‹ไปถ/listener ้“พ่ทฏ้ชŒ่ฏ๏ผˆF17๏ผ‰๏ผ›ๅฝ“ๅ‰่ทฏๅพ„ไธๅ‘ไบ‹ไปถไธ่ƒฝ่ขซๆ่ฟฐไธบๅทฒ้ชŒ่ฏๅฎŒๆ•ดไบ‹ไปถไผ ๆ’ญใ€‚ ### ้˜ถๆฎต 2๏ผšๅ•†ๅŠก้€šๅˆ†็ฑปไธ€่‡ดๆ€ง @@ -122,8 +127,8 @@ - [ ] ๅˆ†็ฑปไฟฎๆ”นๅคฑ่ดฅ/ไธ็กฎๅฎšๆ—ถไฟ็•™ๆ˜Ž็กฎ็Šถๆ€๏ผŒไธๆ˜พ็คบ่™šๅ‡ๆˆๅŠŸใ€‚ - [ ] ่ฏ„ไผฐๅนถๅฎž็Žฐ `RESET` ๆธ…้™ค่ƒฝๅŠ›๏ผ›ๅ่ฎฎๆœช้ชŒ่ฏๅ‰็ปง็ปญไฟๆŒ็ฆ็”จใ€‚ - [ ] ไธบๅˆ†็ฑปไฟฎๆ”น่กฅๅ……่ง’่‰ฒๆƒ้™ๆ ก้ชŒๅ’Œ่ทจ inbox/่ทจ account ่ดŸๅ‘ๆต‹่ฏ•ใ€‚ -- [ ] R14๏ผšไฟๅญ˜ๅฎž้™… pending ๆ“ไฝœๅนถๆ ธๅฏน็ป“ๆžœๅ…ณ่”๏ผ›catalog ่ฏทๆฑ‚/ๅ›ž่ฐƒๆŒ‰ไปฃ้™…ๅค„็†๏ผŒๅŽŸๅญๆ›ดๆ–ฐๅฑžๆ€ง๏ผŒไธไธขๅนถ่กŒๆถˆๆฏ/ๅˆ†็ฑปไบง็”Ÿ็š„ๆ— ๅ…ณๅญ—ๆฎตใ€‚ -- [ ] R17๏ผšๅˆ†็ฑปไธŽ้ขœ่‰ฒๅˆ†ๅˆซๅฑ•็คบๅทฒ็กฎ่ฎคๅ€ผใ€่ฏทๆฑ‚ๅ€ผใ€pending/ๅคฑ่ดฅ/ไธ็กฎๅฎš๏ผ›ๅˆ‡ๆขไผš่ฏ/inbox/ๅธ่ฝฝๅŽๆ—งๆŸฅ่ฏขไธๅฏๆฑกๆŸ“ๅฝ“ๅ‰ UIใ€‚catalog ๅ็งฐๅ˜ๆ›ดใ€ID ๅˆ ้™ค/ๆœช็Ÿฅ/่ฟ‡ๆœŸๅ’Œๅคฑ่ดฅๅŽๆ—ง็ผ“ๅญ˜็š„ๅฏ็”จ็ญ–็•ฅ้กปๆ˜Ž็กฎใ€‚ +- [ ] R14๏ผšไฟๅญ˜ๅฎž้™…ๆ“ไฝœๅนถๆ ธๅฏน็ป“ๆžœ็›ฎๆ ‡๏ผ›catalog ไปฃ้™…ๅ’Œไผ ๆ’ญๅ†™ๅ…ฅๅŽŸๅญๅŒ–๏ผŒ่กฅ F02โ€“F07/F11 ็š„ๅคฑ่ดฅไธŽๅนถๅ‘ๅ›žๅฝ’๏ผŒไธไธขๆ— ๅ…ณๅฑžๆ€งใ€‚ +- [ ] R17๏ผšๅˆ†ๅˆซๆขๅคๅ’Œๅฑ•็คบๅทฒ็กฎ่ฎคๅ€ผใ€่ฏทๆฑ‚ๅ€ผๅŠๆŒไน…็Šถๆ€๏ผ›ไป…้‡็ฝฎๅคฑ่ดฅๅญ—ๆฎต๏ผŒๅœๆญขๅ‰็ซฏ็ญ‰ๅพ…ไธๆ”นๅ˜่ฟœ็จ‹็ป“ๆžœ๏ผ›่กฅ F15 ็ป„ไปถ/ๅˆทๆ–ฐ/ๅˆ‡้กต/catalog ๅคฑๆ•ˆ็”จไพ‹ใ€‚ ### ้˜ถๆฎต 3๏ผšGoChat ๅŽŸ็”Ÿ่ต„ๆ–™่ƒฝๅŠ› @@ -191,16 +196,16 @@ | ๅฎžๆ—ถๆถˆๆฏๅŠๆœ€็ปˆ็Šถๆ€ | ๅทฒๆœ‰้“พ่ทฏ๏ผ›ๆœฌ่ฝฎๅช่ท‘ๆœฌๅœฐๅ›žๅฝ’๏ผŒๆœช้‡ๆ–ฐ้ชŒ่ฏๆ‰€ๆœ‰็œŸๅฎžๆถˆๆฏ็ฑปๅž‹ | R10/R11๏ผŒV23/V24 | | kind=52 ่ขซๅŠจๅކๅฒ | ไป…ๆๅ– CID๏ผŒไธๅฏผๅ…ฅๅކๅฒๆญฃๆ–‡๏ผ›ไฟ็•™ๅŽŸๅง‹ไบ‹ไปถไธ็ญ‰ไบŽ้กต้ขๅฏ่ฏปๅކๅฒ | R10๏ผŒV21 | | ไธปๅŠจๅކๅฒๆ‹‰ๅ–ใ€็ผ–่พ‘ใ€ๅๅบ” | ๆœช่งๅฎŒๆ•ด่ƒฝๅŠ›๏ผ›้€้กนๅพ…่Œƒๅ›ด็กฎ่ฎค๏ผŒไธ่ƒฝไธŽๆ’คๅ›žๆททๅŒ | R10๏ผŒV22 | -| ไบบๅทฅๅง“ๅๅ‡บ็ซ™ | ๅทฒๆœ‰ๅผ‚ๆญฅๆ‰ง่กŒ๏ผ›็ผบๆœ€็ปˆ็Šถๆ€ใ€็ผบ CID ๆ„ๅ›พไฟๅ…จ๏ผŒๅฆๆœ‰็ฉบๅค‡ๆณจ้ฃŽ้™ฉ | R02โ€“R04/R18๏ผŒV01/V03โ€“V06 | -| ่ฟœ็จ‹ๅง“ๅ/ๅค‡ๆณจๅ…ฅ็ซ™ | ไบ‹ไปถๅญ—ๆฎต/ๆฅๆบไธŽๅ†ฒ็ชๅฅ‘็บฆๅพ…้ชŒ่ฏ๏ผŒไธๆŠŠๆ™ฎ้€šๆถˆๆฏๅธฆๅง“ๅๅฝ“ไฝœๅฎŒๆ•ด่ต„ๆ–™ไบ‹ไปถๅŒๆญฅ | R01/R02๏ผŒV02/V07 | +| ไบบๅทฅๅง“ๅๅ‡บ็ซ™ | ๅทฒๆœ‰ durable ้“พ่ทฏ๏ผ›ๅ่ฎฎๅฎ‰ๅ…จ้—จ็ฆใ€CID ๆˆชๆญขใ€็ป“ๆžœๆขๅคๅŠ UI ๅฑ•็คบๆœช้—ญๅˆ๏ผŒไธ่ƒฝๆŒ‰ๅฎŒๆˆ้ชŒๆ”ถ | R02โ€“R04/R18๏ผŒV01/V03โ€“V06 | +| ่ฟœ็จ‹ๅง“ๅ/ๅค‡ๆณจๅ…ฅ็ซ™ | ๅทฒๆทปๅŠ ๆฅๆบ header/ไบ‹ไปถๅญ—ๆฎต๏ผ›ๅฎž้™… public API ่ตฐ็›ดๆŽฅไฟๅญ˜๏ผŒๅฎŒๆ•ดไบ‹ไปถไผ ๆ’ญๆœช้ชŒ่ฏ๏ผ›็‹ฌ็ซ‹่ต„ๆ–™ไบ‹ไปถ่ฏญไน‰ไปๅพ…่ฏๆฎ | R01/R02๏ผŒV02/V07 | | `cnote` ๅŒๅ‘็ผ–่พ‘ | ๆœชๅฎž็Žฐใ€ๆœช่Žท่Œƒๅ›ด็กฎ่ฎค๏ผ›็‹ฌ็ซ‹ไบŽ Contact Note | R01/R02๏ผŒV01/V02 | -| ๅˆ†็ฑป catalog | ๆœ‰่ฏปๅ–/็ผ“ๅญ˜/ๆ‰‹ๅทฅๅŒๆญฅ๏ผ›ๅคฑ่ดฅๅ›žไผ ๅ’ŒๅŒๆญฅไปฃ้™…ไฟ่ฏไธๅฎŒๆ•ด | R14/R18๏ผŒV11/V12 | +| ๅˆ†็ฑป catalog | ่ฏปๅ–/็ผ“ๅญ˜/ๆ‰‹ๅทฅๅŒๆญฅๅทฒๆœ‰๏ผ›ๅทฒ่กฅ login ่ฟ”ๅ›žๅญ—ๆฎตไฝœไธบ็œŸๅฎžๆœๅŠกๅ™จ็š„ catalog ๆฅๆบ๏ผŒๅนถๆ”ฏๆŒๅŒ้‡ URL ่งฃ็ /ๅฐพๅˆ†้š”็ฌฆ๏ผ›ๅคฑ่ดฅๅ›žไผ ๅ’ŒๅŒๆญฅไปฃ้™…ไฟ่ฏไปไธๅฎŒๆ•ด | R14/R18๏ผŒV11/V12 | | ไผš่ฏๅˆ†็ฑปๅ‡บ็ซ™ / ่ฟœ็จ‹ๅ…ฅ็ซ™ | ๅ‡บ็ซ™ๅŠ็ป“ๆžœ้˜Ÿๅˆ—ๅทฒๆœ‰๏ผ›็‹ฌ็ซ‹่ฟœ็จ‹ไฟฎๆ”นไบ‹ไปถๅฐšๅพ…่ฏๆฎไธŽๅฎž็Žฐ | R05/R14๏ผŒV07โ€“V09 | -| ๅฎขๆˆท้ขœ่‰ฒๅ…ฅ็ซ™/ๅ‡บ็ซ™ | kind=29 ๅฝ“ๅ‰ๅชๅ†™ไผš่ฏๆ–‡ๆœฌๅฑžๆ€ง๏ผ›ๅ‡บ็ซ™ๅทฒๆœ‰๏ผŒๅฎขๆˆท็บงไผ ๆ’ญๅ’Œ ID/ๅ็งฐไธ€่‡ดๆ€งๆœชๅฎŒๆˆ | R06๏ผŒV10/V12 | +| ๅฎขๆˆท้ขœ่‰ฒๅ…ฅ็ซ™/ๅ‡บ็ซ™ | ๅทฒ็กฎ่ฎค็œŸๅฎž kind=29 ่ฝฝ่ทไธบๆ•ฐๅญ— color ID๏ผˆๆœฌๆฌกไธบ `10`๏ผ‰๏ผ›ๅฝ“ๅ‰ไปๅชๅ†™ไผš่ฏๆ–‡ๆœฌๅฑžๆ€ง๏ผŒๅ‡บ็ซ™ๅทฒๆœ‰๏ผŒๅฎขๆˆท็บงไผ ๆ’ญๅ’Œ ID/ๅ็งฐไธ€่‡ดๆ€งๆœชๅฎŒๆˆ | R06๏ผŒV10/V12 | | ๅˆ†็ฑป/้ขœ่‰ฒๆธ…้™ค | ๅ‡บ็ซ™ RESET ไป็ฆ็”จ๏ผŒ้œ€ๅ•็‹ฌๅ่ฎฎ้—จ็ฆ | R05/R06๏ผŒV13 | -| ๅŽŸ็”Ÿ Contact Label | ๆœฌๅœฐ่ƒฝๅŠ›ๅทฒๆœ‰๏ผ›ๅ•†ๅŠก้€šไพงๆ ๅ…ฅๅฃๅพ…็กฎ่ฎคๅŠ้ฆ–ๆฌกๅŠ ่ฝฝไฟฎๅค | R08๏ผŒV16 | -| ๅŽŸ็”Ÿ Conversation Label | ๆœฌๅœฐ้“พ่ทฏๅทฒๆœ‰๏ผŒไฝ†ๅ“ๅบ”ไธŽ store ไธๅŒน้…๏ผŒๆ•ฐๆฎๆบไธ€่‡ดๆ€งๅพ…ๆ ธๅฏน | R13๏ผŒV15 | -| ๅŽŸ็”Ÿ Contact Note | ๅฝ“ๅ‰ HTTP ไฝฟ็”จ notes๏ผ›็ผ–่พ‘ API/store/UIใ€ๅކๅฒ่กจๅฎ‰ๅ…จๅŠๆœ‰ๆ•ˆ็ˆถ่”็ณปไบบๅพ…่กฅ | R09/R16๏ผŒV17/V18 | +| ๅŽŸ็”Ÿ Contact Label | ๆœฌๅœฐ่ƒฝๅŠ›ๅทฒๆœ‰๏ผŒprop ้ฆ–ๆฌกๅŠ ่ฝฝไฟๆŠคๅทฒๆ”น่ฟ›๏ผ›ๅ•†ๅŠก้€šไพงๆ ๅ…ฅๅฃไปๅพ…ไบงๅ“็กฎ่ฎค๏ผŒ็ป„ไปถ้ชŒๆ”ถๅพ…่กฅ | R08๏ผŒV16 | +| ๅŽŸ็”Ÿ Conversation Label | ๅ“ๅบ”ๆ•ฐ็ป„ๅทฒไฟฎ๏ผ›ๆ™ฎ้€š/ๆ‰น้‡/ๅ…ณ่”ๅˆ ้™คไปๆœ‰ๅŒๆ•ฐๆฎๆบไธไธ€่‡ด๏ผŒๆœชๅฎŒๆˆๅฎŒๆ•ด R13 | R13๏ผŒV15 | +| ๅŽŸ็”Ÿ Contact Note | ๅฝ“ๅ‰ HTTP ไฝฟ็”จ notes๏ผ›update API/store ๅ’Œ็ˆถ่”็ณปไบบๆ ก้ชŒๅทฒๆœ‰๏ผŒ็ผ–่พ‘ๆˆชๆ–ญ/ๅฟซๆท้”ฎ/่‰็จฟ้š”็ฆปๅพ…ไฟฎ๏ผŒๅކๅฒ่กจ็›˜็‚นไปๅพ…่กฅ | R09/R16๏ผŒV17/V18 | | CRM ๆ ‡็ญพ | ไฟๆŒ็‹ฌ็ซ‹๏ผŒๆœฌๆœŸไธๆ–ฐๅขžๆ˜ ๅฐ„ๆˆ–ๅŒๆญฅ | R12๏ผŒV19 | ### 6.2 ๅฟ…้กปๆ–ฐๅขžๆˆ–่กฅ้ฝ็š„้ชŒๆ”ถ็Ÿฉ้˜ต @@ -235,45 +240,57 @@ | V24 | R10/R11 | ๆ–‡ๆœฌ/ๅ›พ็‰‡/ๆ–‡ไปถ/่ฏญ้Ÿณใ€ๆ’คๅ›žๅ…ˆๅŽใ€่พ“ๅ…ฅ็Šถๆ€ๅŠไผš่ฏๆŽงๅˆถๅ›žๅฝ’๏ผ›ไฟ็•™ๅน‚็ญ‰ไธŽ progress/sent/failed/uncertain๏ผŒ่ต„ๆ–™ไฟฎๅคไธๆ”นๅ˜ๆถˆๆฏ่ฏญไน‰ | ็Žฐๆœ‰่‡ชๅŠจๅŒ–๏ผ‹ๆŽˆๆƒ็œŸๅฎž่ดฆๅท | | V25 | R02/R11/R18 | ๅ…จ้“พ่ทฏไปฅๆ“ไฝœ ID ๅ…ณ่”ไฝ†ไธ่ฎฐๅฝ•ๆ˜Žๆ–‡ๅง“ๅ/ๅค‡ๆณจ/token/็ญพๅ๏ผ›ๆ—ฅๅฟ—ใ€้”™่ฏฏ/UI ไธๆณ„้œฒ่ทจ็งŸๆˆทๆ•ฐๆฎ๏ผŒ่กฅๅฟๅฏๅฎก่ฎก | ๆ—ฅๅฟ—ๆฃ€ๆŸฅ๏ผ‹่ดŸๅ‘ๆต‹่ฏ• | -### 6.3 ๆœฌ่ฝฎๅฎž้™…ๆ‰ง่กŒ็š„้ชŒ่ฏ๏ผˆ2026-09-12๏ผ‰ +### 6.3 ๅฎžๆ–ฝ้˜ถๆฎตๅކๅฒ้ชŒ่ฏ่ฎฐๅฝ•๏ผˆ2026-09-13๏ผ‰ -ไปฅไธ‹ๅ‘ฝไปคไปŽไป“ๅบ“ๆ น็›ฎๅฝ•ๅˆ†ๅˆซ่ฟ่กŒ๏ผ›ๅช้ชŒ่ฏๅฝ“ๅ‰ๅทฅไฝœๆ ‘ๆ—ขๆœ‰ไปฃ็ ๏ผŒ**ๆœฌ่ฝฎๆœชๆ–ฐๅขžไธšๅŠกไปฃ็ ๆˆ–ๅ›žๅฝ’ๆต‹่ฏ•**ใ€‚ +ไปฅไธ‹ไฟ็•™ไธŠไธ€่ฝฎๅฎžๆ–ฝ้˜ถๆฎต็š„ๅ‘ฝไปคไธŽ็ป“ๆžœ๏ผŒไธไปฃ่กจๅฎžๆ–ฝๅŽๅฎกๆŸฅ้€š่ฟ‡๏ผŒไนŸไธๆ˜ฏๆœฌๆฌกๆ–‡ๆกฃ่ฝๅœฐ้‡ๆ–ฐๆ‰ง่กŒ็š„่ฎฐๅฝ•ใ€‚ๆ–ฐๅขžๅคฑ่ดฅ็”จไพ‹ๅ’Œ่ฆ†็›–็ผบๅฃ่งไธ‹ๆ–นๅคๅฎก็ป“่ฎบ๏ผ›็œŸๅฎžๅ•†ๅŠก้€šๅ่ฎฎๅ’Œ็œŸๅฎž่ดฆๅทไธๅœจๆœฌๅœฐๆต‹่ฏ•่Œƒๅ›ดใ€‚ ```bash -# Connector ๅ…จ้ƒจ็Žฐๆœ‰ๆต‹่ฏ•๏ผ›้ขๅค–ๅฏนๅ…ณ้”ฎๅŒ…็ฆ็”จ็ผ“ๅญ˜ๅค่ท‘ -(cd channels/shangwutong && go test ./...) -(cd channels/shangwutong && go test -count=1 ./internal/swt ./internal/delivery ./internal/store ./internal/httpapi ./internal/account) +# Connector๏ผšๅ…จๅŒ… raceใ€vet ๅ’Œๆ— ็ผ“ๅญ˜ๆต‹่ฏ• +(cd channels/shangwutong && go test -race ./...) +(cd channels/shangwutong && go vet ./...) -# ๅŽ็ซฏ๏ผšSQLite ๅฎšๅ‘๏ผ›ไธๅŒ…ๅซ็”Ÿไบง PG ๅนถๅ‘/่ฟ็งป้ชŒๆ”ถ -(cd backend && GOCHAT_TEST_DB=sqlite go test ./internal/service ./internal/handler/api/v1 \ - -run 'Test.*(Shangwutong|ContactUpdate|ContactNote|ContactLabel|ConversationLabel)' -count=1) +# ๅŽ็ซฏ๏ผšๅ…จๆจกๅ— SQLite ๆต‹่ฏ•ใ€vet ๅ’Œๆž„ๅปบ +(cd backend && GOCHAT_TEST_DB=sqlite go test ./... -count=1) +(cd backend && go vet ./...) +(cd backend && go build ./...) -# ๅ‰็ซฏ๏ผšๆ—ขๆœ‰ store ไธŽ inbox ่ฎพ็ฝฎๆต‹่ฏ•๏ผŒไธๅŒ…ๅซๅฐšๆœช่กฅ็š„ๅˆ†็ฑป็ป„ไปถๆต‹่ฏ• -(cd frontend && TZ=UTC pnpm exec vitest run \ - app/javascript/dashboard/store/modules/specs/contactLabels \ - app/javascript/dashboard/store/modules/specs/contactNotes \ - app/javascript/dashboard/store/modules/specs/conversationLabels \ - app/javascript/dashboard/routes/dashboard/settings/inbox/channels/specs/Shangwutong.spec.js \ - --maxWorkers=2 --minWorkers=1) +# ๅ‰็ซฏ๏ผšๅ…จ้‡ๆต‹่ฏ•ใ€ESLintใ€็”Ÿไบงๆž„ๅปบ +(cd frontend && TZ=UTC pnpm test -- --run --maxWorkers=2 --minWorkers=1) +(cd frontend && pnpm exec eslint \ + app/javascript/dashboard/routes/dashboard/conversation/ShangwutongClassifications.vue \ + app/javascript/dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue \ + app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactNotes.vue \ + app/javascript/dashboard/components-next/Contacts/ContactsSidebar/components/ContactNoteItem.vue \ + app/javascript/dashboard/routes/dashboard/conversation/contact/ContactNotes.vue \ + app/javascript/dashboard/store/modules/contactNotes.js \ + app/javascript/dashboard/store/modules/contactLabels.js \ + app/javascript/dashboard/api/contactNotes.js) +(cd frontend && pnpm run build) ``` | ๆฃ€ๆŸฅ | ๆœฌ่ฝฎ็ป“ๆžœ | ไธ่ฆ†็›–็š„ๅ†…ๅฎน | | --- | --- | --- | -| Connector ๅ…จๅฅ—ๅŠไบ”ไธชๅ…ณ้”ฎๅŒ…ๆ— ็ผ“ๅญ˜ๅค่ท‘ | ้€š่ฟ‡ | ็œŸๅฎžๆœๅŠก cnote/ไบ‹ไปถ็ผ–ๅท/RESET/ๅކๅฒ่ฏญไน‰๏ผ›ๆ–ฐๅขž V01โ€“V25 | -| ๅŽ็ซฏไธคไธชๅŒ…ๅฎšๅ‘ๆต‹่ฏ• | ้€š่ฟ‡ | ๅฎŒๆ•ด RBAC ่ทฏ็”ฑใ€PG ๅนถๅ‘/่ฟ็งปใ€ไฟฎๅคๅŽ็š„ๆ–ฐๅฅ‘็บฆ | -| ๅ‰็ซฏ | 10 ๆ–‡ไปถใ€33 ๆต‹่ฏ•้€š่ฟ‡ | ๆ ‡็ญพ็œŸๅฎžๅŽ็ซฏๅ“ๅบ”ใ€ๅˆ†็ฑป็ป„ไปถ็ซžๆ€ใ€ๅค‡ๆณจ็ผ–่พ‘ๅ’Œๆต่งˆๅ™จ E2E | -| ไธŠๆธธๅŠๆœฌๅœฐๆบ็ ๅฏน็…ง | ๅทฒๆ ธๆŸฅๅนถ็™ป่ฎฐ ยง3.1 | ้™ๆ€็ผบๅฃไธ็ญ‰ไบŽๅทฒ่ฟ่กŒๅคฑ่ดฅๅค็Žฐๆต‹่ฏ• | +| Connector race/vet/ๅ…จๅŒ…ๆต‹่ฏ• | ้€š่ฟ‡ | ็œŸๅฎžๆœๅŠก cnote/ไบ‹ไปถ็ผ–ๅท/RESET/ๅކๅฒ่ฏญไน‰ | +| ๅŽ็ซฏๅ…จๆจกๅ— SQLite ๆต‹่ฏ• | ้€š่ฟ‡ | ็œŸๅฎž PostgreSQL ๅนถๅ‘/่ฟ็งปใ€ๅ…จ้‡ RBAC ็ฐๅบฆ | +| ๅŽ็ซฏ vet/build | ้€š่ฟ‡ | ็œŸๅฎž่ฟœ็จ‹ๆœๅŠก | +| ๅ‰็ซฏๅ…จ้‡ๆต‹่ฏ• | 352 ๆ–‡ไปถใ€3451 ๆต‹่ฏ•้€š่ฟ‡ | ๆต่งˆๅ™จ E2E๏ผ›ๅญ˜ๅœจไพ่ต– source-map ไธŽๆ—ขๆœ‰ jsdom warning | +| ๅ‰็ซฏ ESLint/build | ้€š่ฟ‡๏ผ›ๆž„ๅปบ่ฝฌๆข 3469 modules | ็œŸๅฎžๅ•†ๅŠก้€š่ดฆๅท | +| ๅฎžๆ–ฝ้˜ถๆฎตๆ–ฐๅขž Connector/ๅŽ็ซฏๅฎšๅ‘ๅ›žๅฝ’ | ๆ—ขๆœ‰็”จไพ‹้€š่ฟ‡๏ผ›ไธไปฃ่กจๆ•ดๆ”นๅไพ‹้€š่ฟ‡ | ๅŒ event ๅผ‚ๅ€ผใ€ๅฎŒๆ•ดๆฅๆบ้“พ่ทฏใ€ๅˆ†็ฑปๅ…ฅ้˜Ÿๆ•…้šœใ€ๆ–ญ่ฟž/ๆ™šๅˆฐ CIDใ€PostgreSQL ๅนถๅ‘ใ€็œŸๅฎž่ดฆๅท/่กฅๅฟ | -ๆœฌๅœฐๅŸบ็บฟๆ—ฅๅฟ—/ๆ”นๅ‰ๅค‡ไปฝไฝไบŽ `/tmp/gochat-swt-plan-review-20260912/`๏ผˆไธดๆ—ถ่ฏๆฎ๏ผŒไธๆ˜ฏ CI ้•ฟๆœŸๅฝ’ๆกฃ๏ผ‰๏ผ›ๅ…ณ้”ฎๅŒ…ๆ— ็ผ“ๅญ˜ๅค่ท‘่ฎฐๅฝ•่งๆœฌๆฌก Connector ๅช่ฏปๅฎกๆŸฅไบง็‰ฉใ€‚ๅˆๅ…ฅ/ๅ‘ๅธƒๅ‰ๅฟ…้กปๆŠŠๆœ‰ๆ•ˆ้ชŒๆ”ถ่ฏๆฎๅฝ’ๆกฃๅˆฐๅ›ข้˜Ÿๅฏ่ฎฟ้—ฎ็š„ CI ๆˆ–ๆŠฅๅ‘Šไฝ็ฝฎ๏ผŒๅนถ่ฎฐๅฝ•็กฎๅˆ‡ๆไบค/ๅทฅไฝœๆ ‘็‰ˆๆœฌใ€‚ๅŽŸ็”Ÿๆ ‡็ญพ action mock ้€š่ฟ‡ไธ่ƒฝๆŠตๆถˆ R13 ็š„็œŸๅฎžๅฅ‘็บฆ็ผบๅฃใ€‚ +ๅทฒๆœ‰ๅฎž็Žฐๅฏไฟ็•™๏ผšๆ”นๅ็œ็•ฅ็ฉบ `cnote`ใ€operation ็ป“ๆžœ้˜Ÿๅˆ—ๆ‰ฉๅฑ•ใ€CID ็ญ‰ๅพ…/ๅ”ค้†’ๅŸบ็ก€่ทฏๅพ„ใ€ๆ”นๅ listener ็š„็Šถๆ€ไธŽ webhook ไบ‹ๅŠกๅ…ฅ้˜Ÿใ€ๅˆ†็ฑป็‹ฌ็ซ‹็Šถๆ€ๆงฝใ€ๆ•ฐ็ป„ๅ“ๅบ”ใ€ๅค‡ๆณจ update API/storeใ€ๆ ‡็ญพ้ฆ–ๆฌกๅŠ ่ฝฝไฟๆŠคๅ’Œๆœ‰ๆ•ˆ็ˆถ่”็ณปไบบๆ ก้ชŒใ€‚ไปฅไธŠๅ‡ไธๆ˜ฏ็›ธๅ…ณ R ้กนๅฎŒๆ•ด้ชŒๆ”ถใ€‚ -ๆœฌ่ฝฎๆœชๅš๏ผš็œŸๅฎžๅ•†ๅŠก้€š็™ปๅฝ•/ๅ†™ๅ…ฅใ€ๆต่งˆๅ™จ E2Eใ€็”Ÿไบง PostgreSQL ๅนถๅ‘/่ฟ็งปใ€ๆ•…้šœๆณจๅ…ฅใ€็ฐๅบฆ/้ƒจ็ฝฒใ€‚ๆ‰€ๆœ‰ๆ–ฐๅขž้ชŒๆ”ถ้กนไปๅพ…้ชŒ่ฏใ€‚ +**ๅฎžๆ–ฝๅŽๅคๅฎก็บ ๆญฃ๏ผˆ2026-09-13๏ผ‰**๏ผšๅˆ†็ฑปๅŒ event ๅผ‚็›ฎๆ ‡ๅ€ผๅทฒ้€š่ฟ‡ไธดๆ—ถ Go overlay ๅค็Žฐ๏ผš่ฏทๆฑ‚ color-1๏ผŒๅ›ž่ฐƒ color-2 ่ฟ”ๅ›ž 200/`updated:true`๏ผŒ้ข„ๆœŸ 409ใ€‚่”็ณปไบบๆ— ๅฎž้™…ๆ“ไฝœ็š„ๅฆไธ€ไธดๆ—ถๆต‹่ฏ•่ถ…ๆ—ถ๏ผŒๆœช่ฎกๅ…ฅๅค็Žฐใ€‚ๅ…ถไฝ™ๅ‘็Žฐไธบๆบ็ /่ฐƒ็”จ้“พ็กฎ่ฎค๏ผŒๆถ‰ๅŠๅค‡ๆณจ 200 ๅญ—็ฌฆๆˆชๆ–ญใ€็ผ–่พ‘ๆจกๅผ/่‰็จฟใ€็Šถๆ€ไธŽ outbox ็ผ้š™ใ€ๅ›ž่ฐƒไปฃ้™…็ซžไบ‰ใ€CID ่Œƒๅ›ดไธŽๆˆชๆญขใ€ไธ็กฎๅฎš่ฏๆฎใ€่กฅๅฟๅ…ฅๅฃๅŠ UI ๆขๅค๏ผ›ๅนถๅ‘/็ป„ไปถๅœบๆ™ฏๅฐš้œ€ๆ–ฐๅขžๆต‹่ฏ•ใ€‚่ฏฆ่งๆ•ดๆ”น่ฎกๅˆ’ F01โ€“F18๏ผŒๅŽŸๅ…ˆโ€œๅทฒๅฎž็Žฐโ€็š„็›ธๅบ”ๆก็›ฎๅทฒๅ›ž้€€ไธบ้ƒจๅˆ†ๅฎž็Žฐใ€‚ + +**ๆ–ฐๅขžๅ่ฎฎ่ฏๆฎ๏ผˆ2026-09-13๏ผ‰**๏ผšๆŽˆๆƒ `swt.test` ็š„ๅช่ฏปๆŽข้’ˆๆ˜พ็คบ login ่ฟ”ๅ›ž `colorkind0/colorkind1` ๅ…ฑ 12 ไธช customer-color ๆก็›ฎ๏ผŒๅ€ผไธบๅŒ้‡ URL ็ผ–็ ไธ”ๅธฆๅฐพ `|`๏ผ›ๅŒ่ฝฎ heartbeat ๅ‘ฝไธญ kind=29๏ผŒ่งฃ็ ๅŽ็š„ๆ–‡ๆœฌไธบๆ•ฐๅญ— color ID `10`ใ€‚`SiteSetting.aspx` ่ฟ”ๅ›ž HTTP 200 ไฝ†็ฉบ body ไธ”ๆ— ๅˆ†็ฑป headers๏ผŒlogout ่ฟ”ๅ›ž `r=ok`ใ€‚Connector ๅทฒๆŠŠ login catalog ไฝœไธบไผ˜ๅ…ˆๆฅๆบ๏ผŒๅนถไฟ็•™ `SiteSetting.aspx` ไฝœไธบๆ—  login catalog ๆ—ถ็š„ๅ…ผๅฎนๅ›ž้€€๏ผ›่ฏๆฎ่ง `reference/shang-wu-tong/results/real-tests/20260913T113244Z/catalog-login-kind29/manifest.json`ใ€‚่ฟ™ๅช่ฏๆ˜Ž catalog ๆฅๆบๅ’Œ kind=29 ่ฝฝ่ทๅฝขๆ€๏ผŒไธ่ฏๆ˜Ž้ขœ่‰ฒๅฎขๆˆท็บงไผ ๆ’ญๆˆ–็œŸๅฎžๅ†™ๅ…ฅใ€‚ + +ไปๆœชๅšไธ”ไธๅพ—ๆ ‡่ฎฐไธบๅทฒ้ชŒๆ”ถ๏ผš็œŸๅฎžๅ•†ๅŠก้€š็™ปๅฝ•/ๅ†™ๅ…ฅใ€่ฟœ็จ‹ `cnote` ไธ‰็ง็ฉบๅ€ผ่ฏญไน‰ใ€่ฟœ็จ‹็‹ฌ็ซ‹ๆ”นๅ/ๅค‡ๆณจ/chatkind ไบ‹ไปถใ€kind=52 ๆญฃๆ–‡ๅކๅฒ/ไธปๅŠจๅކๅฒๆ‹‰ๅ–ใ€ๆถˆๆฏ็ผ–่พ‘/ๅๅบ”ใ€RESETใ€ๆต่งˆๅ™จ E2Eใ€็”Ÿไบง PostgreSQL ๆ•…้šœๆณจๅ…ฅ/่ฟ็งปใ€็ฐๅบฆ้ƒจ็ฝฒๅŠๅ‘Š่ญฆ่กฅๅฟๆผ”็ปƒใ€‚ ### 6.4 ๅ‘ๅธƒใ€่ง‚ๅฏŸไธŽๅ›žๆปš - **่ฟ›ๅ…ฅๅฎžๆ–ฝ**๏ผšR01/R07/R08/R10 ็ญ‰่Œƒๅ›ดๆˆ–ๆƒ้™ๅพ…ๅ†ณ้กน็•™ๆ˜Ž็กฎๅ†ณ็ญ–๏ผŒไธๆ“…่‡ชๅผ€ๆ”พ๏ผ›R02 ๆ•ฐๆฎไฟๅ…จๅŠ็Žฐๆœ‰้”™่ฏฏไฟฎๅคไผ˜ๅ…ˆใ€‚ๆŽฅๅฃๅญ—ๆฎต/็Šถๆ€ไปฅๅ†ป็ป“ๅฅ‘็บฆไธบๅ‡†๏ผŒไธๅœจๅคšไปฝๆ–‡ๆกฃๅ„็ปดๆŠคไธๅŒ็‰ˆๆœฌใ€‚ - **่ฟ›ๅ…ฅ็ฐๅบฆ**๏ผš้€‚็”จ V01โ€“V25 ๅ…จ้ƒจๆœ‰่‡ชๅŠจๅŒ–่ฏๆฎ๏ผ›ๆถ‰ๅŠ่ฟœ็จ‹่ฏญไน‰็š„ fixture ๅฟ…้กป่ƒฝ่ฟฝๆบฏ่„ฑๆ•ๆ ทๆœฌใ€‚ๅค‡ไปฝใ€่ฟ็งปๅ›žๆปšใ€็ป“ๆžœ่กฅๅฟใ€ๆ“ไฝœๅฎก่ฎกๅŠ็œŸๅฎž่ดฆๅทๆŽˆๆƒ้ฝๅค‡๏ผŒๅ่ฎฎๆœช็Ÿฅ็š„่ƒฝๅŠ›็ปง็ปญๅ…ณ้—ญใ€‚ - **็œŸๅฎž้ชŒ่ฏ**๏ผš็™ป่ฎฐไธ“็”จๆต‹่ฏ•็ซ™็‚น/inboxใ€ๅ…่ฎธๅ†™ๅ…ฅๅฏน่ฑกๅ’ŒๅŽŸๅ€ผใ€ๆ‰ง่กŒไบบใ€ๆต‹่ฏ•ๆ—ถ้—ดๅŠๆขๅคๆญฅ้ชค๏ผ›่‡ณๅฐ‘้ชŒ่ฏๅง“ๅไธ”ๅทฒๆœ‰้ž็ฉบๅค‡ๆณจใ€ไผš่ฏๅˆ†็ฑปใ€้ขœ่‰ฒใ€ๅคš SIDใ€ไธ€้กนๅคฑ่ดฅ/ไธ็กฎๅฎšไธŽไป…็ป“ๆžœ่กฅไผ ใ€‚ไธ่ŽทๆŽˆๆƒไธๆ“ไฝœ็œŸๅฎžๅฎขๆˆท๏ผŒไธ็”จๅๅคๅ†™ๅ…ฅๆŽจๆต‹่ฟœ็จ‹็ป“ๆžœใ€‚ -- **็ฐๅบฆ่ง‚ๅฏŸ**๏ผšๆŒ‰ inbox ๆŸฅ็œ‹ pending ๆ•ฐ/ๆœ€่€ๅนด้พ„ใ€็ผบ CID ๅˆฐๆœŸใ€ๆ˜Ž็กฎๅคฑ่ดฅไธŽ uncertain_timeoutใ€ๅ›ž่ฐƒ้‡่ฏ•/่€—ๅฐฝใ€catalog ๆœ€ๅŽๆˆๅŠŸๆ—ถ้—ดไธŽๆ•…้šœใ€่ดฆๅท้˜Ÿๅคด้˜ปๅกž๏ผ›ๅ‘Š่ญฆ้˜ˆๅ€ผใ€่ง‚ๅฏŸ็ช—ๅฃๅ’Œ่ดŸ่ดฃไบบๅœจๆ”พ้‡ๅ‰ๅกซๅ†™๏ผŒๆœชๅกซๅ†™ไธๆ”พ้‡ใ€‚ +- **็ฐๅบฆ่ง‚ๅฏŸ**๏ผšๆŒ‰ inbox ๆŸฅ็œ‹ pending ๆ•ฐ/ๆœ€่€ๅนด้พ„ใ€็ผบ CID ๅˆฐๆœŸใ€ๆ˜Ž็กฎๅคฑ่ดฅไธŽไธ็กฎๅฎš/่ง‚ๅฏŸๅˆฐๆœŸใ€ๅ›ž่ฐƒ้‡่ฏ•/่€—ๅฐฝใ€catalog ๆœ€ๅŽๆˆๅŠŸๆ—ถ้—ดไธŽๆ•…้šœใ€่ดฆๅท้˜Ÿๅคด้˜ปๅกž๏ผ›่ง‚ๅฏŸๅˆฐๆœŸไธ็ญ‰ไบŽ่ฟœ็จ‹ๅคฑ่ดฅใ€‚ๅ…ˆๅฎŒๆˆ F18 ็š„ๆŒ‡ๆ ‡ๅฎž็Žฐๆ ธ้ชŒ๏ผ›ๅ‘Š่ญฆ้˜ˆๅ€ผใ€่ง‚ๅฏŸ็ช—ๅฃๅ’Œ่ดŸ่ดฃไบบๆœชๅกซๅ†™ไธๆ”พ้‡ใ€‚ - **ๅœๆญขๆกไปถ**๏ผšๅค‡ๆณจ่ฏฏๆธ…็ฉบใ€่ทจ็งŸๆˆท/ๆธ ้“ไธฒๅ†™ใ€ๆ—ง็ป“ๆžœ่ฆ†็›–ใ€้‡ๅค่ฟœ็จ‹ๅ‰ฏไฝœ็”จใ€่ฟ็งปๆ•ฐๆฎไธไธ€่‡ด็ซ‹ๅณๅœๆญขๅฏนๅบ”ๅ‡บ็ซ™ๅŠŸ่ƒฝ๏ผ›ไฟๅญ˜้˜Ÿๅˆ—ๅ’Œๅฎก่ฎก่ฏๆฎ๏ผŒไธ่ƒฝๆธ…็ฉบ้˜Ÿๅˆ—ๆŽฉ็›–้—ฎ้ข˜ใ€‚ไปฃ็ ๅ›žๆปšไธๆ’ค้”€่ฟœ็จ‹ๅ†™ๅ…ฅ๏ผŒๆขๅคๅŽŸๅ€ผ้กปๆ ธๅฏน่ฟœ็จ‹ๅฝ“ๅ‰ๅ€ผๅนถ้‡ๆ–ฐๆŽˆๆƒใ€‚ - **้€€ๅ‡บ็ฐๅบฆ**๏ผš่Œƒๅ›ดๅ†…้€‚็”จ็”จไพ‹็œŸๅฎž้ชŒ่ฏๅฎŒๆˆใ€ๆ— ๆœชๅค„็† P0/P1ใ€ๆ•ฐๆฎๅบ“ๅ…ผๅฎน/ๅ›žๆปšไธŽ็ป“ๆžœ่กฅๅฟๆผ”็ปƒ้€š่ฟ‡ใ€README/runbook/่ƒฝๅŠ›็Ÿฉ้˜ตไธ€่‡ด๏ผŒๆ‰้€้กนๆ ‡่ฎฐๅฎŒๆˆใ€‚ไป…ๆœฌ่ฝฎ็Žฐๆœ‰ๆต‹่ฏ•้€š่ฟ‡ไธๅฏๅ‘ๅธƒๅฎฃ็งฐๆ–ฐๅขž่ƒฝๅŠ›ใ€‚ diff --git a/docs/plans/2026-09-13-shangwutong-review-remediation-plan.md b/docs/plans/2026-09-13-shangwutong-review-remediation-plan.md new file mode 100644 index 00000000..e13780f5 --- /dev/null +++ b/docs/plans/2026-09-13-shangwutong-review-remediation-plan.md @@ -0,0 +1,293 @@ +# ๅ•†ๅŠก้€š่ƒฝๅŠ›ไฟฎๅคๅฎžๆ–ฝๅŽๅฎกๆŸฅ๏ผšๆ•ดๆ”นๆ‰ง่กŒ่ฎกๅˆ’ + +> ๆ—ฅๆœŸ๏ผš2026-09-13 +> ็Šถๆ€๏ผš**ๅฎžๆ–ฝไธญ๏ผ›ๆœฌๅœฐ่‡ชๅŠจๅŒ–้ชŒๆ”ถ้ƒจๅˆ†้€š่ฟ‡๏ผŒ็œŸๅฎžๅ่ฎฎ smoke ๅทฒๆœ‰ๅฑ€้ƒจ่ฏๆฎ๏ผŒไป้˜ปๆญขๆŒ‰โ€œๅ…จ้ƒจๅฎŒๆˆโ€้ชŒๆ”ถ/ๅ‘ๅธƒ**ใ€‚ๅฎŒๆ•ด็œŸๅฎžๅ†™ๆ“ไฝœใ€PostgreSQL ๅนถๅ‘ๅ’Œๆต่งˆๅ™จ็ซฏๅˆฐ็ซฏ้€่พพไปๆœช้€š่ฟ‡ใ€‚ +> ๅฎกๆŸฅๅฏน่ฑก๏ผš`main@0dd188c46f41e9d89644e25fe3c0b6a06ed9ae73` ๅŠ ๆœฌๆฌกๆœชๆไบคๅทฅไฝœๆ ‘๏ผŒๅŒ…ๆ‹ฌๆœช่ทŸ่ธช็š„ `channels/shangwutong/internal/gochat/contact_status.go`๏ผŒไธๆ˜ฏ็บฏ HEADใ€‚ +> ่ฆๆฑ‚ๆฅๆบ๏ผš[่ƒฝๅŠ›็ผบๅฃไฟฎๅค่ฎกๅˆ’](2026-09-12-shangwutong-capability-gap-repair-plan.md) ็š„ๅŽŸๅง‹ R01โ€“R18ใ€V01โ€“V25 ๅ’Œ ยง3.2 ๅฅ‘็บฆ๏ผ›ๅŒๆ—ถๅฏน็…งๅฎžๆ–ฝๅŽไฟฎๆ”น็š„ๅฎŒๆˆๅฃฐๆ˜Žใ€‚ๆœฌๆ–‡็ป†ๅŒ–ๆ•ดๆ”น๏ผŒไธๆ”นๅ˜ๅŽŸ่ฎกๅˆ’็š„ไบงๅ“่Œƒๅ›ดไธŽๆŽˆๆƒ่พน็•Œใ€‚ + +## 1. ็ป“่ฎบไธŽๆ‰ง่กŒ่พน็•Œ + +ๅฝ“ๅ‰ไปๆ˜ฏ้ƒจๅˆ†็ฌฆๅˆ่ฆๆฑ‚ใ€‚ๅทฒๅฎž็Žฐ็š„ outboxใ€็ป“ๆžœ้˜Ÿๅˆ—ใ€ๅค‡ๆณจ API/storeใ€ๆ ‡็ญพๅŒๅญ—ๆฎตไบ‹ๅŠกๅŒๆญฅใ€ๅˆ†็ฑป็ป“ๆžœๆŒไน…้˜Ÿๅˆ—ๅ’Œ็Šถๆ€ UI ๅฏไฟ็•™๏ผ›ๆœฌๅœฐๅไพ‹ไธŽๅ…จ้‡ๆต‹่ฏ•ๅทฒ่กฅๅผบ๏ผŒไฝ†ไธ่ƒฝๆฎ SQLite/ๅ•ๅ…ƒๆต‹่ฏ•ๅฐ† R02/R03/R04/R06/R09/R13/R14/R17/R18 ๅˆคไธบๅค–้ƒจๅฎŒๆˆใ€‚ + +ๆœฌ่ฝฎๅทฒๅœจไฟ็•™ dirty worktree ็š„ๅ‰ๆไธ‹่ฝๅœฐ Connectorใ€ๅŽ็ซฏใ€ๅ‰็ซฏๅŠๆ–‡ๆกฃๆ•ดๆ”น๏ผŒๅนถๆ‰ง่กŒๆœฌๅœฐ/้š”็ฆป PostgreSQL ้ชŒ่ฏ๏ผ›็ป็”จๆˆทๆŽˆๆƒไฝฟ็”จ `swt.test` ๅฎŒๆˆ็œŸๅฎž็™ปๅฝ•ใ€heartbeatใ€ๅ…ฅ็ซ™ markerใ€ไธ€ๆฌก `send.aspx r=ok` ไธŽ็ป“ๆŸไผš่ฏ๏ผŒๅฆไฟ็•™ๅ‘้€/ๆŽฅ็ฎกๆ—  `r` ็š„ๅคฑ่ดฅ่ฏๆฎ๏ผ›ๆœชๆ‰ง่กŒ็”Ÿไบง่ฟ็งปใ€ๆไบคๆˆ–้ƒจ็ฝฒใ€‚ๅŽ็ปญๅฎžๆ–ฝไปไธๅพ—้‡็ฝฎใ€ไธ่ฆ†็›–ไป–ไบบไฟฎๆ”นใ€‚ + +็ปง็ปญ้ตๅฎˆ๏ผš + +- `cnote`ใ€ๅ•†ๅŠก้€š chatkind/้ขœ่‰ฒใ€GoChat Note/Labelใ€CRM ๆ ‡็ญพๅฝผๆญค็‹ฌ็ซ‹ใ€‚ +- ๆœชๅ†ป็ป“็š„ๅŒๅ‘ `cnote`ใ€็‹ฌ็ซ‹่ฟœ็จ‹่ต„ๆ–™/chatkind ไบ‹ไปถใ€kind 18/19/20ใ€kind=52 ๆญฃๆ–‡ใ€ไธปๅŠจๅކๅฒใ€RESETใ€็ผ–่พ‘/ๅๅบ”ไธๅ€Ÿๆ•ดๆ”นๅผ€ๆ”พใ€‚ +- ๅค็”จ `WorkerPool.EnqueueInTransaction`ใ€ๆ—ขๆœ‰ๅŽๅฐไปปๅŠกๅ’Œ `outbound_operations`๏ผ›ไธๅฆๅปบ้€š็”จ้˜Ÿๅˆ—/่ฐƒๅบฆๆก†ๆžถใ€‚ +- 202 ไป…่กจ็คบๆŒไน…ๅŒ–ๅ—็†๏ผ›่ถ…ๆ—ถไธๆ˜ฏ่ฟœ็จ‹ๅคฑ่ดฅ่ฏๆฎ๏ผ›้‡ๆ”พ็ป“ๆžœไธๅพ—้‡ๅค่ฟœ็จ‹ๅ‰ฏไฝœ็”จใ€‚ +- ไป…ๆˆๅŠŸๆกˆไพ‹ใ€mock action ๆต‹่ฏ•ๅ’Œ SQLite ๆต‹่ฏ•ไธ่ƒฝๆ›ฟไปฃ่ดŸๅ‘ใ€็ป„ไปถใ€PostgreSQL ๅนถๅ‘ๅŠ็œŸๅฎžๅ่ฎฎ้ชŒ่ฏใ€‚ +- ๅŽŸ็”Ÿ่ƒฝๅŠ›ๅ…ˆ่ฟฝ่ธช `docs/chatwoot/` ๅฏนๅบ” controller/model/serializer/ๅ‰็ซฏๆถˆ่ดน่€…๏ผ›่ฏฅ็›ฎๅฝ•ไธŽๅ็ผ–่ฏ‘ๅ‚่€ƒ็›ฎๅฝ•ๅช่ฏปใ€‚ + +## 2. ่ฏๆฎๅฃๅพ„ + +| ๆ ‡่ฎฐ | ๅซไน‰ | ๆœฌๆฌก่ฏๆฎ | +| --- | --- | --- | +| ๅทฒๅค็Žฐ | ๆ•ดๆ”นๅ‰ๅฎž้™…่ฟ่กŒๅคฑ่ดฅ็”จไพ‹ | ๅˆ†็ฑปๅŒ eventใ€ๅŒ operationใ€ไธๅŒ็›ฎๆ ‡ๅ€ผๅ›ž่ฐƒ่ฟ”ๅ›ž 200 ไธ” `updated:true`๏ผŒๆ•ดๆ”นๅŽๅทฒ็บณๅ…ฅ 409 ๅ›žๅฝ’ | +| ๆœฌๅœฐ้ชŒๆ”ถ | ๅฝ“ๅ‰ๅทฅไฝœๆ ‘ๅฎž้™…่ฟ่กŒ้€š่ฟ‡ | Connector ๅ…จ้‡ race/vet/build๏ผ›ๅŽ็ซฏ SQLite ๅ…จ้‡ race/vet/build๏ผ›้š”็ฆป PostgreSQL ๅนถๅ‘/ๅ†’็ƒŸ็”จไพ‹๏ผ›ๅ‰็ซฏๆต‹่ฏ•ใ€ๆž„ๅปบใ€ไฟฎๆ”นๆ–‡ไปถ lint/format๏ผ›่ฟ็งป 010/011 ๅพ€่ฟ”ๅŠๆ•ฐๆฎไฟๆŠคๅ›žๅฝ’ | +| ็œŸๅฎž smoke | ๅทฒ่ŽทๆŽˆๆƒๅนถไฟ็•™่„ฑๆ•่ฏๆฎ | ็™ปๅฝ•/heartbeat/ๅ…ฅ็ซ™ marker/ไธ€ๆฌก `send.aspx r=ok`/็ป“ๆŸไผš่ฏ๏ผ›UI ๆœช่ง‚ๅฏŸๅˆฐ็ซ‹ๅณๅ›žๅค๏ผŒๅŽ็ปญๅŒ SID ๅ‘้€ไธŽๆŽฅ็ฎกๆ—  `r` | +| ๆบ็ ็กฎ่ฎค | ๅทฒๅฎšไฝ่ฐƒ็”จ้“พไฝ†ๅฐšๆœชๅฎŒๆˆๅฎŒๆ•ดๅค–้ƒจๅœบๆ™ฏ | `cnote` ่ฏญไน‰ใ€ๅฎŒๆ•ดๅ†™ๆ“ไฝœ/ๆŽฅ็ฎกใ€ๆต่งˆๅ™จ็ซฏๅˆฐ็ซฏ้€่พพใ€็”Ÿไบง็›‘ๆŽงๅ’ŒๅฎŒๆ•ด PostgreSQL ๆ•…้šœๆณจๅ…ฅ | +| ๆœชๅฎŒๆˆ้ชŒ่ฏ | ไธๅฏๅฝ“ไฝœ้€š่ฟ‡ๆˆ–ๅคฑ่ดฅๅค็Žฐ | LSP/CodeGraph ไธๅฏ็”จๆˆ– inconclusive๏ผ›็”Ÿไบง็บงๅ‘Š่ญฆ/่กฅๅฟๆผ”็ปƒไปๆœชๅฎŒๆˆ | + +ๅˆ†็ฑปๅค็Žฐๆญฅ้ชค๏ผšๅœจ `TestShangwutongClassificationCallbackIsScopedAndStaleSafe` ๅ—็† `color-1` ๅŽ๏ผŒ็”จๅ…ถ event ID ๅ…ˆๅ›ž่ฐƒ `customer_color_id=color-2`ใ€`status=succeeded`ใ€‚่ทฏ็”ฑๅฎž้™…่ฟ”ๅ›ž 200ใ€‚ๅค็Žฐไฝฟ็”จไธดๆ—ถ Go overlay๏ผŒๆœชไฟฎๆ”นไป“ๅบ“ๆต‹่ฏ•๏ผ›ๅฎžๆ–ฝๆ—ถ้กป่ฝฌไธบๆŒไน…ๅ›žๅฝ’๏ผŒไธ่ƒฝไพ่ต– `/tmp` ่ทฏๅพ„ไฝœไธบไบคไป˜่ฏๆฎใ€‚ + +ไปฅไธ‹ไฝ็ฝฎไปฅๆ–‡ไปถๅ’Œ็ฌฆๅทไธบๅ‡†๏ผŒ่กŒๅท้šๆ•ดๆ”นๅ˜ๅŒ–ใ€‚ๅˆๅง‹ๅฎกๆŸฅ็Šถๆ€่งไธŠๆ–‡๏ผ›ๅฝ“ๅ‰็™ป่ฎฐไปฅ็ฌฌ 6.5 ่Š‚ไธบๅ‡†ใ€‚P0 ่กจ็คบๆ—ขๅฎšๅฎ‰ๅ…จ้—จ็ฆ๏ผŒไธ่กจ็คบๅทฒ่ฏๅฎžๅ‘็”Ÿ่ฟœ็จ‹ๆ•ฐๆฎๆŸๅใ€‚ + +## 3. ๆ•ดๆ”นๆธ…ๅ• + +### F01 โ€” P0 ๅฎ‰ๅ…จ้—จ็ฆ๏ผšๆœช็Ÿฅ `cnote` ่ฏญไน‰ไธ่ƒฝ็›ดๆŽฅๅ‘้€ rename + +- **ๅฏนๅบ”**๏ผšR02๏ผ›V01ใ€‚ +- **ไฝ็ฝฎ**๏ผš`channels/shangwutong/internal/swt/operations.go` ็š„ `ChangeContactName`๏ผ›`internal/swt/operations_test.go`๏ผ›`reference/shang-wu-tong/reverse/swt-decompiled/sources/com/reception/app/business/sendfile/net/RenamedThread.java`ใ€‚ +- **้—ฎ้ข˜**๏ผšๅˆ ้™ค็ฉบ `cnote` ๆ”นไธบ็œ็•ฅๅŽไปๆ— ๆกไปถ่ฏทๆฑ‚่ฟœ็จ‹๏ผ›็œ็•ฅ่ฏญไน‰ๆœช้ชŒ่ฏใ€‚ไธ่ƒฝๆ–ญ่จ€็œ็•ฅไธ€ๅฎšๆธ…็ฉบ๏ผŒไนŸไธ่ƒฝ่ฎคๅฎš็œ็•ฅๅฎ‰ๅ…จใ€‚ +- **ไฟฎๅค**๏ผšๅ่ฎฎไฟๅ…จ่ฏๆฎ็ผบๅคฑๆ—ถ๏ผŒๆ˜Ž็กฎๆ‹’็ปๅฎž้™…่ฟœ็จ‹ rename๏ผŒๅนถ้€š่ฟ‡ๆ—ขๆœ‰ๅคฑ่ดฅ็ป“ๆžœ้“พ่ทฏ็ป™ๅ‡บโ€œๅ่ฎฎๆœช้ชŒ่ฏ/ๅŠŸ่ƒฝๆœชๅผ€ๆ”พโ€ๅŽŸๅ› ใ€‚ไธๅพ—็”จๆ–ฐๅค‡ๆณจๅ€ผใ€็Œœๆต‹็š„ๅŽŸๅ€ผๆˆ–็ฉบไธฒ็ป•่ฟ‡ใ€‚ๅ–ๅพ—ๅฎ‰ๅ…จ่ฏทๆฑ‚ๅฝขๆ€่ฏๆฎๅŽๆ‰่ƒฝ่งฃ้™ค้—จ็ฆ๏ผ›ไธไพ่ต–ๅŒๅ‘ๅค‡ๆณจ่Žทๆ‰นใ€‚ +- **้ชŒๆ”ถ**๏ผšๆœช้ชŒ่ฏๆจกๅผ่ฐƒ็”จ่ฎกๆ•ฐไธบ 0๏ผŒๅคฑ่ดฅๅŽŸๅ› ๅฏ่ง๏ผ›fixture ๅˆ†ๅผ€่ฆ†็›–็ผบ็œใ€็ฉบไธฒใ€ๅŽŸๅ€ผใ€‚่ฟœ็จ‹ๅทฒๆœ‰้ž็ฉบๅค‡ๆณจ็š„็œŸๅฎžไฟๅ…จ้ชŒ่ฏๅฟ…้กปๅฆ่ŽทๆŽˆๆƒใ€‚ + +### F02 โ€” P1๏ผš็ป“ๆžœๅฟ…้กป็ป‘ๅฎš็œŸๅฎžๆ“ไฝœใ€่บซไปฝๅ’Œไธๅฏๅ˜็›ฎๆ ‡ๅ€ผ + +- **ๅฏนๅบ”**๏ผšR14๏ผ›V08/V14ใ€‚ +- **ไฝ็ฝฎ**๏ผš`backend/internal/handler/api/v1/shangwutong_connector_handler.go` ็š„ `UpdateClassificationStatus`ใ€`UpdateContactOperationStatus`ใ€`persistContactOperationStatus`ใ€‚ +- **้—ฎ้ข˜**๏ผšๅˆ†็ฑป็š„ `existingValue` ๅช็”จไบŽ่ฏ†ๅˆซๅฎŒๅ…จ็›ธๅŒ็š„้‡ๆ”พ๏ผŒๆœชๆ‹’็ปๅŒ event ๅผ‚ๅ€ผ๏ผ›่”็ณปไบบๆ—  operation ๆ—ถๅฏ่ขซ้ฆ–ๆฌกๅ›ž่ฐƒ็›ดๆŽฅๅ†™ๆˆๆˆๅŠŸใ€‚ +- **ไฟฎๅค**๏ผšๅœจ้”ๅ†…ๆ ธๅฏนๅทฒๆŒไน…ๅŒ–็š„ eventใ€operationใ€account/inboxใ€contact/contact-inboxใ€็›ฎๆ ‡ๅญ—ๆฎตๅ’Œๅ€ผ๏ผ›ไฟ็•™ไธๅฏๅ˜ๆ“ไฝœไฟกๆฏ๏ผŒไธๅœจ็ปˆๆ€ๆ›ฟๆขๆ—ถไธขๅผƒใ€‚ๆœช็Ÿฅๆ“ไฝœๆ‹’็ป๏ผŒๅŒ้”ฎๅผ‚่ดŸ่ฝฝๅ†ฒ็ชใ€‚ๅทฒ็Ÿฅๆ—งไปฃ้™…ๅชไฝœๆ— ๅ‰ฏไฝœ็”จ stale ๅค„็†๏ผŒไธ่ƒฝ้ ๆ‹ผๆŽฅ idempotency header ่ฏๆ˜Žๆ“ไฝœๅญ˜ๅœจใ€‚ +- **้ชŒๆ”ถ**๏ผš็œŸๅฎž middleware ่ทฏ็”ฑ่ฆ†็›–ๆœช็Ÿฅ eventใ€ๆ—  pendingใ€้”™ operation/็›ฎๆ ‡/ๅ€ผ/่บซไปฝใ€็ปˆๆ€ๅŒๅ€ผ้‡ๆ”พใ€็ปˆๆ€ๅผ‚ๅ€ผๅ†ฒ็ชใ€่ทจ็งŸๆˆทๅ’Œๆ—งไปฃ้™…ใ€‚้”™่ฏฏ่ฏทๆฑ‚ไธๆ”นๅ‘่ตทไผš่ฏใ€peerใ€่”็ณปไบบๅ…ƒๆ•ฐๆฎ๏ผŒไธ็”Ÿๆˆๅ‡บ็ซ™ไปปๅŠกใ€‚ๆŠŠๅทฒๅค็Žฐ็š„ color-1โ†’color-2 ็”จไพ‹็บณๅ…ฅไป“ๅบ“ใ€‚ + +### F03 โ€” P1๏ผšๅˆ†็ฑป็Šถๆ€ไธŽ outbox ๅŽŸๅญๅŒ–๏ผŒ่กฅๆŠ•้€’็ปˆๆญข็ป“ๆžœ + +- **ๅฏนๅบ”**๏ผšR03/R14/R18๏ผ›V04/V05/V09ใ€‚ +- **ไฝ็ฝฎ**๏ผšไธŠ่ฟฐ handler ็š„ `UpdateConversationClassification`ใ€`markClassificationPending/Failed`๏ผ›`backend/internal/service/shangwutong_webhook_delivery.go`๏ผ›ๅฏน็…ง `shangwutong_contact_listener.go` ็š„ไบ‹ๅŠกๅ…ฅ้˜Ÿใ€‚ +- **้—ฎ้ข˜**๏ผšๅˆ†็ฑป pending ๅ…ˆ็‹ฌ็ซ‹ๆไบคใ€ๅ† enqueue๏ผ›ไธญ้€”้€€ๅ‡บ็•™ไธ‹ๆ— ไปปๅŠก็Šถๆ€ใ€‚A ๅ…ฅ้˜Ÿๅคฑ่ดฅ็š„ๆ— ๆกไปถ่กฅๅฟๅฏ่ฆ†็›– Bใ€‚webhook ่ขซๆฐธไน…ๆ‹’็ปๆˆ–้‡่ฏ•่€—ๅฐฝๆ—ถ๏ผŒ่ต„ๆ–™ๆ“ไฝœๅฏ่ƒฝๆฐธ่ฟœ pending๏ผŒ็Žฐๆœ‰ๅคฑ่ดฅๆ”ถๅฐพไธป่ฆ้’ˆๅฏนๆถˆๆฏใ€‚ +- **ไฟฎๅค**๏ผšๅŒไบ‹ๅŠกๆŒไน…ๅŒ–ๆ“ไฝœ็Šถๆ€ไธŽไปปๅŠก๏ผŒๆไบคๅŽ publishใ€‚ๅŽ็ปญ่กฅๅฟๅฟ…้กปๅŒน้…ๅฝ“ๅ‰ eventใ€‚ๆŠ•้€’ๆ˜Ž็กฎๆ‹’็ป่ฎฐๅฝ•ๆœฌๅœฐๆŠ•้€’ๅคฑ่ดฅ๏ผ›ๆŠ•้€’็ป“ๆžœไธๆ˜Žไฟ็•™ไธ็กฎๅฎš่ฏๆฎ๏ผŒไธไผช่ฃ…่ฟœ็จ‹ๆ‹’็ป๏ผ›ๅทฒ็Ÿฅไป…ๆŠ•้€’้‡่ฏ•/็ป“ๆžœ่กฅไผ ไธๆ”นๅ˜่ฟœ็จ‹ๆ‰ง่กŒๆฌกๆ•ฐใ€‚ +- **้ชŒๆ”ถ**๏ผšไบ‹ๅŠกๅ†™็Šถๆ€ๅŽๅ…ฅ้˜Ÿๅคฑ่ดฅใ€commit ๅ‰/ๅŽ่ฟ›็จ‹้€€ๅ‡บใ€publish ๅคฑ่ดฅๆขๅคใ€A ๅคฑ่ดฅไธŽ B ๆ–ฐ่ฏทๆฑ‚ไบค้”™ใ€Connector 4xx/่ถ…ๆ—ถ/่€—ๅฐฝใ€‚ๆฏไธช pending ๅ‡ๆœ‰ๅฏๅ‘็ŽฐไปปๅŠกๆˆ–ๆ˜Ž็กฎๆขๅค่ฎฐๅฝ•๏ผŒๆขๅคไธไธขๆ–ฐ็Šถๆ€ใ€ไธ้‡ๅ‘ๅทฒ็กฎ่ฎคๅ‰ฏไฝœ็”จใ€‚ + +### F04 โ€” P1๏ผšๆ”นๅ่ฏทๆฑ‚ไปฃ้™…ไธ่ƒฝไธŽๅ›ž่ฐƒๆ—ถ้—ดๆฏ”่พƒ + +- **ๅฏนๅบ”**๏ผšR03/R04/R14๏ผ›V03/V08/V09ใ€‚ +- **ไฝ็ฝฎ**๏ผš`backend/internal/service/shangwutong_contact_listener.go` ็š„ pending helper๏ผ›`shangwutong_webhook_delivery.go` ็š„ `newShangwutongContactJob`๏ผ›handler ็š„่”็ณปไบบ็ป“ๆžœๆŒไน…ๅŒ–ใ€‚ +- **่งฆๅ‘**๏ผšA pending โ†’ B ๅทฒไฟๅญ˜(t2) โ†’ A ๅ›ž่ฐƒ(t3>t2) โ†’ B listenerใ€‚B ่ขซๆ—งๅ›ž่ฐƒ็š„ `updated_at` ๅˆคๆ—ง๏ผŒไปๅ…ฅ้˜Ÿๅ‘้€๏ผŒB ๅ›ž่ฐƒๅˆๅ› ๅ…ƒๆ•ฐๆฎไปๆ˜ฏ A ่€Œ staleใ€‚ +- **ไฟฎๅค**๏ผšไฝฟ็”จๆ˜Ž็กฎใ€ไธๅฏๅ˜็š„่ฏทๆฑ‚ไปฃ้™…/ๅบๅˆ—๏ผŒ็ป“ๆžœๆ›ดๆ–ฐๆ—ถ้—ดๅ•็‹ฌไฟ็•™๏ผ›็กฎๅฎšๅŒ่บซไปฝๅŒๅญ—ๆฎต็š„ไธฒ่กŒ/ๆœ€ๆ–ฐๆ„ๅ›พ็ญ–็•ฅใ€‚helper ่ฟ”ๅ›žๆ˜ฏๅฆๆŽฅๅ—่ฏฅๆ“ไฝœ๏ผŒ็Šถๆ€ๆœชๆŽฅ็บณ็š„ไปปๅŠกไธๅฏ็ปง็ปญๅ‘้€ใ€‚ๅค็”จ็Žฐๆœ‰ๆ“ไฝœ่ฎฐๅฝ•๏ผŒๅฟ…่ฆๅญ—ๆฎตๆ‰ๅขžๅŠ ่ฟ็งป๏ผŒไธ็”จๅ›ž่ฐƒๆ—ถ้—ดๆˆ–้‡ๅฏๆ—ถ้—ดๅ†’ๅ……็‰ˆๆœฌใ€‚ +- **้ชŒๆ”ถ**๏ผšๅค็ŽฐไธŠ่ฟฐ็ฒพ็กฎไบค้”™๏ผ›Aโ†’Bโ†’Cใ€้‡ๅคๆดพๅ‘ใ€็ปˆๆ€ๅŽ้‡ๅค listenerใ€้‡ๅฏใ€็›ธๅŒๆ—ถ้—ด็ฒพๅบฆๅคšๆฌกๆ”นๅใ€‚ไธๅ‡บ็Žฐโ€œๆ‰ง่กŒ B๏ผŒ็Šถๆ€ๆฐธ่ฟœๆ˜พ็คบ Aโ€ๆˆ–่ฟ‡ๆœŸ A ๆœ€ๅŽ่ฆ†็›–่ฟœ็จ‹ใ€‚ + +### F05 โ€” P1๏ผšcatalog ไปฃ้™…ๆ ธๅฏนไธŽๅ†™ๅ…ฅๅฟ…้กปๅŽŸๅญๅŒ– + +- **ๅฏนๅบ”**๏ผšR14/R18๏ผ›V11ใ€‚ +- **ไฝ็ฝฎ**๏ผšhandler ็š„ `SyncClassifications`ใ€`UpdateClassificationCatalog`ใ€`UpdateClassificationSyncStatus`ใ€‚ +- **่งฆๅ‘**๏ผšA ๅ›ž่ฐƒ้€š่ฟ‡ๆ—งๅฟซ็…งๆฃ€ๆŸฅ โ†’ B ๅ†™ๅ…ฅๆ–ฐไปฃ้™… โ†’ A ๆ— ๆกไปถ Save ๆขๅค A โ†’ B ๅ›ž่ฐƒ่ขซๅˆค staleใ€‚ +- **ไฟฎๅค**๏ผšไฝฟ็”จ้”ๅ†…้ชŒ่ฏๆ›ดๆ–ฐๆˆ–ๅธฆ `last_sync_event_id` ๆกไปถ็š„ๅŽŸๅญๆ›ดๆ–ฐ๏ผ›้ฆ–ๆฌก็ผ“ๅญ˜ๅˆ›ๅปบๅค„็†ๅ”ฏไธ€ๆ€ง็ซžไบ‰ใ€‚ๆˆๅŠŸใ€ๅคฑ่ดฅใ€่ฏทๆฑ‚ๅ…ฅ้˜Ÿๅคฑ่ดฅๅ‡้ตๅฎˆๅŒไธ€ไปฃ้™…่ง„ๅˆ™๏ผŒ้‡ๅค็ป“ๆžœไธๆ— ่ฐ“ๅˆทๆ–ฐ็‰ˆๆœฌใ€‚ +- **้ชŒๆ”ถ**๏ผšๆŒ‚่ฝฝ catalog ็œŸๅฎž่ทฏ็”ฑ๏ผŒ็”จๅฑ้šœๆŽงๅˆถ่ฏป/ๅ†™ไบค้”™๏ผ›A ๆˆๅŠŸ/ๅคฑ่ดฅๅˆ†ๅˆซๆ™šไบŽ B pending/successใ€้‡ๅคๆˆๅŠŸใ€ๅนถๅ‘้ฆ–ๆฌกๅˆ›ๅปบใ€ๅ…ฅ้˜Ÿๅคฑ่ดฅใ€‚ๆ—งไปฃ้™…ไธ่ƒฝ่ฆ†็›–ๆˆ–่ฎฉๆ–ฐไปฃ้™…ๅคฑๅŽปๆŽฅๆ”ถ็ป“ๆžœ็š„่ต„ๆ ผใ€‚ + +### F06 โ€” P1๏ผšๅ…ˆ้”ๅฎšๅนถ้ชŒ่ฏๅ‘่ตทๆ“ไฝœ๏ผŒๅ†ไผ ๆ’ญ้ขœ่‰ฒ + +- **ๅฏนๅบ”**๏ผšR14๏ผ›V09ใ€‚ +- **ไฝ็ฝฎ**๏ผšhandler `UpdateClassificationStatus` ็š„ๅคšไผš่ฏไบ‹ๅŠกใ€‚ +- **้—ฎ้ข˜**๏ผš้ๅކ peer ๅ…ˆๅ†™ๅ…ฅ๏ผŒ้šๅŽๅœจๅ‘่ตทไผš่ฏๅ‘็Žฐ stale/conflict ๅด่ฟ”ๅ›ž nil๏ผŒไบ‹ๅŠกๅฏ่ƒฝๆไบคๅทฒๅ†™ peerใ€‚ +- **ไฟฎๅค**๏ผšไปปไฝ•ไผ ๆ’ญๅ†™ๅ…ฅๅ‰ๅฎŒๆˆๅ‘่ตทๆ“ไฝœ้”ๅ†…้ชŒ่ฏ๏ผ›้‡‡็”จไธ€่‡ด้”้กบๅบ๏ผŒๅคฑๆ•ˆๆ—ถๆ•ด็ฌ”ไบ‹ๅŠกๆ— ๅ†™ๅ…ฅๆˆ–ๅ›žๆปš๏ผŒไธ็”จ่ฟ”ๅ›ž nil ็š„ๆๅ‰้€€ๅ‡บๆไบค้ƒจๅˆ†็ป“ๆžœใ€‚ +- **้ชŒๆ”ถ**๏ผšๅ…ˆๅˆ›ๅปบ peer ๅ†ๅˆ›ๅปบๅ‘่ตทไผš่ฏ๏ผ›A ้€š่ฟ‡้ข„ๆฃ€ๆŸฅๅŽ B ๆ›ฟๆขๆ“ไฝœ๏ผŒๅ†ๆ‰ง่กŒ Aใ€‚ๆ–ญ่จ€ stale/conflict ๆ—ถๆ‰€ๆœ‰็›ธๅ…ณ่กŒๅ‡ๆœชๆ”นๅ˜๏ผ›่ฆ†็›–ๅนถ่กŒๆถˆๆฏๅฑžๆ€งๆ›ดๆ–ฐใ€ไธไธขๆ— ๅ…ณๅญ—ๆฎตใ€‚PostgreSQL ้”/ไบ‹ๅŠกๆต‹่ฏ•ไธๅฏ็”จ SQLite ็ป“ๆžœๆ›ฟไปฃใ€‚ + +### F07 โ€” P1๏ผšๅฎขๆˆท้ขœ่‰ฒๆŒ‰ account/inbox/CID ๅฝ’ๅฑžไผ ๆ’ญ + +- **ๅฏนๅบ”**๏ผšR06/R15๏ผ›V10/V20ใ€‚ +- **ไฝ็ฝฎ**๏ผšhandler ็š„้ขœ่‰ฒไผ ๆ’ญๆŸฅ่ฏข๏ผ›`backend/internal/model/contact_inbox.go` ๅŠๅฏนๅบ” repositoryใ€‚ +- **้—ฎ้ข˜**๏ผš็›ธๅŒ contact-inbox ไธๆ˜ฏๅŒ CID ๅคš SID ็š„ๅฎŒๆ•ด้›†ๅˆ๏ผ›ๆŒ‰ contact_id ๅ›ž้€€ๅˆๅฏ่ƒฝๅŒ…ๅซๅˆๅนถๅŽไธๅŒ่ฟœ็จ‹่บซไปฝใ€‚ +- **ไฟฎๅค**๏ผšๅ…ˆ็กฎๅฎšๅฝ“ๅ‰ๆœ‰ๆ•ˆ่ฟœ็จ‹็ป‘ๅฎš๏ผŒๅ†ๅœจ account/inbox/CID ่Œƒๅ›ด่งฃๆž็›ธๅ…ณ contact-inbox/ไผš่ฏ๏ผ›ไธๆŒ‰่ฃธ CID ่ทจ็ซ™็‚นๅˆๅนถ๏ผŒไธไปฅๆœฌๅœฐ Contact ไปฃๆ›ฟ่ฟœ็จ‹ๅฎขๆˆท่บซไปฝใ€‚CID ็ผบๅคฑ/ๆญงไน‰ไธๅพ—ๆ‰ฉๅคงๆŸฅ่ฏข่Œƒๅ›ดใ€‚ +- **้ชŒๆ”ถ**๏ผšๅŒ account/inbox/CIDใ€ไธๅŒ SID ไธŽ contact-inbox ๅ…จ้ƒจไธ€่‡ด๏ผ›่ทจ inbox/account ็›ธๅŒ CID ไธๅ˜๏ผ›ๅŒ Contact ๅˆๅนถไบ†ไธๅŒ CID ไธไธฒๅ†™๏ผ›็ฉบ/ๅคฑๆ•ˆ CIDใ€ๆ—ง้…็ฝฎๅ›ž่ฐƒๆ— ๅ‰ฏไฝœ็”จใ€‚ + +### F08 โ€” P1๏ผšCID ็ญ‰ๅพ…ไธŽไผš่ฏๅฏ็”จๆ€งใ€ๅ‘้€้‡่ฏ•ๅ’Œๆˆชๆญขๅˆ†็ฆป + +- **ๅฏนๅบ”**๏ผšR04/R18๏ผ›V03/V06ใ€‚ +- **ไฝ็ฝฎ**๏ผš`channels/shangwutong/internal/delivery/outbound.go` ็š„ `processOperation/retryContactNameCID`๏ผ›`db/queries/operations.sql` ็š„ wake/claim๏ผ›`internal/delivery/inbound.go`ใ€‚ +- **้—ฎ้ข˜**๏ผšๅœจ `WithSession` ๅ†…ๆฃ€ๆŸฅ CID๏ผŒๆ–ญ่ฟžๅ…ˆๆถˆ่€—ๅๆฌก้€š็”จ้‡่ฏ•๏ผ›่‹ฅ่ถ…่ฟ‡ 24h ๅŽๅ…ˆๆ”ถๅˆฐ CID๏ผŒๅฝ“ๅ‰ไป…ๅœจ CID ไธบ็ฉบๆ—ถ็š„่ถ…ๆ—ถๅˆ†ๆ”ฏไผš่ขซ็ป•่ฟ‡ใ€‚ +- **ไฟฎๅค**๏ผš้œ€่ฆ็ญ‰ๅพ…็š„ๆ“ไฝœๅ…ˆ่งฃๆž CID/็ญ‰ๅพ…็Šถๆ€ๅ†่ฏทๆฑ‚่ฟœ็จ‹ไผš่ฏ๏ผ›็ญ‰ๅพ…้‡่ฏ•ไธ่€—็”จ็œŸๆญฃ็š„่ฟœ็จ‹ๅ‘้€้ข„็ฎ—ใ€‚ไฟ็•™ๅฏ้‡ๅฏๆขๅค็š„็ญ‰ๅพ…ๆˆชๆญขไธŽ CID ๅญฆๅˆฐๆ—ถ้—ด/่งฃ้™ค็ญ‰ๅพ…่ฏๆฎ๏ผ›ๆขๅคใ€claimใ€wake ๅ‡้ตๅฎˆๅฎƒใ€‚ๆ™šไบŽๆˆชๆญขๅ–ๅพ— CID ็š„ๆ„ๅ›พไธๅพ—ๆ‰ง่กŒใ€‚ๅทฒๅœจๆˆชๆญขๅ‰่งฃ้™ค CID ็ญ‰ๅพ…ไฝ†ๅฐšๆœชๅ‘้€็š„ไปปๅŠก๏ผŒๆŒ‰็‹ฌ็ซ‹ๅ‘้€็ญ–็•ฅๅค„็†๏ผŒไธ่ƒฝๆทท็”จไธค็งๆˆชๆญขใ€‚ +- **้ชŒๆ”ถ**๏ผš่ฟž็ปญๅๆฌกไปฅไธŠๆ—  CID ไธ”ๆ–ญ่ฟžใ€CID ๅˆฐ่พพๅ‰ๅŽๆ–ญ่ฟžใ€ๆˆชๆญขๅ‰ๅ–ๅพ— CIDใ€ๆˆชๆญขๅŽๅ…ˆๅ…ฅ็ซ™ๅ†ๅ‡บ็ซ™ใ€ๆฐๅฅฝๆˆชๆญขใ€้‡ๅฏๅŽ่ฟ‡ๆœŸใ€ๅŽ็ปญ่ฏทๆฑ‚ไปฃ้™…ใ€‚้€š่ฟ‡็œŸๅฎž worker/ไผš่ฏๆกฉๅพช็Žฏ๏ผŒไธๅชๆ”นๅ†…ๅญ˜ attempts ๅŽ็›ดๆŽฅ่ฐƒ็”จ helper๏ผ›่ฟ‡ๆœŸๅพ…ๅŠž่ฟœ็จ‹่ฐƒ็”จ่ฎกๆ•ฐไธบ 0ใ€‚24h ๆ˜ฏๅฝ“ๅ‰ไปฃ็ /่ฎกๅˆ’ๅฃๅพ„๏ผŒไธๆ–ฐๅขž็”Ÿไบง SLO ๆ‰ฟ่ฏบใ€‚ + +### F09 โ€” P1๏ผšไธ็กฎๅฎš่ฏๆฎไธ้š่ง‚ๅฏŸ่ถ…ๆ—ถๅ˜ไธบ็กฎๅฎšๅคฑ่ดฅ + +- **ๅฏนๅบ”**๏ผšR03/R18๏ผ›V04โ€“V06ใ€‚ +- **ไฝ็ฝฎ**๏ผš`channels/shangwutong/db/queries/operations.sql` ็š„ `FailExpiredUncertainOperations` ๅ’Œ็ป“ๆžœ็Šถๆ€ๆ›ดๆ–ฐ๏ผ›`internal/delivery/outbound.go`๏ผ›GoChat ็ป“ๆžœ handlerใ€‚ +- **้—ฎ้ข˜**๏ผš่ง‚ๅฏŸ่ถ…ๆ—ถ็”Ÿๆˆ `failed/uncertain_timeout`๏ผŒ่‹ฅ GoChat ๅทฒๆŽฅๅ— uncertain ๅˆ™ๅ†ฒ็ช๏ผ›ไพ่ต–ไผš่ขซๅ›ž่ฐƒๆ›ดๆ–ฐ็š„ `updated_at`๏ผŒๆˆชๆญข่ฟ˜ไผš็งปๅŠจใ€‚ +- **ไฟฎๅค**๏ผš่ฟœ็จ‹็ป“ๆžœใ€็ป“ๆžœๆŠ•้€’็Šถๆ€ใ€้˜Ÿๅคดๅฑ้šœ้‡Šๆ”พๅˆ†ๅˆซๅค„็†ใ€‚ไฟๆŒ็œŸๅฎž uncertain ่ฏๆฎ๏ผ›็”จไธๅฏๅ˜่ง‚ๅฏŸ่ตท็‚น/ๆˆชๆญขๆŽงๅˆถ่ฐƒๅบฆ๏ผŒไธ่ƒฝ้ ๆ”พๅฎฝๅŽ็ซฏ็ปˆๆ€ๆ ก้ชŒๆŽฉ็›–ไผช้€ ็ป“ๆžœใ€‚่ฟ็งปไฟ็•™ๅކๅฒ็ป“ๆžœ๏ผŒไธ่ƒฝ้‡็ฝฎๅพ…ๅŠžๆฅโ€œๆขๅคโ€ใ€‚ +- **้ชŒๆ”ถ**๏ผšๆˆๅŠŸๅ‘้€ๅ“ๅบ”ไธขๅคฑ โ†’ uncertain ๅ›ž่ฐƒๆˆๅŠŸ/ๅคฑ่ดฅ/่€—ๅฐฝ โ†’ ๅ›บๅฎšๆˆชๆญข โ†’ ้‡ๅฏใ€‚่ดฏ้€šๅฎž้™… GoChat ็Šถๆ€่ทฏ็”ฑ๏ผŒ็ป“ๆžœไธๅ†ฒ็ชใ€ไธ่™šๆž„ๅคฑ่ดฅ๏ผŒๅ›ž่ฐƒๆดปๅŠจไธๅปถ้•ฟๆˆชๆญข๏ผ›้‡Šๆ”พๅฑ้šœๅŽๅฆไธ€ SID ๅฏ็ปง็ปญไฝ†ๅŽŸๅ‰ฏไฝœ็”จไธ้‡ๅ‘ใ€‚ + +### F10 โ€” P1๏ผš่กฅ็ป“ๆžœ่€—ๅฐฝๅŽ็š„ๅฏๅฎก่ฎกใ€ไป…็ป“ๆžœ่กฅๅฟๅ…ฅๅฃ + +- **ๅฏนๅบ”**๏ผšR18๏ผ›V05/V06/V25ใ€‚ +- **ไฝ็ฝฎ**๏ผšConnector `processOperationResult`ใ€็ป“ๆžœ claim/retry/fail/recover SQLใ€็Žฐๆœ‰็ฎก็†ๅ‘ฝไปค๏ผ›`docs/runbooks/shangwutong-connector.md`ใ€‚ +- **้—ฎ้ข˜**๏ผšๅๆฌกๅคฑ่ดฅๅŽๅœๅœจ `result_sync_status=failed`๏ผ›startup ๅชๆขๅค syncing๏ผŒ้‡ๆ”พๅŽŸ webhook ไนŸไธ้‡ๅฏ็ป“ๆžœ่กฅไผ ๏ผ›โ€œๅช้‡ๆ”พ็ป“ๆžœโ€็›ฎๅ‰ไป…ไธบๆ–‡ๅญ—ๆŒ‡ๅผ•ใ€‚ +- **ไฟฎๅค**๏ผš้€š่ฟ‡็Žฐๆœ‰็ฎก็†ๅ…ฅๅฃๅขžๅŠ ๆœ€ๅฐใ€ๅ—ๆƒ้™ๅ’Œ่บซไปฝ่Œƒๅ›ด็บฆๆŸ็š„่กฅๅฟๆ“ไฝœ๏ผŒๅช้‡ๆ–ฐๅฎ‰ๆŽ’ๅทฒๅญ˜็ป“ๆžœ๏ผŒไธๆ”น่ฟœ็จ‹ delivery ็Šถๆ€ๆˆ–็ป“ๆžœ่ฏๆฎ๏ผŒไธๅˆ›ๅปบๆ–ฐ็š„ไธšๅŠกๅ‘ฝไปคใ€‚่ฎฐๅฝ•ๆ“ไฝœ่€…ใ€ๆ“ไฝœ IDใ€ๆ—ถ้—ดใ€ๅŽŸๅ› ๅ’Œ็ป“ๆžœ๏ผŒไธ่ฎฐๅฝ•ๆ˜Žๆ–‡ๆ•ๆ„Ÿ่ต„ๆ–™ใ€‚ +- **้ชŒๆ”ถ**๏ผš่ฟœ็จ‹ๆˆๅŠŸไธ€ๆฌก โ†’ ๅๆฌก็ป“ๆžœๅคฑ่ดฅ โ†’ ้‡ๅฏ โ†’ ไบบๅทฅ่กฅๅฟ โ†’ GoChat ๆœ€็ปˆๆ”ถๅˆฐ๏ผ›ๅ…จ็จ‹่ฟœ็จ‹่ฐƒ็”จๆฐไธบไธ€ๆฌกใ€‚่ฆ†็›–้‡ๅค/ๅนถๅ‘่กฅๅฟใ€่ทจ็งŸๆˆทใ€ๆ— ็ป“ๆžœ/ไป delivering/ๅทฒ่กฅไผ ๆˆๅŠŸ็š„่ฏทๆฑ‚๏ผ›ๆไพ›ๅ‡†็กฎๅฏๆ‰ง่กŒ็š„ runbook ๆญฅ้ชค๏ผŒไธๆœๆ’ฐๅฐšไธๅญ˜ๅœจ็š„ CLIใ€‚ + +### F11 โ€” P1๏ผšcatalog ่ฏปๅ–ๅŠๅ›žไผ ๅŒๆ•…้šœไนŸๅฟ…้กปๅฏๆขๅค + +- **ๅฏนๅบ”**๏ผšR14/R18๏ผ›V11ใ€‚ +- **ไฝ็ฝฎ**๏ผš`channels/shangwutong/internal/account/manager.go:SyncClassifications`๏ผ›`internal/httpapi/server.go`๏ผ›catalog client๏ผ›GoChat catalog ็Šถๆ€ handlerใ€‚ +- **้—ฎ้ข˜**๏ผšๅฝ“ๅ‰ๅŒๆญฅ็›ดๆŽฅๆ‰ง่กŒ๏ผŒๅคฑ่ดฅ็Šถๆ€ๅ›žไผ ้”™่ฏฏ่ขซๅฟฝ็•ฅ๏ผŒไธๅ—ไธšๅŠก operation ็ป“ๆžœ้˜Ÿๅˆ—ไฟ่ฏใ€‚ +- **ไฟฎๅค**๏ผšๅค็”จๆ—ขๆœ‰ๆŒไน…ไปปๅŠก/็ป“ๆžœๅŸบ็ก€่ฎพๆ–ฝไฟๅญ˜ๅŒๆญฅไปฃ้™…ๅ’Œๅพ…ๅ›žไผ ็ป“ๆžœ๏ผ›ไธๅพ—ๆŠŠโ€œๅŒๆญฅ่ฏทๆฑ‚ๅ—็†โ€่ง†ไธบโ€œ็›ฎๅฝ•ๆ›ดๆ–ฐๅฎŒๆˆโ€ใ€‚ๅ›žไผ ๅคฑ่ดฅไธŽ้‡ๆ–ฐ่ฏปๅ–่ฟœ็จ‹ catalog ็š„็ญ–็•ฅๅˆ†ๅผ€๏ผŒไธ่ƒฝ้‡ๆ–ฐ่ฏปๅ–ๅŽๅฐ†ๆ—งไปฃ้™…ไผช่ฃ…ๆˆๆ–ฐ็ป“ๆžœใ€‚ +- **้ชŒๆ”ถ**๏ผš่ฟœ็จ‹่ฏปๅ–ๅคฑ่ดฅไธ” GoChat ๅŒๆ—ถไธๅฏ่พพ๏ผ›่ฟœ็จ‹่ฏปๅ–ๆˆๅŠŸไฝ†็ป“ๆžœๅ›žไผ ๅคฑ่ดฅ๏ผ›ไปปๆ„้˜ถๆฎต้‡ๅฏ๏ผ›ๆ–ฐไปฃ้™…ๅ–ไปฃๆ—งไปฃ้™…ใ€‚ๆขๅคๅŽๅฏ่ฟฝ่ธช็ปˆๆ€ใ€ไธๆ— ้™ syncingใ€ไธ่ขซๆ—ง็ป“ๆžœ่ฆ†็›–ใ€‚ + +### F12 โ€” P1๏ผš้•ฟๅค‡ๆณจ็ผ–่พ‘ไธๅพ—้™้ป˜ๆˆชๆ–ญ + +- **ๅฏนๅบ”**๏ผšR09/R16๏ผ›V17ใ€‚ +- **ไฝ็ฝฎ**๏ผšไธคๅค„ `ContactNotes.vue` ็š„ๆ–ฐ็ผ–่พ‘ๅผน็ช—๏ผ›`frontend/app/javascript/dashboard/components-next/Editor/Editor.vue`ใ€‚ +- **้—ฎ้ข˜**๏ผš็ผ–่พ‘ๅ™จ้ป˜่ฎค `maxLength=200`๏ผŒๆ—  actions slot ๆ—ถ watcher ไผš `slice(0, 200)`๏ผ›ๅทฒๆœ‰ๅˆๆณ•้•ฟๅค‡ๆณจ่ขซ็ผ–่พ‘ๅŽๅฏ่ƒฝไธขๅ†…ๅฎนใ€‚ +- **ไฟฎๅค**๏ผšๅœจๅค‡ๆณจ่ฐƒ็”จๅค„ๆ˜พๅผ็ฆ็”จๅถ็„ถ็ปงๆ‰ฟ็š„ๆˆชๆ–ญ๏ผŒๆˆ–ไฝฟ็”จๅทฒ็กฎ่ฎค็š„้ž็ ดๅๆ€ง้•ฟๅบฆๆ ก้ชŒใ€‚ไธๅพ—ไธบไบ†ๅค‡ๆณจไฟฎๆ”นๅ…จ็ซ™ Editor ้ป˜่ฎค่กŒไธบ๏ผ›ไธๅพ—ไฟๅญ˜ๆˆชๆ–ญๅŽ็š„ๆ•ฐๆฎ่€Œไธ็ป™้”™่ฏฏใ€‚ +- **้ชŒๆ”ถ**๏ผšไธคๅค„ UI ็ผ–่พ‘ >200 ๅญ—็ฌฆใ€ไธญๆ–‡/ๅคš่กŒ/ๅฏŒๆ–‡ๆœฌๅ†…ๅฎน๏ผŒPATCH ๅฎŒๆ•ด๏ผ›ๆœๅŠก็ซฏๆ‹’็ปๆ—ถ่‰็จฟไปๅฎŒๆ•ด๏ผ›ๆ™ฎ้€š Editor ่ฐƒ็”จๆ— ๅ›žๅฝ’ใ€‚ + +### F13 โ€” P1๏ผšๅค‡ๆณจๆ–ฐๅขž/็ผ–่พ‘ใ€ๅฟซๆท้”ฎใ€ๅˆ‡ๆขๅ’Œๅคฑ่ดฅ่‰็จฟ้š”็ฆป + +- **ๅฏนๅบ”**๏ผšR09/R16๏ผ›V17/V23ใ€‚ +- **ไฝ็ฝฎ**๏ผš`frontend/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactNotes.vue`๏ผ›`dashboard/components-next/Contacts/ContactsSidebar/ContactNotes.vue`ใ€‚ +- **้—ฎ้ข˜**๏ผš็ผ–่พ‘ๅ…ฑ็”จ create buffer๏ผŒCtrl/Cmd+Enter ไปๆ‰ง่กŒ onAdd๏ผ›่”็ณปไบบๅˆ‡ๆขๆœชๅ…ณ้—ญ edit๏ผ›ๆ—ง่ฏทๆฑ‚ๅฎŒๆˆๆธ…็ฉบๆ–ฐ่‰็จฟ๏ผ›่”็ณปไบบ่ฏฆๆƒ…ๅˆ›ๅปบๅคฑ่ดฅไป็ซ‹ๅณๆธ…็ฉบ่‰็จฟ๏ผˆๆ—ขๆœ‰้—ฎ้ข˜๏ผŒๅŽŸ่ฎกๅˆ’ๆ˜Ž็กฎ่ฆๆฑ‚ไฟฎๅค๏ผ‰ใ€‚ +- **ไฟฎๅค**๏ผšๆŒ‰ๅฝ“ๅ‰ๆจกๅผ่ทฏ็”ฑๅฟซๆท้”ฎๅนถ้˜ฒๆญข create ๅœจ edit ็Šถๆ€ๆ‰ง่กŒ๏ผ›้š”็ฆป่‰็จฟไธŽ็ผ–่พ‘่บซไปฝ๏ผŒๅˆ‡ๆขๆ—ถๅฎŒๆ•ด้‡็ฝฎ๏ผ›ๆ•่Žทๆไบค็›ฎๆ ‡/ไปฃ้™…๏ผŒๅฟฝ็•ฅๆ—ง่ฏทๆฑ‚ๅฏนๅฝ“ๅ‰ๅผน็ช—็š„ๆธ…็†ใ€‚ๅˆ›ๅปบใ€ๆ›ดๆ–ฐๆˆๅŠŸๅŽๆ‰ๆธ…็ฉบ๏ผŒๅคฑ่ดฅๆ็คบๅนถไฟ็•™่‰็จฟ๏ผ›ไธ็งป้™ค้”ฎ็›˜ๆ”ฏๆŒๆฅๅ›ž้ฟ้”™่ฏฏใ€‚ +- **้ชŒๆ”ถ**๏ผšไธคๅค„ๆ–ฐๅขž/็ผ–่พ‘/ๅˆ ้™คไธŽๅคฑ่ดฅ๏ผ›้”ฎ็›˜ไฟๅญ˜็ผ–่พ‘ๅชๆœ‰ PATCH ๆ—  POST๏ผ›A ็ผ–่พ‘ๅˆ‡ Bใ€A ๆไบคๅŽ B ่ตท่‰ๅ†่ฟ”ๅ›ž A ๅ“ๅบ”๏ผ›ๆ— ่ทจ่”็ณปไบบ่ฏทๆฑ‚ๅ’Œ่‰็จฟๆŸๅคฑ๏ผŒ้‡ๅค็‚นๅ‡ปไธ้‡ๅคๆไบคใ€‚ + +### F14 โ€” P1๏ผšๆ”นๅ่ฟœ็จ‹็Šถๆ€็œŸๆญฃ่ฟ›ๅ…ฅ UI + +- **ๅฏนๅบ”**๏ผšR03๏ผ›V04/V23ใ€‚ +- **ไฝ็ฝฎ**๏ผš`backend/internal/handler/api/v1/crm_serializer.go`๏ผ›ๅ‰็ซฏ `conversation/contact/ContactInfo.vue`ใ€`components-next/Contacts/Pages/ContactDetails.vue` ๅŠ่”็ณปไบบ storeใ€‚ +- **้—ฎ้ข˜**๏ผšๅบๅˆ—ๅŒ–ไบ† `shangwutong_contact_name_operation`๏ผŒๅ‰็ซฏๆฒกๆœ‰ๆถˆ่ดน่€…๏ผ›ๆœฌๅœฐไฟฎๆ”นๆˆๅŠŸๆ็คบไธไปฃ่กจ่ฟœ็จ‹ๆˆๅŠŸใ€‚ +- **ไฟฎๅค**๏ผšไธคๅค„ๆ”นๅๅ…ฅๅฃๅฑ•็คบๆฏไธช็›ธๅ…ณ inbox ็š„ pending/succeeded/failed/uncertain ๅŠๅฎ‰ๅ…จ้”™่ฏฏไฟกๆฏ๏ผ›ๆœฌๅœฐไฟๅญ˜ๅ’Œ่ฟœ็จ‹็กฎ่ฎคๅˆ†ๅผ€ใ€‚ๅค็”จๅฎž้™…่”็ณปไบบๆ•ฐๆฎๅˆทๆ–ฐๆœบๅˆถ๏ผŒไธๆ— ๆกไปถๆ–ฐๅขž่ฝฎ่ฏข๏ผ›ๅˆทๆ–ฐ/้‡ๅ…ฅๆขๅค็Šถๆ€๏ผŒๅŒ่”็ณปไบบๅคš inbox ไธ่ƒฝๅˆๅนถๆˆๅ•ไธ€ๆˆๅŠŸๆ็คบใ€‚ +- **้ชŒๆ”ถ**๏ผšๆœฌๅœฐไฟๅญ˜ๆˆๅŠŸไฝ†่ฟœ็จ‹ๆ‹’็ป/ๅ“ๅบ”ไธขๅคฑ/็ผบ CID/ๅ่ฎฎๆœชๅผ€ๆ”พ๏ผ›ๅคšไธช inbox ไธๅŒ็ป“ๆžœ๏ผ›ๆต่งˆๅ™จๅˆทๆ–ฐไปๆญฃ็กฎใ€‚ๅฐ†ๅฎž้™… API ๅบๅˆ—ๅŒ–็ป“ๆžœไบค็ป™็ป„ไปถ๏ผŒไธๅชๆ‰‹ๅ†™็Šถๆ€ mockใ€‚ + +### F15 โ€” P1๏ผšๅˆ†็ฑป UI ๆŒไน…็Šถๆ€ใ€็‹ฌ็ซ‹ๅญ—ๆฎตไธŽ็ญ‰ๅพ…่ฏญไน‰ไธ€่‡ด + +- **ๅฏนๅบ”**๏ผšR17๏ผ›V09/V12/V23ใ€‚ +- **ไฝ็ฝฎ**๏ผš`frontend/app/javascript/dashboard/routes/dashboard/conversation/ShangwutongClassifications.vue`ใ€‚ +- **้—ฎ้ข˜**๏ผšๅˆทๆ–ฐไธๆขๅค operation ็Šถๆ€/่ฏทๆฑ‚ๅ€ผ๏ผ›ไธ€ไธชๅญ—ๆฎตๅคฑ่ดฅ่ฐƒ็”จๅ…จ้‡ resetSelections ๅนฒๆ‰ฐๅฆไธ€ไธช pending ๅญ—ๆฎต๏ผ›ๅๆฌกๆŸฅ่ฏขๅŽๆœฌๅœฐๅˆถ้€  uncertain ๅนถๅ…่ฎธๅ†ๆฌกๆไบคใ€‚ +- **ไฟฎๅค**๏ผšไปŽๆŒไน…ๅŒ– operation ๆขๅคๅทฒ็กฎ่ฎคๅ€ผใ€่ฏทๆฑ‚ๅ€ผๅ’Œ็Šถๆ€๏ผ›ๅช้‡็ฝฎๅคฑ่ดฅๅญ—ๆฎต๏ผ›ๅœๆญข่ง‚ๅฏŸๅชๆ„ๅ‘ณ็€ๅ‰็ซฏๆš‚ๆœช่Žทๆ–ฐ็ป“ๆžœ๏ผŒไธ่ƒฝๆ”นๅ˜่ฟœ็จ‹็Šถๆ€ใ€‚ๆ˜Ž็กฎ pending/uncertain ๅฑ•็คบๅŠ้‡ๆ–ฐๆไบค่ง„ๅˆ™๏ผ›ไฟๆŒไผš่ฏ/inbox/ๅธ่ฝฝไปฃ้™…ไฟๆŠคใ€‚ +- **้ชŒๆ”ถ**๏ผš้ขœ่‰ฒ pendingใ€chatkind ๅคฑ่ดฅๅŠๅๅ‘็ป„ๅˆ๏ผ›pending/failed/uncertain ้กต้ข้‡่ฝฝ๏ผ›>5 ็ง’ไป pending๏ผ›ๆ—งๆˆๅŠŸ/ๅคฑ่ดฅ/ๆŸฅ่ฏขๅ›žๅŒ…ๆ™šๅˆฐๆ–ฐไผš่ฏ๏ผ›catalog ็ฉบ/่ฟ‡ๆœŸ/ID ๅˆ ้™คๆˆ–ๆ”นๅ๏ผ›ๆ— ่™šๅ‡็Šถๆ€ใ€่ฏฏๆธ…้€‰ๆ‹ฉๆˆ–้‡ๅคๆ“ไฝœใ€‚ + +### F16 โ€” P1๏ผšไผš่ฏๆ ‡็ญพ่ทจๅ†™ๅ…ฅๅ…ฅๅฃ้‡‡็”จไธ€่‡ดๆ•ฐๆฎๆบ + +- **ๅฏนๅบ”**๏ผšR13๏ผ›V15ใ€‚ +- **ไฝ็ฝฎ**๏ผš`backend/internal/service/conversation_service.go`ใ€`repository/conversation_repo.go`ใ€`repository/conversation_label_repo.go`ใ€`router/router.go`๏ผ›ๅ‰็ซฏ conversationLabels store/composableใ€‚ +- **้—ฎ้ข˜**๏ผšๆ™ฎ้€šๆ›ดๆ–ฐ/GET/ๅบๅˆ—ๅŒ–ไฝฟ็”จ `conversations.labels`๏ผ›ๆ‰น้‡ๅขžๅˆ /ๅ…ณ่”ๅˆ ้™คไฝฟ็”จ `conversation_labels`๏ผŒๆ— ๅŒๆญฅใ€‚ๆ•ฐ็ป„ๅ“ๅบ”ๆญฃ็กฎไธไปฃ่กจๅฎŒๆ•ด R13 ๅทฒๅฎŒๆˆใ€‚ +- **ไฟฎๅค**๏ผš่ฟฝ่ธช็œŸๅฎž่ทฏ็”ฑๅŠไธŠๆธธ่กŒไธบ๏ผŒ่ฎฉๆ‰€ๆœ‰ไปๅผ€ๆ”พ็š„ๅ†™ๅ…ฅๅค็”จไธ€่‡ดๆœๅŠก/ๆ•ฐๆฎๆบ๏ผŒๅฟ…่ฆๆ—ถไบ‹ๅŠกๅ†…ๅŒๆญฅ็Žฐๆœ‰่กจ็คบ๏ผ›ไธๅ…ˆๅผ•ๅ…ฅๆ— ๅ…ณ้‡ๆž„ใ€ๅŒๅ†™ๆก†ๆžถๆˆ–ๅˆ ่กจใ€‚่‹ฅๆถ‰ๅŠๅކๅฒๆ•ฐๆฎไฟฎๅค๏ผŒๅ…ˆ็›˜็‚นๅนถๆไพ›่ฟ็งป/ๅ›žๆปšๆ–นๆกˆใ€‚ +- **้ชŒๆ”ถ**๏ผšๆ‰น้‡ๆทปๅŠ โ†’GET/ๅบๅˆ—ๅŒ–/็ญ›้€‰๏ผ›ๆ™ฎ้€šๆทปๅŠ โ†’ๅ…ณ่”ๅˆ ้™คโ†’GET๏ผ›ๆ‰น้‡็งป้™คใ€ๆธ…็ฉบใ€้‡่ฝฝๅ’Œ็œŸๅฎž store/composable ๆถˆ่ดน๏ผ›ๅ…ณ่”็งป้™คไธๅพ—ๅˆ ้™ค่ดฆๆˆทๆ ‡็ญพใ€‚ + +### F17 โ€” P1 ้ชŒๆ”ถ็ผบๅฃ๏ผšๆฅๆบๆ ‡่ฎฐๅฟ…้กป้ชŒ่ฏ็œŸๅฎž่ฏทๆฑ‚่ทฏๅพ„ + +- **ๅฏนๅบ”**๏ผšR02/R14/R15๏ผ›V07/V19/V20ใ€‚ +- **ไฝ็ฝฎ**๏ผšConnector `internal/gochat/messaging.go`๏ผ›ๅŽ็ซฏ widget handlerใ€`WidgetService.PublicUpdateContact/updateContactFields`ใ€`ContactRepo.Update`ใ€`ContactService.Update`ใ€`ShangwutongContactListener`ใ€‚ +- **ไบ‹ๅฎž่พน็•Œ**๏ผšๅฎž้™… public API ่ตฐ WidgetServiceโ†’ContactRepo๏ผŒๆœชๅˆฐๆ–ฐๅขž origin ็š„ ContactService ไบ‹ไปถ็”Ÿไบง็‚น๏ผ›่ฏฅ่ทฏๅพ„ๅฝ“ๅ‰ไธๅ‘ contact event๏ผŒ**ไธ่ƒฝๆฎๆญคๅฎฃ็งฐๅฎž้™…ๅญ˜ๅœจๅๅ‘ๅพช็Žฏ**๏ผŒไนŸไธ่ƒฝๅฐ†ๅˆ†็ฆปๆต‹่ฏ•ๅฝ“ไฝœ็ซฏๅˆฐ็ซฏไผ ๆ’ญ่ฏๆฎใ€‚ +- **ไฟฎๅค/ๅ†ณ็ญ–**๏ผšไฟ็•™ public Widget ็›ดๆŽฅไฟๅญ˜่ทฏๅพ„๏ผŒๆ˜Ž็กฎไธไปŽๅ…ฌๅผ€่ฏทๆฑ‚ header ๆดพ็”Ÿ trusted origin๏ผŒไนŸไธ็”ฑ Connector client ๅ‘้€ๅฏไผช้€ ็š„ origin/event-ID header๏ผ›ๅฝ“ๅ‰่ทฏๅพ„ไธๅนฟๆ’ญ contact eventใ€‚่‹ฅๆœชๆฅๅฟ…้กปๅ‘ไบ‹ไปถ๏ผŒๅช่ƒฝๅœจๅฎž้™…ๅ—ไฟก็”Ÿไบง็‚นไผ ้€’ๆฅๆบใ€ไบ‹ไปถ ID ไธŽๆธ ้“่Œƒๅ›ดใ€‚ไธไธบโ€œ่ฏๆ˜Žๅญ—ๆฎตๅญ˜ๅœจโ€ๆ— ๆกไปถๅนฟๆ’ญ๏ผ›ไปปไฝ•ๆ–ฐๅขžไบ‹ไปถ่กŒไธบๅ…ˆๅฏน็…งไธŠๆธธ๏ผŒ่ฎฐๅฝ•ๆœ‰ๆ„ๅทฎๅผ‚ใ€‚ +- **้ชŒๆ”ถ**๏ผš็”จ Connector clientโ†’็œŸๅฎž้‰ดๆƒ/public contact ่ทฏ็”ฑโ†’ๆ•ฐๆฎๅบ“/ไบ‹ไปถโ†’listener ่ดฏ้€šๆต‹่ฏ•๏ผ›่ฟœ็จ‹ๆ›ดๆ–ฐไธไบง็”Ÿ renameใ€ๆœฌๅœฐไบบๅทฅๆ”นๅไปไบง็”Ÿๆญฃ็กฎไปปๅŠกใ€‚่ฆ†็›–ไผช้€ ๆฅๆบใ€้‡ๅคๅ…ฅ็ซ™ใ€ๅคšไธช inbox๏ผŒไฟๆŒ Note/Label ไธๅค–ๆณ„๏ผ›็‹ฌ็ซ‹่ฟœ็จ‹ไบ‹ไปถ็š„็œŸๅฎž่ฏญไน‰ไปๅพ…ๆŽˆๆƒ่ฏๆฎใ€‚ + +### F18 โ€” P2๏ผš็›‘ๆŽงไธŽๅฎŒๆˆๅฃฐๆ˜Žๅฟ…้กปๅฏนๅบ”ๅฎž้™…ๅฎž็Žฐ + +- **ๅฏนๅบ”**๏ผšR12/R18๏ผ›V06/V25ใ€‚ +- **ไฝ็ฝฎ**๏ผš`channels/shangwutong/internal/observability/metrics.go`๏ผ›Connector READMEใ€runbookใ€ๅˆ†็ฑป่ฎกๅˆ’ใ€ๅŽŸ่ƒฝๅŠ›่ฎกๅˆ’ใ€‚ +- **้—ฎ้ข˜**๏ผšrunbook ๆ–ฐๅˆ—็š„่‹ฅๅนฒ rename/catalog/stale ๆŒ‡ๆ ‡ๆ— ็”Ÿไบง่€…๏ผ›็›‘ๆŽงไปไธป่ฆ่ฆ†็›–ๆถˆๆฏ่€Œ้ž operationใ€‚ๅฎŒๆˆๆ ‡่ฎฐ่ถ…ๅ‡บไบ†ๅฎž้™…็”จไพ‹ไธŽๅฎž็Žฐใ€‚ +- **ไฟฎๅค**๏ผšไผ˜ๅ…ˆไฝฟ็”จๅทฒๆœ‰ metrics ้€š้“ๅขžๅŠ ๅฟ…่ฆ operation ็ปŸ่ฎก๏ผŒๆˆ–ๆ˜Ž็กฎๆ ‡ๆณจโ€œ่ง„ๅˆ’/ๆœชๅฎž็Žฐโ€๏ผ›ๅฎž็Žฐๅ‰ไธๅพ—ไฝœไธบๅฏ็”จๅ‘Š่ญฆๆŒ‡ๅผ•ใ€‚่ฆ†็›– pending ๆœ€่€ๅนด้พ„ใ€CID ็ญ‰ๅพ…/ๅˆฐๆœŸใ€็ป“ๆžœ่€—ๅฐฝใ€catalog ๆ•…้šœๅ’Œ่ดฆๅท้˜Ÿๅคด้˜ปๅกž๏ผ›้ฟๅ… contact/SID/event_id ็ญ‰้ซ˜ๅŸบๆ•ฐๆ•ๆ„Ÿๆ ‡็ญพใ€‚ +- **้ชŒๆ”ถ**๏ผšๆŒไน…ไปปๅŠกๅ˜ๅŒ–็œŸๅฎžๅๆ˜ ๅœจ exporter/ๆŸฅ่ฏขไธญ๏ผ›็ป“ๆžœ่€—ๅฐฝๅฏ่งไธ”ๆœ‰ๆ“ไฝœๆ‰‹ๅ†Œ๏ผ›ๅฆไธ€ SID ่ขซ้˜Ÿๅคด้˜ปๅกžๅฏ่ง‚ๅฏŸ/ๆขๅค๏ผ›้˜ˆๅ€ผใ€่ดŸ่ดฃไบบใ€่กฅๅฟๅ…ฅๅฃๅ‡ๆœ‰็œŸๅฎž้…็ฝฎๆˆ–ๆ˜Ž็กฎๆœชๅฎŒๆˆๆ ‡่ฎฐใ€‚ๆ–‡ๆกฃไธๅฐ†ๆ—ถ้—ด่ถ…ๆ—ถๆ่ฟฐไธบ่ฟœ็จ‹ๆ˜Ž็กฎๅคฑ่ดฅใ€‚ + +## 4. ๅฎžๆ–ฝ้กบๅบไธŽไพ่ต– + +ๆฏ้กนๅฟ…้กปๅ…ˆ่กฅ่ƒฝๅคŸๅคฑ่ดฅ็š„ๆœ€ๅฐๅ›žๅฝ’๏ผŒๅ†ไฟฎๅ…ฑไบซ่ทฏๅพ„๏ผ›ไธ็”จๆต‹่ฏ•ๆ•ฐ้‡ๆ›ฟไปฃๅœบๆ™ฏ่ฆ†็›–ใ€‚่ดŸ่ดฃไบบใ€reviewerใ€็ŽฏๅขƒไธŽ่ฏๆฎ่ทฏๅพ„ๅœจๅผ€ๅทฅ/้ชŒๆ”ถๆ—ถ็™ป่ฎฐ๏ผŒๆœฌๆ–‡ไธ่™šๆž„ไบบๅ‘˜ๆˆ–ๆ—ฅๆœŸใ€‚ + +| ้˜ถๆฎต | ๅทฅไฝœ | ไพ่ต–ไธŽ้€€ๅ‡บๆกไปถ | +| --- | --- | --- | +| A๏ผšๆญขๆŸ | F01ใ€F12ใ€F13 | ๅ่ฎฎๆœช้ชŒ่ฏไธไผšๅ‘่ฏทๆฑ‚๏ผ›ๅค‡ๆณจๆ— ๆˆชๆ–ญ/้‡ๅคๆ–ฐๅขž/ไธข่‰็จฟ | +| B๏ผšๆ“ไฝœ่บซไปฝไธŽๆŒไน…่พน็•Œ | F02ใ€F03ใ€F04ใ€F05ใ€F06 | ๅ›ž่ฐƒ่บซไปฝ/ไปฃ้™…้”ๅ†…ๆ ก้ชŒ๏ผŒไปปๅŠกไธŽ็Šถๆ€ไธ€่‡ด๏ผŒๅคฑๆ•ˆๆ“ไฝœๆ— ้ƒจๅˆ†ๆไบค | +| C๏ผšๆธ ้“่บซไปฝไธŽ Connector ๆขๅค | F07ใ€F08ใ€F09ใ€F10ใ€F11 | ไพ่ต– B ็š„็ป“ๆžœๅฅ‘็บฆ๏ผ›่Œƒๅ›ดใ€ๆˆชๆญขใ€ไธ็กฎๅฎšใ€ไป…็ป“ๆžœ่กฅๅฟ่ดฏ้€š | +| D๏ผšๆœฌๅœฐๅฅ‘็บฆไธŽ UI | F14ใ€F15ใ€F16ใ€F17 | F16 ๅฏ็‹ฌ็ซ‹ๆŽจ่ฟ›๏ผ›UI ไฝฟ็”จ B/C ็š„็œŸๅฎžๆŒไน…็Šถๆ€๏ผŒๆฅๆบ็ป“่ฎบๆœ‰็œŸๅฎž่ทฏ็”ฑ่ฏๆฎ | +| E๏ผš่ฟ็ปดไธŽ้ชŒๆ”ถ | F18ใ€ๅ…จ้‡ๅ›žๅฝ’ใ€V14/V20 ้—็•™้—จ็ฆ | ๆ–‡ๆกฃ/ๆŒ‡ๆ ‡/่กฅๅฟไธ€่‡ดใ€P0/P1 ๆธ…้›ถ๏ผ›ๅค–้ƒจๆŽˆๆƒไปๅ•็‹ฌ็”ณ่ฏท | + +ๅŒไธ€ๅทฅไฝœๆ ‘ๅŒๆ—ถๅชๅ…่ฎธไธ€ไธชๅ†™ๅ…ฅ่€…๏ผ›ๅนถ่กŒๅช่ƒฝๅˆ†็ฆปๅทฅไฝœๆ ‘ๆˆ–ๅšๅช่ฏป reviewใ€‚ๅ†ป็ป“ๅŽŸ่ฎกๅˆ’ ยง3.2 ไธญๆœชๅ†ณ็š„่บซไปฝ/ๆƒ้™/ไปฃ้™…็ญ–็•ฅ๏ผŒไธ็”ฑๅฎžๆ–ฝ่€…ๆ“…่‡ชๆ‰ฉๅคงๅŠŸ่ƒฝใ€‚ + +ๅฆ‚ๅฟ…้กปๆ–ฐๅขžๆŒไน…ๅญ—ๆฎต๏ผšๅŽ็ซฏไฝฟ็”จ `backend/migrations/` ็ผ–ๅท up/down๏ผ›Connector ไฝฟ็”จๅ…ถ `db/migrations/` ๅนถๆ ธๅฏน `sqlc.yaml` schema ่พ“ๅ…ฅ๏ผŒๆ‰ง่กŒ `go tool sqlc generate` ๆ›ดๆ–ฐ็”Ÿๆˆไปฃ็ ๏ผŒไธ็›ดๆŽฅๆ‰‹ๆ”น generatedใ€‚SQLite Connector ้‡ๅฏๆต‹่ฏ•ไธŽๅŽ็ซฏ PostgreSQL ๅนถๅ‘/่ฟ็งปๆต‹่ฏ•ๅˆ†ๅˆซ้ชŒๆ”ถ๏ผŒไบŒ่€…ไธ่ƒฝไบ’็›ธๆ›ฟไปฃใ€‚ + +## 5. ๅŽŸ่ฆๆฑ‚่ฆ†็›–ไธŽ็Šถๆ€็™ป่ฎฐ + +ไปฅไธ‹ๆ˜ ๅฐ„็”จไบŽๅ›žๅกซๅŽŸ่ฎกๅˆ’๏ผŒไธๆ–ฐๅขž็ฌฌไบŒๅฅ—ไบงๅ“ๅฅ‘็บฆใ€‚F ้กนๅฎŒๆˆไธ่‡ชๅŠจๆ„ๅ‘ณ็€ๅฏนๅบ” R ็š„็œŸๅฎž็ฐๅบฆๅฎŒๆˆใ€‚ + +| ๅŽŸ่ฆๆฑ‚ | ๅฝ“ๅ‰ๅˆคๅฎš | ๆœฌๆฌกๆ•ดๆ”น/ไปไฟ็•™้—จ็ฆ | +| --- | --- | --- | +| R02 | ้ƒจๅˆ†ๅฎž็Žฐ๏ผŒๅฎ‰ๅ…จ้—จ็ฆๆœช้—ญๅˆ | F01ใ€F17๏ผ›็œŸๅฎž cnote ่ฏญไน‰ๅŠๆŽˆๆƒ | +| R03 | ้ƒจๅˆ†ๅฎž็Žฐ๏ผŒ็ป“ๆžœ้“พ่ทฏไธŽ UI ๅ‡ๆœ‰็ผบๅฃ | F02โ€“F04ใ€F09โ€“F10ใ€F14 | +| R04 | ้ƒจๅˆ†ๅฎž็Žฐ | F04ใ€F08๏ผ›็ญ‰ๅพ…/้‡ๅฏ/่ฟ‡ๆœŸ็ซฏๅˆฐ็ซฏ็”จไพ‹ | +| R06 | ้ƒจๅˆ†ๅฎž็Žฐ | F06โ€“F07ใ€F15๏ผ›็œŸๅฎžๅŒ CID ๅคš SID ่ฏๆฎ | +| R09 | ้ƒจๅˆ†ๅฎž็Žฐ | F12โ€“F13๏ผ›ๅކๅฒ notes/contact_notes ็›˜็‚นๅ’Œ่ฟ็งป้—จ็ฆไปไฟ็•™ | +| R12 | ้ƒจๅˆ†ๅฎž็Žฐ | F18๏ผ›็บ ๆญฃๆ–‡ๆกฃๅณๅฏๅ…ˆไบคไป˜๏ผŒไธๅ‡็งฐๆŒ‡ๆ ‡/็ฐๅบฆๅฎŒๆˆ | +| R13 | ้ƒจๅˆ†ๅฎž็Žฐ๏ผŒๅ“ๅบ”ๆ•ฐ็ป„ๅทฒไฟฎ | F16๏ผ›ๆททๅˆ็œŸๅฎž่ทฏ็”ฑไธ€่‡ดๆ€ง | +| R14 | ้ƒจๅˆ†ๅฎž็Žฐ | F02โ€“F07ใ€F11ใ€F17๏ผ›ๅนถๅ‘ๅ’Œๅฎž้™…ๆ“ไฝœๅ…ณ่” | +| R17 | ้ƒจๅˆ†ๅฎž็Žฐ | F15๏ผ›ๆŒไน…็Šถๆ€/็‹ฌ็ซ‹ๅญ—ๆฎต/ๆต่งˆๅ™จไบคไบ’ | +| R18 | ้ƒจๅˆ†ๅฎž็Žฐ | F03ใ€F08โ€“F11ใ€F18๏ผ›ๆขๅค/่กฅๅฟ/็›‘ๆŽงไธๆ˜ฏไป…ๅพ…ๆผ”็ปƒ | +| R01/R05/R07/R08/R10/R11 | ไฟๆŒๅพ…็กฎ่ฎค/ๅพ…ๅฎžๆ–ฝ | ไบงๅ“ใ€ๆœช็Ÿฅๅ่ฎฎใ€ๆƒ้™ใ€ๅฏ้€‰ๅ…ฅๅฃใ€ๅކๅฒ่Œƒๅ›ดใ€่ทจ็ณป็ปŸ/็œŸๅฎž็ฐๅบฆ๏ผŒไธๅ› ๆ•ดๆ”น่‡ชๅŠจๅผ€ๆ”พ | +| R15/R16 | ไฟๆŒ้ƒจๅˆ†ๅฎž็Žฐ | ๅˆๅนถ/่ฝฏๅˆ ้™คใ€ๅœ็”จ/ๅˆ ้™ค/้‡็ป‘ใ€้…็ฝฎไปฃ้™…ใ€ๅ…ญ็ปดไบŒๅ€ผๆƒ้™็œŸๅฎž่ทฏ็”ฑ้ชŒๆ”ถ๏ผ›่ง V14/V20 | + +้—็•™้—จ็ฆๅฟ…้กป่ฆ†็›–๏ผšๆ—งไปปๅŠกไธๅพ—ๆŠ•้€’ๅˆฐ้‡ๆ–ฐ็ป‘ๅฎš็š„็ซ™็‚น๏ผ›ๆ—งๅ›ž่ฐƒไธๅพ—ๅ†™ๅ…ฅๅˆๅนถ/ๅˆ ้™ค/้‡ๅปบๅŽ็š„้”™่ฏฏๅฏน่ฑก๏ผ›ๆƒ้™ๆต‹่ฏ•ๅฟ…้กปๆŒ‚่ฝฝ็œŸๅฎž middlewareใ€‚ๅทฒๆœ‰่ดฆๆˆทๅฝ’ๅฑžๆฃ€ๆŸฅๆˆ–ๆœๅŠก็ญพๅไธ็ญ‰ไบŽๅฎŒๆ•ด็”จๆˆทๆƒ้™/็”Ÿๅ‘ฝๅ‘จๆœŸ้ชŒๆ”ถใ€‚ + +## 6. ๅ›žๅฝ’ๆ‰ง่กŒไธŽ่ฏๆฎๆธ…ๅ• + +ไธ‹ๅˆ—ๆ˜ฏๅฏๅค่ท‘็š„้ชŒๆ”ถๅ‘ฝไปค๏ผ›ๆœฌ่ฝฎๆ‰ง่กŒ็ป“ๆžœ็™ป่ฎฐๅœจ ยง6.5ใ€‚ๆฏๆฌกๅค่ท‘ไปๅบ”่ฎฐๅฝ• commit/dirty diff ๅŸบ็บฟใ€ๅ‘ฝไปคใ€็Žฏๅขƒใ€้€€ๅ‡บ็ ใ€ๅ…ณ้”ฎๆ–ญ่จ€ใ€ๆ—ฅๅฟ—่ทฏๅพ„ไธŽๅ‰ฉไฝ™้ฃŽ้™ฉใ€‚ + +```bash +# ไป“ๅบ“ๆ น็›ฎๅฝ•๏ผšConnector๏ผˆไฝฟ็”จๆœฌๆจกๅ— toolchain๏ผ‰ +(cd channels/shangwutong && go test -race ./... -count=1) +(cd channels/shangwutong && go vet ./... && go build ./...) +# ไฟฎๆ”นไบ†ๆŸฅ่ฏข/schema ๆ‰ๆ‰ง่กŒ๏ผŒๅนถๆฃ€ๆŸฅ็”Ÿๆˆ diff๏ผš +(cd channels/shangwutong && go tool sqlc generate) + +# ๅŽ็ซฏ Go module ๅœจ backend๏ผŒไธๅœจไป“ๅบ“ๆ น็›ฎๅฝ• +(cd backend && GOCHAT_TEST_DB=sqlite go test ./... -count=1) +(cd backend && GOCHAT_TEST_DB=sqlite go test -race ./... -count=1) +# PostgreSQL ้š”็ฆปๅฎžไพ‹ไธŠ็š„ๅนถๅ‘/ๅ†’็ƒŸ้—จ็ฆ๏ผˆ่ฟžๆŽฅไธฒ็”ฑ็Žฏๅขƒๆไพ›๏ผŒไธๆไบค๏ผ‰ +(cd backend && GOCHAT_TEST_DB=postgres GOCHAT_TEST_DB_URL="$GOCHAT_TEST_DB_URL" go test -race ./internal/service -run 'TestEnsureCaptainAgentBotBindingConcurrentCallsStayUnique|TestCaptainSkillServicePostgresSmoke' -count=1) +(cd backend && go vet ./... && go build ./...) + +# ๅ‰็ซฏ๏ผš้œ€ๅขžๅŠ ็ป„ไปถ็”จไพ‹๏ผŒไธไป… action/mutation mock +(cd frontend && TZ=UTC pnpm test) +(cd frontend && pnpm build) +# ๅฏนๅฎž้™…ไฟฎๆ”นๆ–‡ไปถๅˆ—่กจๅฆ่ท‘ pnpm exec eslint ไธŽ pnpm exec prettier --check + +git diff --check +``` + +- PostgreSQL๏ผšไฝฟ็”จไธ“็”จ้š”็ฆปๆ•ฐๆฎๅบ“/schema๏ผŒๆŒ‰็Žฐๆœ‰ๆต‹่ฏ•้…็ฝฎ่ฟ่กŒๆ–ฐๅขžไบ‹ๅŠก/้”/่ฟ็งป็”จไพ‹๏ผ›ไธๅพ—่ฟžๆŽฅ็”Ÿไบงๅบ“ใ€‚ๅ…ทไฝ“ๅ‘ฝไปคๅ’Œ่ฟžๆŽฅ้…็ฝฎๅœจๅฎž็Žฐ็”จไพ‹ๆ—ถ่ฎฐๅฝ•๏ผŒไธไผช้€ ็›ฎๅ‰ไธๅญ˜ๅœจ็š„ๆต‹่ฏ•่„šๆœฌใ€‚ +- Connector๏ผš็”จๆŒไน… SQLite ๆ•ฐๆฎๅบ“้‡ๅผ€่ฟ›็จ‹/ๅญ˜ๅ‚จ๏ผŒ่ฏๆ˜Žๆขๅค๏ผ›่ฐƒ็”จ่ฎกๆ•ฐ่ดฏ็ฉฟๅ‘้€ใ€็ป“ๆžœๅ›ž่ฐƒใ€่€—ๅฐฝใ€่กฅๅฟ๏ผŒไธ่ƒฝๅช็›ดๆŽฅ่ฐƒ็”จ recovery SQL ๅฐฑๅฎฃ็งฐๅฎŒๆ•ด้‡ๅฏ้ชŒๆ”ถใ€‚ +- ่ทจๆœๅŠก๏ผš่‡ณๅฐ‘ไธ€็ป„ๅฎž้™… GoChat ็Šถๆ€่ทฏ็”ฑๆถˆ่ดน Connector ๆŒไน…็ป“ๆžœ๏ผŒ่ฆ†็›– uncertain/่€—ๅฐฝใ€ๆœช็Ÿฅ/ๅผ‚ๅ€ผใ€ไปฃ้™…ๅ†ฒ็ชใ€‚ +- ็ป„ไปถ๏ผšไธคๅค„ๅค‡ๆณจใ€ๆ–ฐๅขž/็ผ–่พ‘ๅฟซๆท้”ฎ/้•ฟๅ†…ๅฎน/่‰็จฟ๏ผ›ๆ ‡็ญพๆททๅˆ APIโ†’store๏ผ›ๅˆ†็ฑปๅนถ่กŒ็Šถๆ€/ๅˆทๆ–ฐ/ๅˆ‡้กต๏ผ›ไธคๅค„ rename ็Šถๆ€ๆ˜พ็คบใ€‚ +- ๆ•…้šœๆณจๅ…ฅ๏ผšๅฏๆŽงๅฑ้šœ/ไบ‹ๅŠกๆ•…้šœ/ๆ˜Ž็กฎๆ—ถ้’Ÿ่พน็•Œ๏ผŒไธไพ่ต– sleep ็ซžๆ€็ขฐ่ฟๆฐ”๏ผ›็•™ไธ‹็จณๅฎšๆ–ญ่จ€็š„ไป“ๅบ“ๆต‹่ฏ•ใ€‚ +- ๆต่งˆๅ™จ E2E๏ผšๅทฒๆŒ‰็”จๆˆทๆŽˆๆƒไฝฟ็”จ browser-harness ๆ“ไฝœๆต‹่ฏ•็ซ™็‚น๏ผ›ๅฝ•ๅˆถ่ทฏๅพ„ๅ’Œๆญฃ/่ดŸ็ป“ๆžœๅ‡็™ป่ฎฐๅœจ `reference/shang-wu-tong/results/real-tests/`๏ผŒไธๅพ—ๅฐ†ไธ€ๆฌก UI ๆœช่ง‚ๅฏŸๅˆฐๅ›žๅค่งฃ้‡Šไธบ่ฟœ็จ‹ๅคฑ่ดฅใ€‚ +- ๆ•ๆ„Ÿไฟกๆฏ๏ผš่ฏๆฎไปฅๆ“ไฝœ ID ๅ…ณ่”๏ผŒ่„ฑๆ•ๅง“ๅใ€ๅค‡ๆณจใ€tokenใ€็ญพๅ๏ผ›ไธๆไบคๅฏ†้’ฅใ€ๆ•ฐๆฎๅบ“ๆ–‡ไปถใ€ไธดๆ—ถๆ—ฅๅฟ—ใ€‚ + +ๆ•ดๆ”น็™ป่ฎฐๆจกๆฟ๏ผˆๆฏไธช F ็‹ฌ็ซ‹ๅกซๅ†™๏ผ‰๏ผš + +| F ็ผ–ๅท | ็Šถๆ€ | ่ดŸ่ดฃไบบ/reviewer | ไฟฎๅคๆไบค/ๆ–‡ไปถ | ๅฎšๅ‘ๆต‹่ฏ•/็Žฏๅขƒ | ็ป“ๆžœไธŽ่ฏๆฎ | ๆœชๅฎŒๆˆ้—จ็ฆ | +| --- | --- | --- | --- | --- | --- | --- | +| Fxx | ๅพ…ๅฎžๆ–ฝ/ๅฎžๆ–ฝไธญ/ๆœฌๅœฐ้ชŒๆ”ถ้€š่ฟ‡/ๅค–้ƒจ้—จ็ฆ้€š่ฟ‡ | ๅพ…็™ป่ฎฐ | ๅพ…็™ป่ฎฐ | ๅพ…็™ป่ฎฐ | ไธๅกซๅณๆœช้ชŒ่ฏ | ๅพ…็™ป่ฎฐ | + +## 6.5 ๅฝ“ๅ‰ๅฎžๆ–ฝ็™ป่ฎฐ๏ผˆ2026-09-13๏ผ‰ + +| F ็ผ–ๅท | ็Šถๆ€ | ไฟฎๅค/่ฏๆฎ | ๆœชๅฎŒๆˆ้—จ็ฆ | +| --- | --- | --- | --- | +| F01 | ๆœฌๅœฐ้ชŒๆ”ถ้€š่ฟ‡๏ผŒๅค–้ƒจ้—จ็ฆไฟ็•™ | `ChangeContactName` fail-closed๏ผ›่ฟœ็จ‹่ฐƒ็”จ่ฎกๆ•ฐๆต‹่ฏ•ไธบ 0 | ็œŸๅฎž `cnote` ็ผบ็œ/็ฉบไธฒ/ๅŽŸๅ€ผ่ฏญไน‰ไธŽๆŽˆๆƒ | +| F02 | ๆœฌๅœฐ้ชŒๆ”ถ้€š่ฟ‡๏ผŒ็œŸๅฎžๅ่ฎฎไป…้ƒจๅˆ† | ๅˆ†็ฑป/่”็ณปไบบๅ›ž่ฐƒ้”ๅ†…ๆ ก้ชŒ eventใ€operationใ€ๅฏน่ฑกใ€็›ฎๆ ‡ๅ’Œๅ€ผ๏ผ›ๅŒ event ๅผ‚ๅ€ผ 409๏ผ›็œŸๅฎžๅ…ฅ็ซ™ marker ๅทฒ่ขซ Go client ่งฃๆž | PostgreSQL ๅนถๅ‘ไธŽ็œŸๅฎž middleware ็Žฏๅขƒ | +| F03 | ๆœฌๅœฐ้ชŒๆ”ถ้€š่ฟ‡ | ็Šถๆ€/ไปปๅŠกไบ‹ๅŠกๅŒ–๏ผ›webhook 4xx ๆ ‡ failedใ€่ถ…ๆ—ถ/่€—ๅฐฝๆ ‡ uncertain๏ผ›ๆ–ฐๅขž่ดŸๅ‘ๅ›žๅฝ’ | PostgreSQL ๆ•…้šœๆณจๅ…ฅใ€publish/่ฟ›็จ‹้€€ๅ‡บๆผ”็ปƒ | +| F04 | ๆœฌๅœฐ้ชŒๆ”ถ้€š่ฟ‡ | ่”็ณปไบบ version/generation/event ๅฟซ็…งไธŽ่ฟŸๅˆฐๅ›ž่ฐƒไฟๆŠค | PostgreSQL ้”็ซžไบ‰ใ€้‡ๅฏ็ซฏๅˆฐ็ซฏ | +| F05 | ๆœฌๅœฐ้ชŒๆ”ถ้€š่ฟ‡ | catalog event ้”ๅ†…ๆ›ดๆ–ฐใ€ๅ”ฏไธ€ๅ†ฒ็ช upsertใ€ๆ—งไปฃ้™…ไฟๆŠค | PostgreSQL ไบค้”™ๅนถๅ‘ | +| F06 | ๆœฌๅœฐๅ›žๅฝ’้€š่ฟ‡ | ๅ…ˆ้”/้ชŒ่ฏๅ‘่ตทไผš่ฏๅ†ไผ ๆ’ญ๏ผŒๅคฑ่ดฅไธๅ†™ peer๏ผ›ๅŽ็ซฏ SQLite race ๅ…จ้‡้€š่ฟ‡ | PostgreSQL ้”/ไบ‹ๅŠก่ฏๆฎ | +| F07 | ๆœฌๅœฐๅ›žๅฝ’้€š่ฟ‡ | ๅŒ account/inbox/CID ่ฆ†็›–ไธๅŒ SID/contact-inbox๏ผ›่ทจ inbox/account ไธไธฒๅ†™ | ็œŸๅฎžๅˆๅนถ/้‡็ป‘ๆ•ฐๆฎไธŽ PostgreSQL | +| F08 | Connector ๅ›žๅฝ’้€š่ฟ‡ | CID ็ญ‰ๅพ…็‹ฌ็ซ‹ไบŽๅ‘้€ attempts๏ผŒwake/24h cutoff ๆœ‰ๅ›žๅฝ’ | ็œŸๅฎž worker ้‡ๅฏใ€่พน็•Œๆ—ถ้’ŸไธŽๅ่ฎฎไบ‹ไปถ | +| F09 | Connector ๅ›žๅฝ’้€š่ฟ‡ | `uncertain_since` ๅ›บๅฎš่ง‚ๅฏŸ่ตท็‚น๏ผ›็ป“ๆžœๅ›ž่ฐƒไธ็งปๅŠจๆˆชๆญขไธ”ไธๆ”นๆˆ็กฎๅฎšๅคฑ่ดฅ๏ผ›010 rollback ๆœ‰ๆ•ฐๆฎๆ—ถๆ‹’็ปไธข่ฏๆฎ | PostgreSQL/็œŸๅฎž็ป“ๆžœ่ทฏ็”ฑ | +| F10 | Connector ๆœฌๅœฐ้ชŒๆ”ถ้€š่ฟ‡ | `compensate-result` ไป…้‡ๆ”พๅทฒๆœ‰็ป“ๆžœๅนถๅ†™ๅฎก่ฎก | ๅๆฌก่€—ๅฐฝๅŽ็š„ๆŒไน… SQLite ้‡ๅฏ/ไบบๅทฅๆผ”็ปƒ | +| F11 | Connector ๆœฌๅœฐ้ชŒๆ”ถ้€š่ฟ‡ | catalog ่ฏปๅ–/ๅ›žไผ ๅˆ†้˜ถๆฎตๆŒไน…ๅŒ–๏ผŒ้‡่ฏ•ไธ้‡ๅค่ฏปๅ–ๅทฒไฟๅญ˜ catalog๏ผŒๅฏๅŠจๆขๅค syncing๏ผ›migration 010/011 ๅพ€่ฟ”ๅŠๆœ‰ๆ•ฐๆฎๆ—ถ rollback ๆ‹’็ปไธขๅคฑ่ฏๆฎ | ็œŸๅฎž GoChat ไธๅฏ่พพใ€PostgreSQL ๅ’Œๆ–ฐๆ—ง event ไบค้”™ | +| F12 | ๅ‰็ซฏๆต‹่ฏ•/ๆž„ๅปบ้€š่ฟ‡ | ไธคๅค„ๅค‡ๆณจ่ฐƒ็”จ็ฆ็”จๅถ็„ถ 200 ๅญ—็ฌฆๆˆชๆ–ญ | ๆต่งˆๅ™จ็ป„ไปถไบคไบ’ไธŽ็œŸๅฎžๆœๅŠก็ซฏๆ‹’็ป | +| F13 | ๅ‰็ซฏๆต‹่ฏ•/ๆž„ๅปบ้€š่ฟ‡ | create/edit ๅฟซๆท้”ฎใ€่”็ณปไบบไปฃ้™…ใ€ๅคฑ่ดฅไฟ็•™่‰็จฟๅ›žๅฝ’ | ๆต่งˆๅ™จ E2E ไธŽ่ฏทๆฑ‚ไบค้”™ | +| F14 | ๅ‰ๅŽ็ซฏ้™ๆ€/ๅฎšๅ‘้€š่ฟ‡ | ไธคๅค„่ฏฆๆƒ…้กตๆถˆ่ดนๆฏไธช inbox ็š„ๆ”นๅ็Šถๆ€ๅ’Œ้”™่ฏฏ | ๆต่งˆๅ™จๅˆทๆ–ฐใ€ๅคš inbox ๅฎž้™… API | +| F15 | ๅ‰็ซฏๆต‹่ฏ•/ๆž„ๅปบ้€š่ฟ‡ | ๅˆ†็ฑป็Šถๆ€ hydrationใ€ๅญ—ๆฎต็บง resetใ€5 ็ง’็ญ‰ๅพ…ไฟๆŒ pending | ๆต่งˆๅ™จๅˆทๆ–ฐ/ๆ™šๅˆฐๅ“ๅบ”/็œŸๅฎž catalog | +| F16 | ๅŽ็ซฏ/ๅ‰็ซฏๅ›žๅฝ’้€š่ฟ‡ | `conversation_labels` ไธŽ `conversations.labels` ไบ‹ๅŠกๅŒๆญฅ | ็œŸๅฎžๆททๅˆ่ทฏ็”ฑใ€็ญ›้€‰ๅ’Œๅކๅฒๆ•ฐๆฎ็›˜็‚น | +| F17 | ๆœฌๅœฐ่พน็•Œ้€š่ฟ‡๏ผŒ็œŸๅฎžๅ…ฅ็ซ™้ƒจๅˆ†้ชŒๆ”ถ | public Widget ๅชไฝฟ็”จ request context๏ผ›Connector client ไธๅ‘้€ๅฏไผช้€  origin/event-ID header๏ผ›browserโ†’็œŸๅฎž็ซ™็‚นโ†’heartbeat ๅ…ฅ็ซ™ marker ๅทฒ็•™่ฏ๏ผ›public ่ทฏๅพ„ไธไธปๅŠจๅนฟๆ’ญ contact event๏ผ›listener ไฟ็•™ trusted context skip | Connector clientโ†’็œŸๅฎž public ่ทฏ็”ฑ็š„่”็ณปไบบๅญ—ๆฎต/ไบ‹ไปถ็ซฏๅˆฐ็ซฏใ€ไผช้€ ๆฅๆบๅŠๅคš inbox | +| F18 | ๆœฌๅœฐๆŒ‡ๆ ‡/ๆ–‡ๆกฃ้ƒจๅˆ†้€š่ฟ‡ | operation/catalog countersใ€catalog queue gaugeใ€่กฅๅฟ/ๅคฑ่ดฅ runbook ๅทฒๆœ‰ๆต‹่ฏ•/่ฏดๆ˜Ž๏ผ›oldest-age gauge ๆ˜Ž็กฎๆœชๆไพ› | ็”Ÿไบง exporterใ€้˜ˆๅ€ผ/่ดŸ่ดฃไบบ/ๅ‘Š่ญฆๅ’Œ้•ฟๆ—ถ้—ด soak | + +็œŸๅฎž `swt.test` smoke ็š„่„ฑๆ•็ดขๅผ•ไธŽ raw cache ไฝ็ฝฎ่ง `reference/shang-wu-tong/results/real-tests/README.md`๏ผ›ๆต่งˆๅ™จๅฝ•ๅˆถ่ทฏๅพ„ไนŸๅœจ่ฏฅ็ดขๅผ•ไธญใ€‚ๆœฌ่Š‚ๅช็™ป่ฎฐๅฝ“ๅ‰่ฏๆฎ๏ผŒไธๆ›ฟไปฃๅฎŒๆ•ด PostgreSQLใ€็œŸๅฎžๅ†™ๆ“ไฝœ/ๆŽฅ็ฎกใ€ๆต่งˆๅ™จ็ซฏๅˆฐ็ซฏ้€่พพๅ’Œ็”Ÿไบง็›‘ๆŽง้—จ็ฆ๏ผ›ๆœชๅฎŒๆˆ้กน็ปง็ปญไฟๆŒ้˜ปๆญขๅ‘ๅธƒใ€‚ + +## 7. ๅฎŒๆˆไธŽๅ‘ๅธƒๆกไปถ + +- **ๆ–‡ๆกฃๅฎŒๆˆ**๏ผšๆ•ดๆ”น้กนใ€่ฆๆฑ‚ๆ˜ ๅฐ„ใ€่งฆๅ‘ๅœบๆ™ฏใ€้ชŒๆ”ถๆ–ญ่จ€ๅ’Œ่ดฃไปป็™ป่ฎฐๅ…ฅๅฃ้ฝๅ…จ๏ผ›ไธ็ญ‰ไบŽ่ฝฏไปถไฟฎๅคๅฎŒๆˆใ€‚ +- **ไปฃ็ ๆ•ดๆ”นๅฏๅˆๅนถ**๏ผšๆœฌๆฌก้€‚็”จ P0/P1 ไฟฎๅคไธ”ๅฎšๅ‘ๅไพ‹่ฝฌ็ปฟ๏ผ›ๆ— ๆบๆ•ฐๆฎๆˆชๆ–ญใ€้”™่ฏฏ็›ฎๆ ‡ๅ†™ๅ…ฅใ€ไธข pendingใ€ๅ‡็ปˆๆ€ๆˆ–้‡ๅคๅ‰ฏไฝœ็”จ๏ผ›ๅฟ…้กป็š„ PostgreSQL ๅนถๅ‘ๅ’Œ็ป„ไปถๆต‹่ฏ•้€š่ฟ‡๏ผ›ไธๅฏๆ‰ง่กŒ็š„ๅค–้ƒจ่ƒฝๅŠ›ๆœ‰ๅฎž้™… fail-closed ้—จ็ฆ๏ผ›็‹ฌ็ซ‹ๅคๅฎก้€้กนๅค„็ฝฎๅ…จ้ƒจๅ‘็Žฐใ€‚F18 ็š„็”Ÿไบง็›‘ๆŽงๅฏๅœจๆ˜Ž็กฎๆ ‡่ฎฐๆœชๅฎž็Žฐ็š„ๅ‰ๆไธ‹ๅ•ๅˆ—๏ผŒไฝ†ๆœชๅฎŒๆˆไธๅพ—ๅฎฃ็งฐ R18/ๅ‘ๅธƒๅฐฑ็ปชใ€‚ +- **็œŸๅฎž้ชŒ่ฏๆŽˆๆƒ**๏ผšๆœฌ่ฝฎ `swt.test` ็”ฑ็”จๆˆทๆ˜Ž็กฎๆŽˆๆƒ๏ผ›ๅฎž้™…ไฝฟ็”จ่Œƒๅ›ดไธบ็™ปๅฝ•/heartbeatใ€ๆต‹่ฏ•็ซ™็‚น่ฎฟๅฎข markerใ€ไธ€ๆฌกๆ–‡ๆœฌๅ›žๅคๅŠ็ป“ๆŸ่ฏฅๆต‹่ฏ•ไผš่ฏ๏ผŒ่ฏๆฎ่ง `reference/shang-wu-tong/results/real-tests/README.md`ใ€‚ไธๅพ—ๆฎๆญคๆ‰ฉๅคงๅˆฐ `cnote` ๅŽŸๅ€ผไฟๅ…จใ€ๅ…ถไป–่ฎฟๅฎขๆˆ–็”Ÿไบง่ฟ็งปๆƒ้™ใ€‚ +- **ๅ‘ๅธƒ**๏ผš้€‚็”จ V01โ€“V25 ๆŒ‰ๆ‰€้œ€่ฏๆฎๅฑ‚็บง้€š่ฟ‡๏ผ›ๆ— ๆœชๅค„็ฝฎ P0/P1๏ผ›่ฟ็งป/ๅ›žๆปšใ€ๅค‡ไปฝใ€็ป“ๆžœ่กฅๅฟใ€ๅฎก่ฎก/ๅ‘Š่ญฆใ€็”Ÿๅ‘ฝๅ‘จๆœŸๅ’Œๆƒ้™้ชŒ่ฏ้ฝๅค‡๏ผ›README/runbook/ๅˆ†็ฑป่ฎกๅˆ’/่ƒฝๅŠ›็Ÿฉ้˜ตไธ€่‡ดใ€‚่Œƒๅ›ดๆœชๆ‰นๅ‡†้กน่ฎฐๅฝ•โ€œไธ็บณๅ…ฅ๏ผ‹ๅ†ณ็ญ–โ€๏ผŒไธๅฏๅ†’ๅ……ๆต‹่ฏ•้€š่ฟ‡๏ผŒไนŸไธ่ƒฝไปฅโ€œไธ็บณๅ…ฅโ€็ป•ๅผ€ๅค‡ๆณจไฟๅ…จๅ’Œๆƒ้™่พน็•Œใ€‚ +- **ๅœๆญข/ๅ›žๆปš**๏ผšๅ‘็Žฐๅค‡ๆณจๆŸๅใ€่ทจ่บซไปฝๅ†™ๅ…ฅใ€ๆ—งๅ€ผๅ็›–ใ€้‡ๅค่ฟœ็จ‹ๅ†™ๆˆ–่ฟ็งปไธไธ€่‡ด๏ผŒๅœๆญขๅฏนๅบ”ๅ‡บ็ซ™่ƒฝๅŠ›ๅนถไฟ็•™้˜Ÿๅˆ—ๅ’Œ่ฏๆฎใ€‚ไปฃ็ ๅ›žๆปšไธๆ’ค้”€่ฟœ็จ‹ๅ‰ฏไฝœ็”จ๏ผŒๆขๅค่ฟœ็จ‹ๅŽŸๅ€ผไป้œ€ๆ ธๅฏน็Žฐ็Šถๅ’Œ้‡ๆ–ฐๆŽˆๆƒใ€‚ + +ๅ…ณ่”๏ผš[ๅˆ†็ฑป่ฎกๅˆ’](2026-09-11-shangwutong-classification-sync-plan.md) ยท [่ฟ่กŒๆ‰‹ๅ†Œ](../runbooks/shangwutong-connector.md) ยท [Connector README](../../channels/shangwutong/README.md)ใ€‚ไธดๆ—ถๅฎกๆŸฅๆŠฅๅ‘Šไธไฝœไธบๅ”ฏไธ€ไบคไป˜ไพ่ต–๏ผ›ๆœฌๆ–‡ไปถๅทฒๆ”ถๅฝ•ๆ•ดๆ”นไบ‹ๅฎžใ€้™ๅˆถๅ’Œ้ชŒๆ”ถ่ฆๆฑ‚ใ€‚ diff --git a/docs/runbooks/shangwutong-connector.md b/docs/runbooks/shangwutong-connector.md index f76a4049..0f4dcddc 100644 --- a/docs/runbooks/shangwutong-connector.md +++ b/docs/runbooks/shangwutong-connector.md @@ -27,6 +27,8 @@ docker compose up -d postgres redis gochat worker docker compose run --rm shangwutong migrate up docker compose up -d shangwutong docker compose exec shangwutong shangwutong doctor +# ็ป“ๆžœๅ›ž่ฐƒๅคฑ่ดฅไธ”ๅทฒๆœ‰็ป“ๆžœ่ฏๆฎๆ—ถ๏ผŒๆŒ‰ operation/account ๆ˜พๅผ่กฅๅฟ๏ผš +# shangwutong compensate-result --account-id --operation-id --actor --reason '' ``` ไพๆฌก็กฎ่ฎค๏ผš @@ -46,7 +48,10 @@ curl -fsS http://127.0.0.1:9100/metrics 3. ้ชŒ่ฏ่ฎฟๅฎขๆ–‡ๆœฌๅ…ฅ็ซ™ใ€ๅๅธญๆ–‡ๆœฌๅ‡บ็ซ™ใ€ๆ’คๅ›žใ€ๅ›พ็‰‡ใ€ๆ–‡ไปถใ€่ฏญ้Ÿณใ€ไผš่ฏ็ป“ๆŸๅ’Œ typingใ€‚ 4. ๅœจ็บฟๅˆ‡ๆข online/busy/away/offline๏ผ›็กฎ่ฎคๅชๅฝฑๅ“็›ฎๆ ‡ Inboxใ€‚ 5. ๆไบค้”™่ฏฏๆ–ฐๅฏ†็ ๏ผŒ็กฎ่ฎคๆ—ง session ไปๅทฅไฝœไธ”้…็ฝฎ็Šถๆ€ไธบ rejected๏ผ›ๅ†ๆไบคๆญฃ็กฎๅฏ†็ ๆขๅคใ€‚ -6. ่ฟ่กŒ่‡ณๅฐ‘ไธ€ไธชๅทฅไฝœๆ—ฅ็ฐๅบฆๅŽๅˆ†ๆ‰นๅˆ›ๅปบๅ‰ฉไฝ™ Inbox๏ผŒๆŒ็ปญ่ง‚ๅฏŸ้˜Ÿๅˆ—ใ€ๅฟƒ่ทณๅ’Œ่ฎค่ฏ้”™่ฏฏใ€‚ +6. ๅœจๆต‹่ฏ• Inbox ้ชŒ่ฏ่”็ณปไบบๆ”นๅ๏ผšๅทฒๆœ‰้ž็ฉบ่ฟœ็จ‹ๅค‡ๆณจๆ—ถๅชๆ”นๅง“ๅไธๅพ—ๆธ…็ฉบ๏ผ›ๆ—  CID ๆ—ถ durable operation ไฟ็•™ๅนถๆ˜พ็คบ pending๏ผŒCID ๅ…ฅ็ซ™ๅ”ค้†’ๅŽๅ†ๆ‰ง่กŒ๏ผŒ่ถ…่ฟ‡ 24 ๅฐๆ—ถๅ›žๅ†™ `cid_wait_timeout`๏ผ›็ป“ๆžœๅˆ†ๅˆซๅฏ่ง pending/succeeded/failed/uncertainใ€‚่”็ณปไบบ็Šถๆ€ไธŽ webhook outbox ๅฟ…้กปๅŒไบ‹ๅŠกๆไบค๏ผŒenqueue ๅคฑ่ดฅไธๅพ—็•™ไธ‹ๅญค็ซ‹ pendingใ€‚ +7. ้ชŒ่ฏๅˆ†็ฑป catalog ๅŒๆญฅใ€chatkind ๅ’Œ customer color ็š„็‹ฌ็ซ‹็ป“ๆžœ๏ผ›ๅŒไธ€ inbox/contact-inbox ็š„็›ธๅ…ณไผš่ฏ้ขœ่‰ฒไธ€่‡ด๏ผŒ่ทจ inbox ็›ธๅŒ CID ไธไธฒๅ†™ใ€‚GoChat ๅŽŸ็”Ÿๆ ‡็ญพ/ๅค‡ๆณจๆ“ไฝœไธๅพ—่ฐƒ็”จๅ•†ๅŠก้€šๅˆ†็ฑปๆŽฅๅฃใ€‚่ฟœ็จ‹ Connector-origin ่”็ณปไบบไบ‹ไปถไธๅพ—ๅ†ๆฌก่งฆๅ‘ๅๅ‘ renameใ€‚ + +8. ่ฟ่กŒ่‡ณๅฐ‘ไธ€ไธชๅทฅไฝœๆ—ฅ็ฐๅบฆๅŽๅˆ†ๆ‰นๅˆ›ๅปบๅ‰ฉไฝ™ Inbox๏ผŒๆŒ็ปญ่ง‚ๅฏŸ้˜Ÿๅˆ—ใ€ๅฟƒ่ทณๅ’Œ่ฎค่ฏ้”™่ฏฏใ€‚ ## 4. ๆ—ฅๅธธ็›‘ๆŽง @@ -67,9 +72,13 @@ swt_connector_unknown_event_total swt_connector_unmapped_retraction_total swt_connector_contract_error_total swt_connector_sqlite_write_duration_seconds +swt_connector_contact_metadata_wait_total +swt_connector_operation_result_sync_total +swt_connector_classification_sync_total +swt_connector_classification_sync_queue_depth ``` -ๅปบ่ฎฎๅ‘Š่ญฆ๏ผšconnected ๆฏ”ไพ‹ไฝŽไบŽ 95%๏ผ›pending ๆœ€่€่ฎฐๅฝ•่ถ…่ฟ‡ 2 ๅˆ†้’Ÿ๏ผ›uncertain ๅคงไบŽ 0๏ผ›status sync ่ถ…่ฟ‡ 2 ๅˆ†้’Ÿ๏ผ›ๆœช็Ÿฅ kindใ€raw-only ๆˆ– unmapped retraction ๆŒ็ปญๅขž้•ฟ๏ผ›SQLite ๆฃ€ๆŸฅๅคฑ่ดฅๆˆ–ๅท็ฉบ้—ดไฝŽไบŽ 20%ใ€‚ๆŒ‡ๆ ‡ๆ ‡็ญพไธๅŒ…ๅซ่ดฆๅท IDใ€็”จๆˆทๅใ€session ID ๆˆ– Inbox IDใ€‚ +ๅปบ่ฎฎๅ‘Š่ญฆ๏ผšconnected ๆฏ”ไพ‹ไฝŽไบŽ 95%๏ผ›CID pending ๆœ€่€่ฎฐๅฝ•่ถ…่ฟ‡ 2 ๅˆ†้’Ÿๆˆ–ๆŽฅ่ฟ‘ 24 ๅฐๆ—ถ๏ผ›uncertain ๅคงไบŽ 0๏ผ›็ป“ๆžœๅ›ž่ฐƒ้‡่ฏ•/่€—ๅฐฝ๏ผ›status sync ่ถ…่ฟ‡ 2 ๅˆ†้’Ÿ๏ผ›ๅˆ†็ฑป catalog ๅŒๆญฅๅคฑ่ดฅ๏ผ›ๅˆ†็ฑปๅ›ž่ฐƒๅ†ฒ็ช้€š่ฟ‡ GoChat API ๆ—ฅๅฟ—/ๅ“ๅบ”ๅฎก่ฎก่ง‚ๅฏŸ๏ผ›ๆœช็Ÿฅ kindใ€raw-only ๆˆ– unmapped retraction ๆŒ็ปญๅขž้•ฟ๏ผ›SQLite ๆฃ€ๆŸฅๅคฑ่ดฅๆˆ–ๅท็ฉบ้—ดไฝŽไบŽ 20%ใ€‚ๆŒ‡ๆ ‡ๆ ‡็ญพไธๅŒ…ๅซ่ดฆๅท IDใ€็”จๆˆทๅใ€session ID ๆˆ– Inbox IDใ€‚ ๆ—ฅๅฟ—็ฆๆญข่พ“ๅ‡บ passwordใ€pending passwordใ€`ma`ใ€HMAC/webhook secretใ€Authorizationใ€ๅฎŒๆ•ด็™ปๅฝ• body ๅ’Œ่ฎฟๅฎขๆญฃๆ–‡ใ€‚ๅ‘็Žฐๆณ„้œฒๆ—ถๅ…ˆ่ฝฎๆข็›ธๅบ” token/secret๏ผŒๅ†ไฟๅ…จๅนถ้™ๅˆถๆ—ฅๅฟ—่ฎฟ้—ฎ๏ผŒๆœ€ๅŽไฟฎๅค redaction ๅŽ้‡ๆ–ฐ้ƒจ็ฝฒใ€‚ @@ -111,12 +120,16 @@ docker compose exec shangwutong shangwutong doctor ## 7. ๆ•…้šœๅค„็† +- `classification_sync_requested` ๆŠ•้€’ๅคฑ่ดฅ๏ผš4xx ไผšๆŠŠๅŒน้…็š„ pending catalog ๆ ‡ไธบ `failed`๏ผ›่ถ…ๆ—ถ/่€—ๅฐฝๅชไฟ็•™ pending ้”™่ฏฏ่ฏๆฎ๏ผŒ้‡ๆ–ฐ็‚นๅ‡ปๅŒๆญฅ็”Ÿๆˆๆ–ฐ eventใ€‚Connector ๆœฌๅœฐ `classification_sync_results` ๅฐ†่ฏปๅ–้˜ถๆฎตไธŽๅ›žไผ ้˜ถๆฎตๅˆ†ๅผ€ๆŒไน…ๅŒ–๏ผŒ็Šถๆ€ๅฏๅœจ `swt_connector_classification_sync_queue_depth` ๅ’Œ SQLite ไธญ่ฟฝ่ธช๏ผ›ๆ—ง event ไธๅพ—่ฆ†็›–ๆ–ฐ eventใ€‚ - `auth_failed`๏ผšๆ ธๅฏน Inbox ็š„ session IDใ€usernameใ€ๆœ€่ฟ‘ๅฏ†็ ็‰ˆๆœฌ๏ผ›ไธ่ฆ็›ดๆŽฅๆ”น SQLiteใ€‚้€š่ฟ‡ GoChat ้…็ฝฎ้กตๆไบคๆ–ฐๅฏ†็ ใ€‚ - `verification_required`๏ผšๅฝ“ๅ‰ไธ่‡ชๅŠจ็ป•่ฟ‡ vcode/ecsqใ€‚ไฟ็•™่ดฆๅท็ฆป็บฟ๏ผŒๆŒ‰็œŸๅฎžๅ•†ๅŠก้€šๅฎขๆˆท็ซฏๅฎŒๆˆไบบๅทฅ้ชŒ่ฏๅนถ่ฎฐๅฝ•ๆต็จ‹่ฏๆฎใ€‚ - `relogin_required` / `tickint_reset`๏ผšConnector ไผšๆธ…็† token ๅนถ็”จไฟๅญ˜ๅฏ†็ ้‡็™ป๏ผ›่ง‚ๅฏŸๆ˜ฏๅฆๅฝขๆˆ็™ปๅฝ•้ฃŽๆšดใ€‚ - inbound ๅ †็งฏ๏ผšๆฃ€ๆŸฅ GoChat Application/Public APIใ€service token grant ๅ’Œ้™„ไปถไธ‹่ฝฝ๏ผ›cursor ๅทฒๅœจๅŽŸๅง‹ไบ‹ไปถๆŒไน…ๅŒ–ๅŽๆŽจ่ฟ›๏ผŒไธ่ฆๅ›ž้€€ cursor ็Œœๆต‹้‡ๆ”พใ€‚ -- outbound uncertain๏ผš็ญ‰ๅพ…่ง‚ๅฏŸ็ช—ๅฃๅ’Œ kind=3๏ผ›ไธ่ฆ้‡ๅค็‚นๅ‡ปๅ‘้€ใ€‚่ถ…ๆ—ถ failed ๅŽๆ‰ๅ…่ฎธๆ˜พๅผ retryใ€‚ +- outbound uncertain๏ผš็ญ‰ๅพ…่ง‚ๅฏŸ็ช—ๅฃๅ’Œ kind=3๏ผ›ไธ่ฆ้‡ๅค็‚นๅ‡ปๅ‘้€ใ€‚่ง‚ๅฏŸ่ถ…ๆ—ถๅฏนๅˆ†็ฑป/ๆ”นๅๅชไฟ็•™ `uncertain` ๅ’Œ `uncertain_timeout`๏ผŒไธๅพ—ๆ”นๅ†™ๆˆ็กฎๅฎš failed๏ผ›็ป“ๆžœๅŒๆญฅๅคฑ่ดฅๆ—ถไฝฟ็”จๆ˜พๅผ `compensate-result` ไป…้‡ๆ”พ็ป“ๆžœๅ›ž่ฐƒ๏ผŒไธ้‡ๅ‘่ฟœ็จ‹ๆ“ไฝœ๏ผ›`cid_unavailable` ็š„ pending operation ็”ฑ CID ๅ…ฅ็ซ™ๅ”ค้†’๏ผŒ้‡ๅฏไธไธขๅคฑ๏ผŒ่ถ…่ฟ‡ 24 ๅฐๆ—ถ็š„ `cid_wait_timeout` ่ฝฌไบบๅทฅๅค„็†ใ€‚`uncertain_since` ๆ˜ฏๅ›บๅฎš่ง‚ๅฏŸ่ตท็‚น๏ผŒ็ป“ๆžœๅ›ž่ฐƒไธไผšๅปถ้•ฟๆˆชๆญข๏ผ›ๆถˆๆฏไธŽ operation ็ป“ๆžœๅŒๆญฅ็Šถๆ€ๅˆ†ๅผ€็œ‹ใ€‚ +- ๅˆ†็ฑป stale callback๏ผšไฟ็•™ๅฝ“ๅ‰ operation ็Šถๆ€๏ผŒๆฃ€ๆŸฅ event_idใ€inbox ๅ’Œ conversation ็ป‘ๅฎš๏ผ›ไธๅพ—ๆ‰‹ๅทฅๆ”นๅ†™ `additional_attributes` ็ป•่ฟ‡ไปฃ้™…ไฟๆŠคใ€‚ - `invalid_signature`๏ผšๆฃ€ๆŸฅ Inbox webhook secretใ€็ณป็ปŸๆ—ถ้’Ÿๅ’Œ lifecycle secret ่ฝฎๆข้กบๅบใ€‚ + +ๆŒ‡ๆ ‡ๆœชๆไพ›โ€œๆœ€่€ pendingโ€ๆˆ–โ€œๆœ€ๆŽฅ่ฟ‘ CID 24 ๅฐๆ—ถๆˆชๆญขโ€็š„็‹ฌ็ซ‹ gauge๏ผ›ๅ‘Š่ญฆๅบ”็”ฑ SQLite ๅช่ฏปๆŸฅ่ฏข่กฅๅ……๏ผˆๆŒ‰ `created_at`ใ€`uncertain_since`ใ€`result_sync_status` ๅ’Œ `result_sync_attempts`๏ผ‰๏ผŒไธ่ฆๆŠŠ็ผบๅคฑ็š„ๆŒ‡ๆ ‡ๅ†™ๆˆๅทฒ้…็ฝฎๅ‘Š่ญฆใ€‚ - SQLite BUSY/ๆŸๅ๏ผšๅœๆญขๆต้‡๏ผŒไฟๅ…จ DB/WAL/SHM๏ผŒ่ฟ่กŒๅช่ฏปๆฃ€ๆŸฅๅนถไปŽ้ชŒ่ฏ่ฟ‡็š„ๅœจ็บฟๅค‡ไปฝๆขๅคใ€‚ ## 8. ็”Ÿไบง่ฏๆฎๆธ…ๅ• @@ -124,6 +137,10 @@ docker compose exec shangwutong shangwutong doctor ไปฅไธ‹็ป“ๆžœๅฟ…้กปๅˆ†ๅˆซ่ฎฐๅฝ•๏ผŒไธ่ƒฝ็”จๅ•ๅ…ƒๆต‹่ฏ•ๆ›ฟไปฃ๏ผš - ็œŸๅฎž่ดฆๅท็™ปๅฝ•ใ€session ๆขๅคใ€online/busy/away/offlineใ€logout ๅ’Œๅฏ†็ ๆญฃ็กฎ/้”™่ฏฏๆ›ดๆ–ฐใ€‚ +- ็œŸๅฎž่ดฆๅทๆ”นๅ๏ผˆ่ฟœ็จ‹ๅทฒๆœ‰้ž็ฉบ cnote๏ผ‰ใ€ๆ—  CID ๅปถ่ฟŸ/24 ๅฐๆ—ถๅˆฐๆœŸใ€ๆˆๅŠŸ/ๅคฑ่ดฅ/ไธ็กฎๅฎš็ป“ๆžœๅ›žๅ†™๏ผ›ๅˆ†็ฑป catalogใ€chatkindใ€customer color ๅคšไผš่ฏๅ’Œ่ทจ inbox ้š”็ฆปใ€‚ +- ่ฟœ็จ‹่”็ณปไบบๆ›ดๆ–ฐ็š„ๆฅๆบ/event ID ไผ ๆ’ญไธŽๅๅ‘ๅพช็ŽฏๆŠ‘ๅˆถ๏ผ›่”็ณปไบบ็Šถๆ€ไธŽ webhook outbox enqueue ๅคฑ่ดฅๅ›žๆปš๏ผ›Connector ้‡ๅฏๅŽ pending CID operation ๅฏๆขๅคใ€‚ +- ๆ˜Ž็กฎ่ฎฐๅฝ• `cnote` ็ผบ็œ/็ฉบไธฒ/ๅŽŸๅ€ผไธ‰็งๅ่ฎฎ่ฏญไน‰๏ผ›ๆœชๅฎŒๆˆ่ฎฐๅฝ•ๅ‰ไฟๆŒๆ”นๅไธๅ‘้€็ฉบ `cnote`ใ€‚ +- GoChat ๅŽŸ็”Ÿ Contact Note/Contact Label/Conversation Label ไธŽๅ•†ๅŠก้€šๅญ—ๆฎตไบ’ไธๆฑกๆŸ“ใ€‚ - ๆ–‡ๆœฌใ€ๅ›พ็‰‡ใ€ๆ–‡ไปถใ€่ฏญ้ŸณๅŒๅ‘ๆ”ถๅ‘๏ผŒๆ’คๅ›žๅŠ่ฟž็ปญ็›ธๅŒๅ†…ๅฎน็š„ uncertain ๆญงไน‰ๆ ทๆœฌใ€‚ - ่ดŸ kind ไธŽ 65/66/67 cursor ่ฟž็ปญๆŠ“ๅŒ…๏ผ›kind=52 ไธๅŒ็‰ˆๆœฌ็œŸๅฎž fixtureใ€‚ - vcode/ecsq ไบบๅทฅๆขๅคๆต็จ‹ใ€‚ diff --git a/frontend/app/javascript/dashboard/api/contactNotes.js b/frontend/app/javascript/dashboard/api/contactNotes.js index 5be33784..f2c57492 100644 --- a/frontend/app/javascript/dashboard/api/contactNotes.js +++ b/frontend/app/javascript/dashboard/api/contactNotes.js @@ -20,6 +20,11 @@ class ContactNotes extends ApiClient { return super.create({ content }); } + update(contactId, id, content) { + this.contactId = contactId; + return super.update(id, { content }); + } + delete(contactId, id) { this.contactId = contactId; return super.delete(id); diff --git a/frontend/app/javascript/dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue b/frontend/app/javascript/dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue index 48225d2c..287742fa 100644 --- a/frontend/app/javascript/dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue +++ b/frontend/app/javascript/dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue @@ -1,5 +1,5 @@