修复会话安全与实时消息
This commit is contained in:
@@ -19,7 +19,16 @@ func InitDB(dsn string) {
|
||||
log.Fatalf("数据库连接失败: %v", err)
|
||||
}
|
||||
|
||||
err = DB.AutoMigrate(
|
||||
err = Migrate(DB)
|
||||
if err != nil {
|
||||
log.Fatalf("数据库迁移失败: %v", err)
|
||||
}
|
||||
|
||||
log.Println("数据库迁移完成")
|
||||
}
|
||||
|
||||
func Migrate(db *gorm.DB) error {
|
||||
return db.AutoMigrate(
|
||||
&Tenant{},
|
||||
&User{},
|
||||
&Channel{},
|
||||
@@ -33,9 +42,4 @@ func InitDB(dsn string) {
|
||||
&OperationLog{},
|
||||
&Announcement{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("数据库迁移失败: %v", err)
|
||||
}
|
||||
|
||||
log.Println("数据库迁移完成")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
var ErrSessionNotFound = errors.New("会话不存在")
|
||||
|
||||
// CreateMessage 在会话行锁保护下分配消息序号,避免并发写入出现重复序号。
|
||||
func CreateMessage(message *Message) error {
|
||||
return DB.Transaction(func(tx *gorm.DB) error {
|
||||
var session Session
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&session, message.SessionID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrSessionNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
var maxSeq int
|
||||
if err := tx.Model(&Message{}).
|
||||
Where("session_id = ?", message.SessionID).
|
||||
Select("COALESCE(MAX(seq), 0)").
|
||||
Scan(&maxSeq).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
message.Seq = maxSeq + 1
|
||||
return tx.Create(message).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestCreateMessageAssignsMonotonicSequence(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:kefu_message_%d?mode=memory&cache=shared", time.Now().UnixNano())), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("打开测试数据库失败: %v", err)
|
||||
}
|
||||
if err := Migrate(db); err != nil {
|
||||
t.Fatalf("迁移测试数据库失败: %v", err)
|
||||
}
|
||||
DB = db
|
||||
|
||||
tenant := Tenant{Name: "消息测试租户", Status: "normal"}
|
||||
if err := DB.Create(&tenant).Error; err != nil {
|
||||
t.Fatalf("创建租户失败: %v", err)
|
||||
}
|
||||
customer := Customer{TenantID: tenant.ID, Name: "测试客户"}
|
||||
if err := DB.Create(&customer).Error; err != nil {
|
||||
t.Fatalf("创建客户失败: %v", err)
|
||||
}
|
||||
session := Session{TenantID: tenant.ID, CustomerID: customer.ID, Status: "active", Priority: "normal"}
|
||||
if err := DB.Create(&session).Error; err != nil {
|
||||
t.Fatalf("创建会话失败: %v", err)
|
||||
}
|
||||
|
||||
first := Message{SessionID: session.ID, SenderType: "visitor", Content: "第一条", Type: "text", SentAt: time.Now()}
|
||||
second := Message{SessionID: session.ID, SenderType: "agent", Content: "第二条", Type: "text", SentAt: time.Now()}
|
||||
if err := CreateMessage(&first); err != nil {
|
||||
t.Fatalf("创建第一条消息失败: %v", err)
|
||||
}
|
||||
if err := CreateMessage(&second); err != nil {
|
||||
t.Fatalf("创建第二条消息失败: %v", err)
|
||||
}
|
||||
if first.Seq != 1 || second.Seq != 2 {
|
||||
t.Fatalf("消息序号异常: first=%d second=%d", first.Seq, second.Seq)
|
||||
}
|
||||
|
||||
duplicate := Message{SessionID: session.ID, SenderType: "agent", Content: "重复序号", Type: "text", Seq: 2, SentAt: time.Now()}
|
||||
if err := DB.Create(&duplicate).Error; err == nil {
|
||||
t.Fatal("重复会话消息序号未被唯一索引拦截")
|
||||
}
|
||||
}
|
||||
@@ -7,96 +7,97 @@ import (
|
||||
)
|
||||
|
||||
type Tenant struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"size:100;not null;uniqueIndex" json:"name"`
|
||||
PlanID *uint `json:"plan_id"`
|
||||
SeatCount int `gorm:"default:2" json:"seat_count"`
|
||||
ExpireAt time.Time `json:"expire_at"`
|
||||
Status string `gorm:"size:20;default:normal" json:"status"`
|
||||
ContactName string `gorm:"size:30" json:"contact_name"`
|
||||
ContactPhone string `gorm:"size:20" json:"contact_phone"`
|
||||
ContactEmail string `gorm:"size:100" json:"contact_email"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"size:100;not null;uniqueIndex" json:"name"`
|
||||
PlanID *uint `json:"plan_id"`
|
||||
SeatCount int `gorm:"default:2" json:"seat_count"`
|
||||
ExpireAt time.Time `json:"expire_at"`
|
||||
Status string `gorm:"size:20;default:normal" json:"status"`
|
||||
ContactName string `gorm:"size:30" json:"contact_name"`
|
||||
ContactPhone string `gorm:"size:20" json:"contact_phone"`
|
||||
ContactEmail string `gorm:"size:100" json:"contact_email"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
TenantID uint `gorm:"index;not null" json:"tenant_id"`
|
||||
Role string `gorm:"size:20;default:agent" json:"role"`
|
||||
Username string `gorm:"size:50;not null;uniqueIndex" json:"username"`
|
||||
PasswordHash string `gorm:"size:255;not null" json:"-"`
|
||||
Nickname string `gorm:"size:50" json:"nickname"`
|
||||
Status string `gorm:"size:20;default:online" json:"status"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
TenantID uint `gorm:"index;not null" json:"tenant_id"`
|
||||
Role string `gorm:"size:20;default:agent" json:"role"`
|
||||
Username string `gorm:"size:50;not null;uniqueIndex" json:"username"`
|
||||
PasswordHash string `gorm:"size:255;not null" json:"-"`
|
||||
Nickname string `gorm:"size:50" json:"nickname"`
|
||||
Status string `gorm:"size:20;default:online" json:"status"`
|
||||
LastOnlineAt *time.Time `json:"last_online_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Channel struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
TenantID uint `gorm:"index;not null" json:"tenant_id"`
|
||||
Type string `gorm:"size:30;not null" json:"type"`
|
||||
Name string `gorm:"size:50" json:"name"`
|
||||
Status string `gorm:"size:20;default:enabled" json:"status"`
|
||||
Config string `gorm:"type:text" json:"config"`
|
||||
ScriptCode string `gorm:"size:500" json:"script_code"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
TenantID uint `gorm:"index;not null" json:"tenant_id"`
|
||||
Type string `gorm:"size:30;not null" json:"type"`
|
||||
Name string `gorm:"size:50" json:"name"`
|
||||
Status string `gorm:"size:20;default:enabled" json:"status"`
|
||||
Config string `gorm:"type:text" json:"config"`
|
||||
ScriptCode string `gorm:"size:500" json:"script_code"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Customer struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
TenantID uint `gorm:"index;not null" json:"tenant_id"`
|
||||
Name string `gorm:"size:50;not null" json:"name"`
|
||||
Phone string `gorm:"size:20" json:"phone"`
|
||||
Email string `gorm:"size:100" json:"email"`
|
||||
Tags string `gorm:"type:text" json:"tags"`
|
||||
Source string `gorm:"size:30" json:"source"`
|
||||
Status string `gorm:"size:20;default:online" json:"status"`
|
||||
ConversationCount int `gorm:"default:0" json:"conversation_count"`
|
||||
SatisfactionSum float64 `gorm:"default:0" json:"-"`
|
||||
SatisfactionCount int `gorm:"default:0" json:"-"`
|
||||
LastContactAt *time.Time `json:"last_contact_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
TenantID uint `gorm:"index;not null" json:"tenant_id"`
|
||||
Name string `gorm:"size:50;not null" json:"name"`
|
||||
Phone string `gorm:"size:20" json:"phone"`
|
||||
Email string `gorm:"size:100" json:"email"`
|
||||
Tags string `gorm:"type:text" json:"tags"`
|
||||
Source string `gorm:"size:30" json:"source"`
|
||||
Status string `gorm:"size:20;default:online" json:"status"`
|
||||
ConversationCount int `gorm:"default:0" json:"conversation_count"`
|
||||
SatisfactionSum float64 `gorm:"default:0" json:"-"`
|
||||
SatisfactionCount int `gorm:"default:0" json:"-"`
|
||||
LastContactAt *time.Time `json:"last_contact_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
TenantID uint `gorm:"index;not null" json:"tenant_id"`
|
||||
ChannelID uint `json:"channel_id"`
|
||||
CustomerID uint `gorm:"index" json:"customer_id"`
|
||||
AgentID *uint `gorm:"index" json:"agent_id"`
|
||||
Status string `gorm:"size:20;default:waiting" json:"status"`
|
||||
Priority string `gorm:"size:20;default:normal" json:"priority"`
|
||||
SatisfactionScore *int `json:"satisfaction_score"`
|
||||
SatisfactionText string `gorm:"size:500" json:"satisfaction_text"`
|
||||
EndReason string `gorm:"size:50" json:"end_reason"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
EndedAt *time.Time `json:"ended_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
TenantID uint `gorm:"index;not null" json:"tenant_id"`
|
||||
ChannelID uint `json:"channel_id"`
|
||||
CustomerID uint `gorm:"index" json:"customer_id"`
|
||||
AgentID *uint `gorm:"index" json:"agent_id"`
|
||||
VisitorTokenHash string `gorm:"size:64;index" json:"-"`
|
||||
Status string `gorm:"size:20;default:waiting" json:"status"`
|
||||
Priority string `gorm:"size:20;default:normal" json:"priority"`
|
||||
SatisfactionScore *int `json:"satisfaction_score"`
|
||||
SatisfactionText string `gorm:"size:500" json:"satisfaction_text"`
|
||||
EndReason string `gorm:"size:50" json:"end_reason"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
EndedAt *time.Time `json:"ended_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
SessionID uint `gorm:"index;not null" json:"session_id"`
|
||||
SessionID uint `gorm:"not null;uniqueIndex:idx_message_session_seq" json:"session_id"`
|
||||
SenderType string `gorm:"size:20;not null" json:"sender_type"`
|
||||
SenderID *uint `json:"sender_id"`
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
Type string `gorm:"size:20;default:text" json:"type"`
|
||||
Seq int `gorm:"not null" json:"seq"`
|
||||
Seq int `gorm:"not null;uniqueIndex:idx_message_session_seq" json:"seq"`
|
||||
SentAt time.Time `json:"sent_at"`
|
||||
}
|
||||
|
||||
type SessionEvent struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
SessionID uint `gorm:"index;not null" json:"session_id"`
|
||||
OperatorID uint `json:"operator_id"`
|
||||
Action string `gorm:"size:50;not null" json:"action"`
|
||||
Detail string `gorm:"size:500" json:"detail"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
SessionID uint `gorm:"index;not null" json:"session_id"`
|
||||
OperatorID uint `json:"operator_id"`
|
||||
Action string `gorm:"size:50;not null" json:"action"`
|
||||
Detail string `gorm:"size:500" json:"detail"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type Category struct {
|
||||
@@ -119,16 +120,16 @@ type KnowledgeEntry struct {
|
||||
}
|
||||
|
||||
type Plan struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"size:30;not null" json:"name"`
|
||||
PriceMonthly int `json:"price_monthly"`
|
||||
Seats int `json:"seats"`
|
||||
StorageDays int `json:"storage_days"`
|
||||
KBLimit int `json:"kb_limit"`
|
||||
Features string `gorm:"type:text" json:"features"`
|
||||
Status string `gorm:"size:20;default:active" json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"size:30;not null" json:"name"`
|
||||
PriceMonthly int `json:"price_monthly"`
|
||||
Seats int `json:"seats"`
|
||||
StorageDays int `json:"storage_days"`
|
||||
KBLimit int `json:"kb_limit"`
|
||||
Features string `gorm:"type:text" json:"features"`
|
||||
Status string `gorm:"size:20;default:active" json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type OperationLog struct {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
)
|
||||
|
||||
func NewVisitorToken() (string, string, error) {
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
token := base64.RawURLEncoding.EncodeToString(bytes)
|
||||
return token, HashVisitorToken(token), nil
|
||||
}
|
||||
|
||||
func HashVisitorToken(token string) string {
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
return base64.RawURLEncoding.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
func VerifyVisitorToken(session *Session, token string) bool {
|
||||
if token == "" || session.VisitorTokenHash == "" {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(session.VisitorTokenHash), []byte(HashVisitorToken(token))) == 1
|
||||
}
|
||||
Reference in New Issue
Block a user