feat: harden support, localize knowledge and add operator availability [skip ci]
Source-only publication of reviewed commit 64e96a7f423673dc29ad56485be9fc79bce8af1c.
Passed in isolation: focused frontend/backend tests, race checks, TypeScript typecheck, lint, Go compilation, Flowgram build, and deterministic SDK build/tests.
Not accepted as a production release: Next.js export/native assembly remains blocked by environment memory limits; browser interaction and MySQL staging acceptance remain unverified. No production server, database/schema, runtime configuration, deployment, release tag, or workflow execution was performed.
Expected source tree: 0a4a7528e9.
This commit is contained in:
68
internal/repositories/auth_scope_repository.go
Normal file
68
internal/repositories/auth_scope_repository.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// These handwritten queries supplement the generated CRUD repositories.
|
||||
func (r *roleRepository) FindEnabledByUser(db *gorm.DB, userID int64) ([]models.Role, error) {
|
||||
roles := make([]models.Role, 0)
|
||||
err := db.Table("t_role AS r").
|
||||
Select("r.*").
|
||||
Joins("JOIN t_user_role AS ur ON ur.role_id = r.id").
|
||||
Where("ur.user_id = ? AND r.status = ?", userID, enums.StatusOk).
|
||||
Order("r.sort_no ASC, r.id ASC").
|
||||
Scan(&roles).Error
|
||||
return roles, err
|
||||
}
|
||||
|
||||
func (r *permissionRepository) FindEnabledCodesByUserRoles(db *gorm.DB, userID int64) ([]string, error) {
|
||||
codes := make([]string, 0)
|
||||
err := db.Table("t_permission AS p").
|
||||
Joins("JOIN t_role_permission AS rp ON rp.permission_id = p.id").
|
||||
Joins("JOIN t_role AS r ON r.id = rp.role_id").
|
||||
Joins("JOIN t_user_role AS ur ON ur.role_id = r.id").
|
||||
Where("ur.user_id = ? AND r.status = ? AND p.status = ?", userID, enums.StatusOk, enums.StatusOk).
|
||||
Distinct().Pluck("p.code", &codes).Error
|
||||
return codes, err
|
||||
}
|
||||
|
||||
type UserPermissionOverride struct {
|
||||
Code string
|
||||
Effect int
|
||||
}
|
||||
|
||||
func (r *userPermissionRepository) FindActiveEnabledOverrides(db *gorm.DB, userID int64, now time.Time) ([]UserPermissionOverride, error) {
|
||||
overrides := make([]UserPermissionOverride, 0)
|
||||
err := db.Table("t_user_permission AS up").
|
||||
Select("p.code, up.effect").
|
||||
Joins("JOIN t_permission AS p ON p.id = up.permission_id").
|
||||
Where("up.user_id = ? AND p.status = ?", userID, enums.StatusOk).
|
||||
Where("up.expired_at IS NULL OR up.expired_at > ?", now).
|
||||
Scan(&overrides).Error
|
||||
return overrides, err
|
||||
}
|
||||
|
||||
// GetAuthState deliberately excludes the bearer token; revalidation only needs
|
||||
// the identity binding, revocation status, and absolute expiry.
|
||||
func (r *loginSessionRepository) GetAuthState(db *gorm.DB, sessionID, userID int64) (*models.LoginSession, error) {
|
||||
session := &models.LoginSession{}
|
||||
if err := db.Select("id", "user_id", "expired_at", "revoked_at").
|
||||
Take(session, "id = ? AND user_id = ?", sessionID, userID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (r *userRepository) GetAuthState(db *gorm.DB, userID int64) (*models.User, error) {
|
||||
user := &models.User{}
|
||||
if err := db.Select("id", "username", "nickname", "avatar", "user_type", "status", "deleted_at").
|
||||
Take(user, "id = ?", userID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
22
internal/repositories/channel_config_repository.go
Normal file
22
internal/repositories/channel_config_repository.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// UpdatesConfigIfUnchanged prevents a stale read/modify/write from replacing a
|
||||
// concurrently tightened authentication policy or rotated channel secret. HEX is
|
||||
// available in SQLite and MySQL and compares exact bytes even when MySQL's text
|
||||
// column uses a case-insensitive or trailing-space-insensitive collation.
|
||||
func (r *channelRepository) UpdatesConfigIfUnchanged(db *gorm.DB, expected *models.Channel, columns map[string]any) (bool, error) {
|
||||
if expected == nil || expected.ID <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
result := db.Model(&models.Channel{}).
|
||||
Where("id = ? AND status <> ? AND updated_at = ?", expected.ID, enums.StatusDeleted, expected.UpdatedAt).
|
||||
Where("HEX(COALESCE(config_json, '')) = HEX(?)", expected.ConfigJSON).
|
||||
Updates(columns)
|
||||
return result.RowsAffected == 1, result.Error
|
||||
}
|
||||
17
internal/repositories/conversation_dispatch_queries.go
Normal file
17
internal/repositories/conversation_dispatch_queries.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// FindPendingDispatchBatch returns a bounded, checked page. A failed read must
|
||||
// not be mistaken for an empty queue by setup or the periodic dispatcher.
|
||||
func (r *conversationRepository) FindPendingDispatchBatch(db *gorm.DB, limit int) ([]models.Conversation, error) {
|
||||
conversations := make([]models.Conversation, 0)
|
||||
err := db.Where("status = ? AND current_assignee_id = ?", enums.IMConversationStatusPending, 0).
|
||||
Order("id DESC").Limit(limit).Find(&conversations).Error
|
||||
return conversations, err
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
// Modified in the independent hardening fork; see docs/hardening.
|
||||
|
||||
package repositories
|
||||
|
||||
import (
|
||||
@@ -6,7 +8,6 @@ import (
|
||||
|
||||
"agent-desk/internal/pkg/httpx/params"
|
||||
|
||||
"github.com/mlogclub/simple/common/strs"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -36,14 +37,13 @@ func (r *customerIdentityRepository) Take(db *gorm.DB, where ...interface{}) *mo
|
||||
return ret
|
||||
}
|
||||
|
||||
// GetBy 按外部来源 + 外部用户标识查询身份映射(与 uk_customer_external 一致)。
|
||||
// GetBy is the legacy/internal-integration lookup. Ambiguous mappings fail closed.
|
||||
func (r *customerIdentityRepository) GetBy(db *gorm.DB, externalSource enums.ExternalSource, externalID string) *models.CustomerIdentity {
|
||||
if strs.IsAnyBlank(string(externalSource), externalID) {
|
||||
identity, err := r.GetUniqueBy(db, "", externalSource, externalID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return r.FindOne(db, sqls.NewCnd().
|
||||
Eq("external_source", externalSource).
|
||||
Eq("external_id", externalID))
|
||||
return identity
|
||||
}
|
||||
|
||||
func (r *customerIdentityRepository) FindByCustomerID(db *gorm.DB, customerID int64) []models.CustomerIdentity {
|
||||
|
||||
63
internal/repositories/customer_identity_scope.go
Normal file
63
internal/repositories/customer_identity_scope.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ErrAmbiguousCustomerIdentity requires deliberate operator reconciliation. Never
|
||||
// choose the first row: the historical unique index also includes customer_id.
|
||||
var ErrAmbiguousCustomerIdentity = errors.New("ambiguous customer identity")
|
||||
|
||||
// GetUniqueBy includes disabled rows when detecting conflicts. A disabled mapping
|
||||
// must not let a caller create a replacement customer and reacquire the identity.
|
||||
func (r *customerIdentityRepository) GetUniqueBy(db *gorm.DB, scope string, source enums.ExternalSource, externalID string) (*models.CustomerIdentity, error) {
|
||||
if strings.TrimSpace(string(source)) == "" || strings.TrimSpace(externalID) == "" {
|
||||
return nil, errors.New("invalid customer identity")
|
||||
}
|
||||
var identities []models.CustomerIdentity
|
||||
query := db.Where("identity_scope = ? AND external_source = ? AND external_id = ?", scope, source, strings.TrimSpace(externalID))
|
||||
if key := ScopedCustomerIdentityKey(scope, source, externalID); key != nil {
|
||||
// Digest comparison is independent of MySQL's external_id collation. The
|
||||
// NULL branch also detects manually migrated/inconsistent scoped mappings.
|
||||
query = db.Where("scoped_key = ? OR (scoped_key IS NULL AND identity_scope = ? AND external_source = ? AND external_id = ?)", *key, scope, source, strings.TrimSpace(externalID))
|
||||
}
|
||||
err := query.Limit(2).Find(&identities).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(identities) > 1 {
|
||||
return nil, ErrAmbiguousCustomerIdentity
|
||||
}
|
||||
if len(identities) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if identities[0].IdentityScope != scope || identities[0].ExternalSource != source || identities[0].ExternalID != strings.TrimSpace(externalID) {
|
||||
return nil, ErrAmbiguousCustomerIdentity
|
||||
}
|
||||
if identities[0].Status != enums.StatusOk {
|
||||
return nil, errors.New("inactive customer identity")
|
||||
}
|
||||
return &identities[0], nil
|
||||
}
|
||||
|
||||
// ScopedCustomerIdentityKey gives new scoped registrations a database-enforced
|
||||
// claim without imposing a new unique constraint on dirty legacy data. Both
|
||||
// SQLite and MySQL permit multiple NULL values in a unique index. Encoding an
|
||||
// array avoids delimiter collisions and hashing avoids collation ambiguity.
|
||||
func ScopedCustomerIdentityKey(scope string, source enums.ExternalSource, externalID string) *string {
|
||||
if scope == "" {
|
||||
return nil
|
||||
}
|
||||
encoded, _ := json.Marshal([]string{scope, string(source), strings.TrimSpace(externalID)})
|
||||
digest := sha256.Sum256(encoded)
|
||||
key := hex.EncodeToString(digest[:])
|
||||
return &key
|
||||
}
|
||||
153
internal/repositories/support_availability_repository.go
Normal file
153
internal/repositories/support_availability_repository.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"errors"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"time"
|
||||
)
|
||||
|
||||
var SupportAvailabilityRepository = &supportAvailabilityRepository{}
|
||||
|
||||
type supportAvailabilityRepository struct{}
|
||||
|
||||
func (r *supportAvailabilityRepository) Calendar(db *gorm.DB, teamID int64) (*models.SupportCalendar, error) {
|
||||
var item models.SupportCalendar
|
||||
err := db.Where("team_id = ?", teamID).Take(&item).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &item, err
|
||||
}
|
||||
func (r *supportAvailabilityRepository) SaveCalendar(db *gorm.DB, item *models.SupportCalendar) error {
|
||||
return db.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "team_id"}}, DoUpdates: clause.AssignmentColumns([]string{"mode", "timezone", "weekly_json", "exceptions_json", "updated_at"})}).Create(item).Error
|
||||
}
|
||||
func (r *supportAvailabilityRepository) RenewPresence(db *gorm.DB, item *models.AgentPresenceLease) error {
|
||||
return db.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "id"}}, DoUpdates: clause.AssignmentColumns([]string{"expires_at"})}).Create(item).Error
|
||||
}
|
||||
func (r *supportAvailabilityRepository) EndPresence(db *gorm.DB, id string, at time.Time) error {
|
||||
return db.Model(&models.AgentPresenceLease{}).Where("id = ? AND expires_at > ?", id, at).Update("expires_at", at).Error
|
||||
}
|
||||
func (r *supportAvailabilityRepository) PrunePresence(db *gorm.DB, now time.Time) error {
|
||||
return db.Where("expires_at <= ?", now).Delete(&models.AgentPresenceLease{}).Error
|
||||
}
|
||||
func (r *supportAvailabilityRepository) OnlineUsers(db *gorm.DB, userIDs []int64, now time.Time) (map[int64]bool, error) {
|
||||
result := map[int64]bool{}
|
||||
if len(userIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
var leases []models.AgentPresenceLease
|
||||
if err := db.Where("user_id IN ? AND expires_at > ?", userIDs, now).Find(&leases).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sessionIDs := make([]int64, 0, len(leases))
|
||||
for _, lease := range leases {
|
||||
sessionIDs = append(sessionIDs, lease.LoginSessionID)
|
||||
}
|
||||
if len(sessionIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
var sessions []models.LoginSession
|
||||
if err := db.Select("id", "user_id", "expired_at", "revoked_at").Where("id IN ? AND expired_at > ? AND revoked_at IS NULL", sessionIDs, now).Find(&sessions).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
valid := map[int64]int64{}
|
||||
for _, session := range sessions {
|
||||
valid[session.ID] = session.UserID
|
||||
}
|
||||
for _, lease := range leases {
|
||||
if valid[lease.LoginSessionID] == lease.UserID {
|
||||
result[lease.UserID] = true
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// A portable write acquires the profile row's write lock on MySQL and the
|
||||
// transaction write lock on SQLite. All assignment paths take this before count.
|
||||
func (r *supportAvailabilityRepository) LockProfile(db *gorm.DB, userID int64) (*models.AgentProfile, error) {
|
||||
result := db.Model(&models.AgentProfile{}).Where("user_id = ?", userID).UpdateColumn("assignment_version", gorm.Expr("assignment_version + 1"))
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
var item models.AgentProfile
|
||||
err := db.Where("user_id = ?", userID).Take(&item).Error
|
||||
return &item, err
|
||||
}
|
||||
func (r *supportAvailabilityRepository) ActiveCount(db *gorm.DB, userID, excludeConversationID int64) (int, error) {
|
||||
var count int64
|
||||
q := db.Model(&models.Conversation{}).Where("current_assignee_id = ? AND status = ?", userID, enums.IMConversationStatusActive)
|
||||
if excludeConversationID > 0 {
|
||||
q = q.Where("id <> ?", excludeConversationID)
|
||||
}
|
||||
err := q.Count(&count).Error
|
||||
return int(count), err
|
||||
}
|
||||
func (r *supportAvailabilityRepository) LegacySchedules(db *gorm.DB, teamID int64, now time.Time) ([]models.AgentTeamSchedule, error) {
|
||||
var items []models.AgentTeamSchedule
|
||||
err := db.Where("team_id = ? AND status = ? AND end_at > ?", teamID, enums.StatusOk, now).Order("start_at ASC").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
func (r *supportAvailabilityRepository) Profiles(db *gorm.DB, teamIDs []int64) ([]models.AgentProfile, error) {
|
||||
var items []models.AgentProfile
|
||||
q := db.Where("status <> ?", enums.StatusDeleted)
|
||||
if teamIDs != nil {
|
||||
if len(teamIDs) == 0 {
|
||||
return []models.AgentProfile{}, nil
|
||||
}
|
||||
q = q.Where("team_id IN ?", teamIDs)
|
||||
}
|
||||
err := q.Order("id ASC").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
func (r *supportAvailabilityRepository) User(db *gorm.DB, id int64) (*models.User, error) {
|
||||
var item models.User
|
||||
err := db.First(&item, id).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &item, err
|
||||
}
|
||||
func (r *supportAvailabilityRepository) Team(db *gorm.DB, id int64) (*models.AgentTeam, error) {
|
||||
var item models.AgentTeam
|
||||
err := db.First(&item, id).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &item, err
|
||||
}
|
||||
|
||||
func (r *supportAvailabilityRepository) AssignConversation(db *gorm.DB, previous *models.Conversation, values map[string]any) error {
|
||||
result := db.Model(&models.Conversation{}).Where("id = ? AND status = ? AND current_assignee_id = ?", previous.ID, previous.Status, previous.CurrentAssigneeID).Updates(values)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return gorm.ErrInvalidTransaction
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *supportAvailabilityRepository) ProfileByUser(db *gorm.DB, userID int64) (*models.AgentProfile, error) {
|
||||
var item models.AgentProfile
|
||||
err := db.Where("user_id = ?", userID).Take(&item).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &item, err
|
||||
}
|
||||
|
||||
// EffectiveStatusCondition mirrors the vacation-boundary policy for paginated
|
||||
// list filters. Filtering the stored Vacation value would contradict the badge.
|
||||
func (r *supportAvailabilityRepository) EffectiveStatusCondition(cnd *sqls.Cnd, status enums.ServiceStatus, now time.Time) {
|
||||
cnd.Where(`(CASE WHEN service_status <> ? THEN service_status
|
||||
WHEN absence_start_at IS NOT NULL AND absence_end_at IS NOT NULL AND absence_start_at <= ? AND absence_end_at > ? THEN ?
|
||||
WHEN availability_return_status >= ? AND availability_return_status <= ? THEN availability_return_status
|
||||
ELSE ? END) = ?`, enums.ServiceStatusVacation, now, now, enums.ServiceStatusVacation, enums.ServiceStatusIdle, enums.ServiceStatusUnavailable, enums.ServiceStatusUnavailable, status)
|
||||
}
|
||||
Reference in New Issue
Block a user