fix(ticket): harden lightweight backend core

This commit is contained in:
mlogclub
2026-05-02 19:19:18 +08:00
parent a06ce7074e
commit fa9d431cbe
9 changed files with 232 additions and 51 deletions
@@ -0,0 +1,69 @@
package migration
import (
"cs-agent/internal/models"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
)
func init() {
register(8, "sync lightweight ticket permissions", func() error {
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
if err := resetLightweightTicketData(ctx.Tx); err != nil {
return err
}
permissions, err := ensurePermissions(ctx.Tx)
if err != nil {
return err
}
roles, err := ensureRoles(ctx.Tx)
if err != nil {
return err
}
return ensureRolePermissions(ctx.Tx, roles, permissions)
})
})
}
func resetLightweightTicketData(tx *gorm.DB) error {
for _, table := range lightweightTicketResetTables(tx) {
if !tx.Migrator().HasTable(table) {
continue
}
if err := tx.Exec("DELETE FROM " + table).Error; err != nil {
return err
}
}
return nil
}
func lightweightTicketResetTables(tx *gorm.DB) []string {
return []string{
"t_ticket_sla_record",
"t_ticket_resolution_code",
"t_ticket_priority_config",
"t_ticket_watcher",
"t_ticket_collaborator",
"t_ticket_mention",
"t_ticket_event_log",
"t_ticket_relation",
"t_ticket_comment",
tableName(tx, &models.TicketProgress{}, "t_ticket_progress"),
tableName(tx, &models.TicketTag{}, "t_ticket_tag"),
tableName(tx, &models.Ticket{}, "t_ticket"),
tableName(tx, &models.TicketNoSequence{}, "t_ticket_no_sequence"),
tableName(tx, &models.TicketView{}, "t_ticket_view"),
}
}
func tableName(tx *gorm.DB, model any, fallback string) string {
stmt := &gorm.Statement{DB: tx}
if err := stmt.Parse(model); err != nil {
return fallback
}
return stmt.Schema.Table
}
@@ -11,3 +11,13 @@ func TestNotificationPermissionMigrationRegistered(t *testing.T) {
t.Fatalf("unexpected migration remark: %q", migration.Remark) t.Fatalf("unexpected migration remark: %q", migration.Remark)
} }
} }
func TestLightweightTicketPermissionMigrationRegistered(t *testing.T) {
migration, ok := migrationFuncs[8]
if !ok {
t.Fatalf("expected migration version 8 to be registered")
}
if migration.Remark != "sync lightweight ticket permissions" {
t.Fatalf("unexpected migration remark: %q", migration.Remark)
}
}
@@ -2,11 +2,13 @@ package repositories
import ( import (
"cs-agent/internal/models" "cs-agent/internal/models"
"errors"
"time" "time"
"github.com/mlogclub/simple/sqls" "github.com/mlogclub/simple/sqls"
"github.com/mlogclub/simple/web/params" "github.com/mlogclub/simple/web/params"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
var TicketNoSequenceRepository = newTicketNoSequenceRepository() var TicketNoSequenceRepository = newTicketNoSequenceRepository()
@@ -74,6 +76,18 @@ func (r *ticketNoSequenceRepository) GetByDateKey(db *gorm.DB, dateKey string) *
return ret return ret
} }
func (r *ticketNoSequenceRepository) GetByDateKeyForUpdate(db *gorm.DB, dateKey string) (*models.TicketNoSequence, error) {
ret := &models.TicketNoSequence{}
err := db.Clauses(clause.Locking{Strength: "UPDATE"}).Take(ret, "date_key = ?", dateKey).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return ret, nil
}
func (r *ticketNoSequenceRepository) Create(db *gorm.DB, t *models.TicketNoSequence) error { func (r *ticketNoSequenceRepository) Create(db *gorm.DB, t *models.TicketNoSequence) error {
return db.Create(t).Error return db.Create(t).Error
} }
@@ -25,7 +25,7 @@ func (r *ticketViewRepository) Get(db *gorm.DB, id int64) *models.TicketView {
return ret return ret
} }
func (r *ticketViewRepository) Take(db *gorm.DB, where ...interface{}) *models.TicketView { func (r *ticketViewRepository) Take(db *gorm.DB, where ...any) *models.TicketView {
ret := &models.TicketView{} ret := &models.TicketView{}
if err := db.Take(ret, where...).Error; err != nil { if err := db.Take(ret, where...).Error; err != nil {
return nil return nil
@@ -74,10 +74,10 @@ func (r *ticketViewRepository) Update(db *gorm.DB, t *models.TicketView) error {
return db.Save(t).Error return db.Save(t).Error
} }
func (r *ticketViewRepository) Updates(db *gorm.DB, id int64, columns map[string]interface{}) error { func (r *ticketViewRepository) Updates(db *gorm.DB, id int64, columns map[string]any) error {
return db.Model(&models.TicketView{}).Where("id = ?", id).Updates(columns).Error return db.Model(&models.TicketView{}).Where("id = ?", id).Updates(columns).Error
} }
func (r *ticketViewRepository) Delete(db *gorm.DB, id int64) { func (r *ticketViewRepository) Delete(db *gorm.DB, id int64) error {
db.Delete(&models.TicketView{}, "id = ?", id) return db.Delete(&models.TicketView{}, "id = ?", id).Error
} }
@@ -1,7 +1,6 @@
package services package services
import ( import (
"fmt"
"time" "time"
"cs-agent/internal/models" "cs-agent/internal/models"
@@ -70,29 +69,5 @@ func (s *ticketNoSequenceService) Delete(id int64) {
} }
func (s *ticketNoSequenceService) Next(db *gorm.DB, now time.Time) (string, error) { func (s *ticketNoSequenceService) Next(db *gorm.DB, now time.Time) (string, error) {
dateKey := now.Format("20060102") return TicketNoService.Next(db, now)
for i := 0; i < 5; i++ {
current := repositories.TicketNoSequenceRepository.GetByDateKey(db, dateKey)
if current == nil {
item := &models.TicketNoSequence{
DateKey: dateKey,
NextSeq: 2,
CreatedAt: now,
UpdatedAt: now,
}
if err := repositories.TicketNoSequenceRepository.Create(db, item); err != nil {
continue
}
return fmt.Sprintf("TK%s%04d", dateKey, int64(1)), nil
}
seq := current.NextSeq
ok, err := repositories.TicketNoSequenceRepository.UpdateNextSeq(db, current.ID, seq, seq+1, now)
if err != nil {
return "", err
}
if ok {
return fmt.Sprintf("TK%s%04d", dateKey, seq), nil
}
}
return "", fmt.Errorf("generate ticket number failed")
} }
+54 -10
View File
@@ -5,6 +5,7 @@ import (
"cs-agent/internal/repositories" "cs-agent/internal/repositories"
"fmt" "fmt"
"strings" "strings"
"sync"
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"
@@ -12,6 +13,8 @@ import (
var TicketNoService = newTicketNoService() var TicketNoService = newTicketNoService()
var ticketNoSQLiteMu sync.Mutex
func newTicketNoService() *ticketNoService { func newTicketNoService() *ticketNoService {
return &ticketNoService{} return &ticketNoService{}
} }
@@ -22,9 +25,25 @@ func (s *ticketNoService) Next(tx *gorm.DB, now time.Time) (string, error) {
if tx == nil { if tx == nil {
return "", fmt.Errorf("ticket number transaction is required") return "", fmt.Errorf("ticket number transaction is required")
} }
if tx.Dialector.Name() == "sqlite" {
ticketNoSQLiteMu.Lock()
defer ticketNoSQLiteMu.Unlock()
return s.nextWithRetry(tx, now)
}
return s.nextWithRetry(tx, now)
}
func (s *ticketNoService) nextWithRetry(tx *gorm.DB, now time.Time) (string, error) {
dateKey := now.Format("20060102") dateKey := now.Format("20060102")
for attempt := 0; attempt < 20; attempt++ { for attempt := 0; attempt < 100; attempt++ {
current := repositories.TicketNoSequenceRepository.GetByDateKey(tx, dateKey) current, err := repositories.TicketNoSequenceRepository.GetByDateKeyForUpdate(tx, dateKey)
if err != nil {
if isRetriableTicketNoError(err) {
sleepTicketNoRetry(attempt)
continue
}
return "", err
}
if current == nil { if current == nil {
item := &models.TicketNoSequence{ item := &models.TicketNoSequence{
DateKey: dateKey, DateKey: dateKey,
@@ -39,14 +58,25 @@ func (s *ticketNoService) Next(tx *gorm.DB, now time.Time) (string, error) {
if !isRetriableTicketNoError(err) { if !isRetriableTicketNoError(err) {
return "", err return "", err
} }
time.Sleep(time.Duration(attempt+1) * 10 * time.Millisecond)
continue current, err = repositories.TicketNoSequenceRepository.GetByDateKeyForUpdate(tx, dateKey)
}
allocated := current.NextSeq
ok, err := repositories.TicketNoSequenceRepository.UpdateNextSeq(tx, current.ID, current.NextSeq, current.NextSeq+1, now)
if err != nil { if err != nil {
if isRetriableTicketNoError(err) { if isRetriableTicketNoError(err) {
time.Sleep(time.Duration(attempt+1) * 10 * time.Millisecond) sleepTicketNoRetry(attempt)
continue
}
return "", err
}
if current == nil {
sleepTicketNoRetry(attempt)
continue
}
}
allocated := current.NextSeq
ok, err := repositories.TicketNoSequenceRepository.UpdateNextSeq(tx, current.ID, allocated, allocated+1, now)
if err != nil {
if isRetriableTicketNoError(err) {
sleepTicketNoRetry(attempt)
continue continue
} }
return "", err return "", err
@@ -54,11 +84,19 @@ func (s *ticketNoService) Next(tx *gorm.DB, now time.Time) (string, error) {
if ok { if ok {
return formatTicketNo(dateKey, allocated), nil return formatTicketNo(dateKey, allocated), nil
} }
time.Sleep(time.Duration(attempt+1) * 10 * time.Millisecond) sleepTicketNoRetry(attempt)
} }
return "", fmt.Errorf("failed to allocate ticket number") return "", fmt.Errorf("failed to allocate ticket number")
} }
func sleepTicketNoRetry(attempt int) {
delay := time.Duration(attempt+1) * 10 * time.Millisecond
if delay > 200*time.Millisecond {
delay = 200 * time.Millisecond
}
time.Sleep(delay)
}
func formatTicketNo(dateKey string, seq int64) string { func formatTicketNo(dateKey string, seq int64) string {
return fmt.Sprintf("TK%s%05d", dateKey, seq) return fmt.Sprintf("TK%s%05d", dateKey, seq)
} }
@@ -80,5 +118,11 @@ func isDatabaseLockedError(err error) bool {
return false return false
} }
message := strings.ToLower(err.Error()) message := strings.ToLower(err.Error())
return strings.Contains(message, "database is locked") || strings.Contains(message, "database table is locked") return strings.Contains(message, "database is locked") ||
strings.Contains(message, "database table is locked") ||
strings.Contains(message, "database is busy") ||
strings.Contains(message, "lock wait timeout") ||
strings.Contains(message, "deadlock") ||
strings.Contains(message, "try restarting transaction") ||
strings.Contains(message, "could not serialize")
} }
+11 -5
View File
@@ -59,7 +59,7 @@ func (s *ticketService) Get(id int64) *models.Ticket {
return repositories.TicketRepository.Get(sqls.DB(), id) return repositories.TicketRepository.Get(sqls.DB(), id)
} }
func (s *ticketService) Take(where ...interface{}) *models.Ticket { func (s *ticketService) Take(where ...any) *models.Ticket {
return repositories.TicketRepository.Take(sqls.DB(), where...) return repositories.TicketRepository.Take(sqls.DB(), where...)
} }
@@ -96,11 +96,11 @@ func (s *ticketService) Update(t *models.Ticket) error {
return repositories.TicketRepository.Update(sqls.DB(), t) return repositories.TicketRepository.Update(sqls.DB(), t)
} }
func (s *ticketService) Updates(id int64, columns map[string]interface{}) error { func (s *ticketService) Updates(id int64, columns map[string]any) error {
return repositories.TicketRepository.Updates(sqls.DB(), id, columns) return repositories.TicketRepository.Updates(sqls.DB(), id, columns)
} }
func (s *ticketService) UpdateColumn(id int64, name string, value interface{}) error { func (s *ticketService) UpdateColumn(id int64, name string, value any) error {
return repositories.TicketRepository.UpdateColumn(sqls.DB(), id, name, value) return repositories.TicketRepository.UpdateColumn(sqls.DB(), id, name, value)
} }
@@ -576,9 +576,15 @@ func (s *ticketService) validateTicketRefs(customerID, conversationID, assigneeI
if customerID > 0 && CustomerService.Get(customerID) == nil { if customerID > 0 && CustomerService.Get(customerID) == nil {
return errorsx.InvalidParam("客户不存在") return errorsx.InvalidParam("客户不存在")
} }
if conversationID > 0 && ConversationService.Get(conversationID) == nil { if conversationID > 0 {
conversation := ConversationService.Get(conversationID)
if conversation == nil {
return errorsx.InvalidParam("会话不存在") return errorsx.InvalidParam("会话不存在")
} }
if customerID > 0 && conversation.CustomerID != customerID {
return errorsx.InvalidParam("会话与客户不匹配")
}
}
return s.validateAssignee(assigneeID) return s.validateAssignee(assigneeID)
} }
@@ -594,7 +600,7 @@ func (s *ticketService) validateRequiredAssignee(userID int64) error {
return errorsx.InvalidParam("负责人不存在") return errorsx.InvalidParam("负责人不存在")
} }
user := UserService.Get(userID) user := UserService.Get(userID)
if user == nil || user.Status == enums.StatusDeleted { if user == nil || user.Status != enums.StatusOk {
return errorsx.InvalidParam("负责人不存在") return errorsx.InvalidParam("负责人不存在")
} }
return nil return nil
+68 -4
View File
@@ -208,6 +208,43 @@ func TestTicketServiceAssignTicketRequiresTargetUser(t *testing.T) {
} }
} }
func TestTicketServiceAssignTicketRejectsDisabledUser(t *testing.T) {
setupTicketTestDB(t)
operator := createTestOperator(t, "assign-disabled-operator")
disabledUserID := createTestUserWithStatus(t, "assign-disabled-user", enums.StatusDisabled)
ticket, err := services.TicketService.CreateTicket(createTestTicketRequest("assign-disabled-ticket"), operator)
if err != nil {
t.Fatalf("CreateTicket() error = %v", err)
}
err = services.TicketService.AssignTicket(request.AssignTicketRequest{
TicketID: ticket.ID,
ToUserID: disabledUserID,
Reason: "disabled assignment",
}, operator)
if err == nil {
t.Fatalf("expected AssignTicket() to reject disabled target user")
}
}
func TestTicketServiceCreateTicketRejectsMismatchedCustomerConversation(t *testing.T) {
setupTicketTestDB(t)
operator := createTestOperator(t, "mismatch-operator")
customerID := createTestCustomer(t, "mismatch-customer")
otherCustomerID := createTestCustomer(t, "mismatch-other-customer")
conversationID := createTestConversation(t, otherCustomerID, "mismatch-conversation")
_, err := services.TicketService.CreateTicket(request.CreateTicketRequest{
Title: "mismatch ticket",
Description: "mismatch ticket description",
CustomerID: customerID,
ConversationID: conversationID,
}, operator)
if err == nil {
t.Fatalf("expected CreateTicket() to reject mismatched customer and conversation")
}
}
func TestTicketServiceSummaryCountsStaleTickets(t *testing.T) { func TestTicketServiceSummaryCountsStaleTickets(t *testing.T) {
setupTicketTestDB(t) setupTicketTestDB(t)
operator := createTestOperator(t, "summary-operator") operator := createTestOperator(t, "summary-operator")
@@ -284,9 +321,9 @@ func TestTicketServiceFindPageAggregateEnrichesLookups(t *testing.T) {
} }
func TestTicketServiceTicketNoNextConcurrent(t *testing.T) { func TestTicketServiceTicketNoNextConcurrent(t *testing.T) {
setupTicketTestDB(t) setupTicketTestDBWithMaxOpenConns(t, 8)
const count = 20 const count = 50
results := make(chan string, count) results := make(chan string, count)
errs := make(chan error, count) errs := make(chan error, count)
var wg sync.WaitGroup var wg sync.WaitGroup
@@ -332,6 +369,10 @@ func TestTicketServiceTicketNoNextConcurrent(t *testing.T) {
} }
func setupTicketTestDB(t *testing.T) { func setupTicketTestDB(t *testing.T) {
setupTicketTestDBWithMaxOpenConns(t, 0)
}
func setupTicketTestDBWithMaxOpenConns(t *testing.T, maxOpenConns int) {
t.Helper() t.Helper()
dbPath := filepath.Join(t.TempDir(), "ticket-test.db") dbPath := filepath.Join(t.TempDir(), "ticket-test.db")
@@ -339,7 +380,7 @@ func setupTicketTestDB(t *testing.T) {
Type: "sqlite", Type: "sqlite",
DSN: "file:" + dbPath + "?_busy_timeout=5000", DSN: "file:" + dbPath + "?_busy_timeout=5000",
MaxIdleConns: 1, MaxIdleConns: 1,
MaxOpenConns: 1, MaxOpenConns: maxOpenConns,
}) })
if err != nil { if err != nil {
t.Fatalf("InitDB() error = %v", err) t.Fatalf("InitDB() error = %v", err)
@@ -369,13 +410,17 @@ func createTestOperator(t *testing.T, prefix string) *dto.AuthPrincipal {
} }
func createTestUser(t *testing.T, prefix string) int64 { func createTestUser(t *testing.T, prefix string) int64 {
return createTestUserWithStatus(t, prefix, enums.StatusOk)
}
func createTestUserWithStatus(t *testing.T, prefix string, status enums.Status) int64 {
t.Helper() t.Helper()
now := time.Now() now := time.Now()
username := fmt.Sprintf("%s_%d", prefix, now.UnixNano()) username := fmt.Sprintf("%s_%d", prefix, now.UnixNano())
user := &models.User{ user := &models.User{
Username: username, Username: username,
Nickname: prefix, Nickname: prefix,
Status: enums.StatusOk, Status: status,
AuditFields: models.AuditFields{ AuditFields: models.AuditFields{
CreatedAt: now, CreatedAt: now,
CreateUserID: 1, CreateUserID: 1,
@@ -391,6 +436,25 @@ func createTestUser(t *testing.T, prefix string) int64 {
return user.ID return user.ID
} }
func createTestConversation(t *testing.T, customerID int64, prefix string) int64 {
t.Helper()
now := time.Now()
item := &models.Conversation{
CustomerID: customerID,
CustomerName: prefix,
Status: enums.IMConversationStatusActive,
ServiceMode: enums.IMConversationServiceModeAIOnly,
LastMessageAt: now,
LastActiveAt: now,
AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now},
}
if err := repositories.ConversationRepository.Create(sqls.DB(), item); err != nil {
t.Fatalf("create conversation error = %v", err)
}
return item.ID
}
func createTestCustomer(t *testing.T, prefix string) int64 { func createTestCustomer(t *testing.T, prefix string) int64 {
t.Helper() t.Helper()
+2 -3
View File
@@ -57,7 +57,7 @@ func (s *ticketViewService) Save(req request.SaveTicketViewRequest, operator *dt
if item == nil || item.UserID != operator.UserID { if item == nil || item.UserID != operator.UserID {
return nil, errorsx.InvalidParam("视图不存在") return nil, errorsx.InvalidParam("视图不存在")
} }
if err := repositories.TicketViewRepository.Updates(sqls.DB(), req.ID, map[string]interface{}{ if err := repositories.TicketViewRepository.Updates(sqls.DB(), req.ID, map[string]any{
"name": name, "name": name,
"filters_json": string(filtersJSON), "filters_json": string(filtersJSON),
"update_user_id": operator.UserID, "update_user_id": operator.UserID,
@@ -88,6 +88,5 @@ func (s *ticketViewService) Delete(id int64, operator *dto.AuthPrincipal) error
if item == nil || item.UserID != operator.UserID { if item == nil || item.UserID != operator.UserID {
return errorsx.InvalidParam("视图不存在") return errorsx.InvalidParam("视图不存在")
} }
repositories.TicketViewRepository.Delete(sqls.DB(), id) return repositories.TicketViewRepository.Delete(sqls.DB(), id)
return nil
} }