fix(ticket): harden lightweight backend core
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"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) {
|
||||
dateKey := now.Format("20060102")
|
||||
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")
|
||||
return TicketNoService.Next(db, now)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"cs-agent/internal/repositories"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -12,6 +13,8 @@ import (
|
||||
|
||||
var TicketNoService = newTicketNoService()
|
||||
|
||||
var ticketNoSQLiteMu sync.Mutex
|
||||
|
||||
func newTicketNoService() *ticketNoService {
|
||||
return &ticketNoService{}
|
||||
}
|
||||
@@ -22,9 +25,25 @@ func (s *ticketNoService) Next(tx *gorm.DB, now time.Time) (string, error) {
|
||||
if tx == nil {
|
||||
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")
|
||||
for attempt := 0; attempt < 20; attempt++ {
|
||||
current := repositories.TicketNoSequenceRepository.GetByDateKey(tx, dateKey)
|
||||
for attempt := 0; attempt < 100; attempt++ {
|
||||
current, err := repositories.TicketNoSequenceRepository.GetByDateKeyForUpdate(tx, dateKey)
|
||||
if err != nil {
|
||||
if isRetriableTicketNoError(err) {
|
||||
sleepTicketNoRetry(attempt)
|
||||
continue
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if current == nil {
|
||||
item := &models.TicketNoSequence{
|
||||
DateKey: dateKey,
|
||||
@@ -39,14 +58,25 @@ func (s *ticketNoService) Next(tx *gorm.DB, now time.Time) (string, error) {
|
||||
if !isRetriableTicketNoError(err) {
|
||||
return "", err
|
||||
}
|
||||
time.Sleep(time.Duration(attempt+1) * 10 * time.Millisecond)
|
||||
continue
|
||||
|
||||
current, err = repositories.TicketNoSequenceRepository.GetByDateKeyForUpdate(tx, dateKey)
|
||||
if err != nil {
|
||||
if isRetriableTicketNoError(err) {
|
||||
sleepTicketNoRetry(attempt)
|
||||
continue
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if current == nil {
|
||||
sleepTicketNoRetry(attempt)
|
||||
continue
|
||||
}
|
||||
}
|
||||
allocated := current.NextSeq
|
||||
ok, err := repositories.TicketNoSequenceRepository.UpdateNextSeq(tx, current.ID, current.NextSeq, current.NextSeq+1, now)
|
||||
ok, err := repositories.TicketNoSequenceRepository.UpdateNextSeq(tx, current.ID, allocated, allocated+1, now)
|
||||
if err != nil {
|
||||
if isRetriableTicketNoError(err) {
|
||||
time.Sleep(time.Duration(attempt+1) * 10 * time.Millisecond)
|
||||
sleepTicketNoRetry(attempt)
|
||||
continue
|
||||
}
|
||||
return "", err
|
||||
@@ -54,11 +84,19 @@ func (s *ticketNoService) Next(tx *gorm.DB, now time.Time) (string, error) {
|
||||
if ok {
|
||||
return formatTicketNo(dateKey, allocated), nil
|
||||
}
|
||||
time.Sleep(time.Duration(attempt+1) * 10 * time.Millisecond)
|
||||
sleepTicketNoRetry(attempt)
|
||||
}
|
||||
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 {
|
||||
return fmt.Sprintf("TK%s%05d", dateKey, seq)
|
||||
}
|
||||
@@ -80,5 +118,11 @@ func isDatabaseLockedError(err error) bool {
|
||||
return false
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ func (s *ticketService) Get(id int64) *models.Ticket {
|
||||
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...)
|
||||
}
|
||||
|
||||
@@ -96,11 +96,11 @@ func (s *ticketService) Update(t *models.Ticket) error {
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -576,8 +576,14 @@ func (s *ticketService) validateTicketRefs(customerID, conversationID, assigneeI
|
||||
if customerID > 0 && CustomerService.Get(customerID) == nil {
|
||||
return errorsx.InvalidParam("客户不存在")
|
||||
}
|
||||
if conversationID > 0 && ConversationService.Get(conversationID) == nil {
|
||||
return errorsx.InvalidParam("会话不存在")
|
||||
if conversationID > 0 {
|
||||
conversation := ConversationService.Get(conversationID)
|
||||
if conversation == nil {
|
||||
return errorsx.InvalidParam("会话不存在")
|
||||
}
|
||||
if customerID > 0 && conversation.CustomerID != customerID {
|
||||
return errorsx.InvalidParam("会话与客户不匹配")
|
||||
}
|
||||
}
|
||||
return s.validateAssignee(assigneeID)
|
||||
}
|
||||
@@ -594,7 +600,7 @@ func (s *ticketService) validateRequiredAssignee(userID int64) error {
|
||||
return errorsx.InvalidParam("负责人不存在")
|
||||
}
|
||||
user := UserService.Get(userID)
|
||||
if user == nil || user.Status == enums.StatusDeleted {
|
||||
if user == nil || user.Status != enums.StatusOk {
|
||||
return errorsx.InvalidParam("负责人不存在")
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -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) {
|
||||
setupTicketTestDB(t)
|
||||
operator := createTestOperator(t, "summary-operator")
|
||||
@@ -284,9 +321,9 @@ func TestTicketServiceFindPageAggregateEnrichesLookups(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTicketServiceTicketNoNextConcurrent(t *testing.T) {
|
||||
setupTicketTestDB(t)
|
||||
setupTicketTestDBWithMaxOpenConns(t, 8)
|
||||
|
||||
const count = 20
|
||||
const count = 50
|
||||
results := make(chan string, count)
|
||||
errs := make(chan error, count)
|
||||
var wg sync.WaitGroup
|
||||
@@ -332,6 +369,10 @@ func TestTicketServiceTicketNoNextConcurrent(t *testing.T) {
|
||||
}
|
||||
|
||||
func setupTicketTestDB(t *testing.T) {
|
||||
setupTicketTestDBWithMaxOpenConns(t, 0)
|
||||
}
|
||||
|
||||
func setupTicketTestDBWithMaxOpenConns(t *testing.T, maxOpenConns int) {
|
||||
t.Helper()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "ticket-test.db")
|
||||
@@ -339,7 +380,7 @@ func setupTicketTestDB(t *testing.T) {
|
||||
Type: "sqlite",
|
||||
DSN: "file:" + dbPath + "?_busy_timeout=5000",
|
||||
MaxIdleConns: 1,
|
||||
MaxOpenConns: 1,
|
||||
MaxOpenConns: maxOpenConns,
|
||||
})
|
||||
if err != nil {
|
||||
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 {
|
||||
return createTestUserWithStatus(t, prefix, enums.StatusOk)
|
||||
}
|
||||
|
||||
func createTestUserWithStatus(t *testing.T, prefix string, status enums.Status) int64 {
|
||||
t.Helper()
|
||||
now := time.Now()
|
||||
username := fmt.Sprintf("%s_%d", prefix, now.UnixNano())
|
||||
user := &models.User{
|
||||
Username: username,
|
||||
Nickname: prefix,
|
||||
Status: enums.StatusOk,
|
||||
Status: status,
|
||||
AuditFields: models.AuditFields{
|
||||
CreatedAt: now,
|
||||
CreateUserID: 1,
|
||||
@@ -391,6 +436,25 @@ func createTestUser(t *testing.T, prefix string) int64 {
|
||||
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 {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ func (s *ticketViewService) Save(req request.SaveTicketViewRequest, operator *dt
|
||||
if item == nil || item.UserID != operator.UserID {
|
||||
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,
|
||||
"filters_json": string(filtersJSON),
|
||||
"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 {
|
||||
return errorsx.InvalidParam("视图不存在")
|
||||
}
|
||||
repositories.TicketViewRepository.Delete(sqls.DB(), id)
|
||||
return nil
|
||||
return repositories.TicketViewRepository.Delete(sqls.DB(), id)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user