修复会话安全与实时消息

This commit is contained in:
yml2213
2026-07-14 15:06:01 +08:00
parent 064af29d3b
commit 8cff2a5824
29 changed files with 1961 additions and 546 deletions
+10 -6
View File
@@ -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("数据库迁移完成")
}
+34
View File
@@ -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
})
}
+51
View File
@@ -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("重复会话消息序号未被唯一索引拦截")
}
}
+73 -72
View File
@@ -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 {
+30
View File
@@ -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
}