diff --git a/README.md b/README.md index 1512d79..ca7c2c9 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,9 @@ ```bash docker compose -f deploy/docker-compose.dev.yml up -d -docker exec -i hfb-mysql mysql -uhfb -psecret hfb_sys < backend/migrations/000001_init.sql +for file in backend/migrations/*.sql; do + docker exec -i hfb-mysql mysql -uhfb -psecret hfb_sys < "$file" +done cd backend cp .env.example .env @@ -53,6 +55,7 @@ npm run dev - 钱包账务当前为开发态模拟流水,可通过 `GET /api/wallet/balance` 和 `GET /api/wallet/ledger` 查看。 - 后台资金流水已接入,页面为 `http://localhost:5173/admin/wallet-ledger`,支持按用户、订单和业务类型查询。 - 站内信已支持订单关键节点自动写入,可通过 `GET /api/notifications` 查看。 +- 订单群聊已支持支付成功后自动创建,租客、号主和客服可在移动端消息页进入会话。 - 申诉仲裁已支持订单双方发起申诉、开发态后台落账处理,后台页面为 `http://localhost:5173/admin/disputes`。 - 系统配置已支持默认配置初始化和后台编辑,页面为 `http://localhost:5173/admin/system-configs`,更新会写入审计日志。 - 后台已使用独立登录、图形验证码和独立管理 UI,页面为 `http://localhost:5173/admin/login`;开发态默认管理员为 `admin / admin123456`。 diff --git a/backend/internal/model/chat.go b/backend/internal/model/chat.go new file mode 100644 index 0000000..87ee928 --- /dev/null +++ b/backend/internal/model/chat.go @@ -0,0 +1,56 @@ +package model + +import ( + "time" + + "gorm.io/datatypes" +) + +type ChatConversation struct { + ID uint64 `gorm:"primaryKey" json:"id"` + OrderID uint64 `gorm:"not null;uniqueIndex" json:"order_id"` + Type string `gorm:"size:32;not null;default:'order_group'" json:"type"` + Title string `gorm:"size:128;not null" json:"title"` + Status string `gorm:"size:32;not null;default:'active'" json:"status"` + LastMessageID *uint64 `json:"last_message_id"` + LastMessagePreview string `gorm:"size:255;not null;default:''" json:"last_message_preview"` + LastMessageAt *time.Time `json:"last_message_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (ChatConversation) TableName() string { + return "chat_conversations" +} + +type ChatParticipant struct { + ID uint64 `gorm:"primaryKey" json:"id"` + ConversationID uint64 `gorm:"not null;index" json:"conversation_id"` + ParticipantType string `gorm:"size:16;not null;index" json:"participant_type"` + ParticipantID uint64 `gorm:"not null;index" json:"participant_id"` + Role string `gorm:"size:32;not null" json:"role"` + LastReadAt *time.Time `json:"last_read_at"` + JoinedAt time.Time `json:"joined_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (ChatParticipant) TableName() string { + return "chat_participants" +} + +type ChatMessage struct { + ID uint64 `gorm:"primaryKey" json:"id"` + ConversationID uint64 `gorm:"not null;index" json:"conversation_id"` + SenderType string `gorm:"size:16;not null" json:"sender_type"` + SenderID uint64 `gorm:"not null;default:0" json:"sender_id"` + SenderRole string `gorm:"size:32;not null;default:''" json:"sender_role"` + ContentType string `gorm:"size:32;not null;default:'text'" json:"content_type"` + Content string `json:"content"` + AttachmentURLS datatypes.JSON `gorm:"column:attachment_urls" json:"attachment_urls"` + CreatedAt time.Time `json:"created_at"` +} + +func (ChatMessage) TableName() string { + return "chat_messages" +} diff --git a/backend/internal/modules/chat/dto.go b/backend/internal/modules/chat/dto.go new file mode 100644 index 0000000..d8397d1 --- /dev/null +++ b/backend/internal/modules/chat/dto.go @@ -0,0 +1,62 @@ +package chat + +import "time" + +type Principal struct { + Type string + ID uint64 +} + +type ConversationDTO struct { + ID uint64 `json:"id"` + OrderID uint64 `json:"order_id"` + Type string `json:"type"` + Title string `json:"title"` + Status string `json:"status"` + Role string `json:"role"` + Participants []ParticipantDTO `json:"participants,omitempty"` + LastMessageID *uint64 `json:"last_message_id"` + LastMessagePreview string `json:"last_message_preview"` + LastMessageAt *time.Time `json:"last_message_at"` + UnreadCount int64 `json:"unread_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ParticipantDTO struct { + ID uint64 `json:"id"` + ConversationID uint64 `json:"conversation_id"` + ParticipantType string `json:"participant_type"` + ParticipantID uint64 `json:"participant_id"` + Role string `json:"role"` + DisplayName string `json:"display_name"` + AvatarURL string `json:"avatar_url"` + LastReadAt *time.Time `json:"last_read_at"` + JoinedAt time.Time `json:"joined_at"` +} + +type MessageDTO struct { + ID uint64 `json:"id"` + ConversationID uint64 `json:"conversation_id"` + SenderType string `json:"sender_type"` + SenderID uint64 `json:"sender_id"` + SenderRole string `json:"sender_role"` + SenderName string `json:"sender_name"` + SenderAvatar string `json:"sender_avatar"` + IsSelf bool `json:"is_self"` + ContentType string `json:"content_type"` + Content string `json:"content"` + AttachmentURLS []string `json:"attachment_urls"` + CreatedAt time.Time `json:"created_at"` +} + +type SendMessageRequest struct { + Content string `json:"content" binding:"required"` +} + +type PaginatedResult struct { + Items interface{} `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} diff --git a/backend/internal/modules/chat/handler.go b/backend/internal/modules/chat/handler.go new file mode 100644 index 0000000..ce1e464 --- /dev/null +++ b/backend/internal/modules/chat/handler.go @@ -0,0 +1,243 @@ +package chat + +import ( + "errors" + "net/http" + "strconv" + + "hfb_sys/backend/internal/middleware" + "hfb_sys/backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +func (h *Handler) List(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + h.list(c, Principal{Type: "user", ID: userID}) +} + +func (h *Handler) AdminList(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + h.list(c, Principal{Type: "admin", ID: adminID}) +} + +func (h *Handler) Detail(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + h.detail(c, Principal{Type: "user", ID: userID}) +} + +func (h *Handler) AdminDetail(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + h.detail(c, Principal{Type: "admin", ID: adminID}) +} + +func (h *Handler) OrderConversation(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + orderID, ok := parseID(c, "id") + if !ok { + return + } + item, err := h.service.FindOrderConversation(userID, orderID) + if err != nil { + writeChatError(c, err) + return + } + response.OK(c, item) +} + +func (h *Handler) Messages(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + h.messages(c, Principal{Type: "user", ID: userID}) +} + +func (h *Handler) AdminMessages(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + h.messages(c, Principal{Type: "admin", ID: adminID}) +} + +func (h *Handler) Send(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + h.send(c, Principal{Type: "user", ID: userID}) +} + +func (h *Handler) AdminSend(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + h.send(c, Principal{Type: "admin", ID: adminID}) +} + +func (h *Handler) MarkRead(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + h.markRead(c, Principal{Type: "user", ID: userID}) +} + +func (h *Handler) AdminMarkRead(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + h.markRead(c, Principal{Type: "admin", ID: adminID}) +} + +func (h *Handler) list(c *gin.Context, principal Principal) { + page, pageSize := parsePagination(c) + result, err := h.service.ListConversations(principal, page, pageSize) + if err != nil { + writeChatError(c, err) + return + } + response.OK(c, result) +} + +func (h *Handler) detail(c *gin.Context, principal Principal) { + id, ok := parseID(c, "id") + if !ok { + return + } + item, err := h.service.FindConversation(principal, id) + if err != nil { + writeChatError(c, err) + return + } + response.OK(c, item) +} + +func (h *Handler) messages(c *gin.Context, principal Principal) { + id, ok := parseID(c, "id") + if !ok { + return + } + page, pageSize := parsePagination(c) + result, err := h.service.Messages(principal, id, page, pageSize) + if err != nil { + writeChatError(c, err) + return + } + response.OK(c, result) +} + +func (h *Handler) send(c *gin.Context, principal Principal) { + id, ok := parseID(c, "id") + if !ok { + return + } + var req SendMessageRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "消息内容不能为空") + return + } + message, err := h.service.SendMessage(principal, id, req) + if err != nil { + writeChatError(c, err) + return + } + response.Created(c, message) +} + +func (h *Handler) markRead(c *gin.Context, principal Principal) { + id, ok := parseID(c, "id") + if !ok { + return + } + if err := h.service.MarkRead(principal, id); err != nil { + writeChatError(c, err) + return + } + response.OK(c, gin.H{"read": true}) +} + +func parsePagination(c *gin.Context) (int, int) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + return normalizePagination(page, pageSize) +} + +func parseID(c *gin.Context, key string) (uint64, bool) { + id, err := strconv.ParseUint(c.Param(key), 10, 64) + if err != nil || id == 0 { + response.BadRequest(c, "ID 不正确") + return 0, false + } + return id, true +} + +func currentUserID(c *gin.Context) (uint64, bool) { + value, ok := c.Get(middleware.ContextUserID) + if !ok { + return 0, false + } + userID, ok := value.(uint64) + return userID, ok +} + +func currentAdminID(c *gin.Context) (uint64, bool) { + value, ok := c.Get(middleware.ContextAdminID) + if !ok { + return 0, false + } + adminID, ok := value.(uint64) + return adminID, ok +} + +func writeChatError(c *gin.Context, err error) { + switch { + case errors.Is(err, ErrDependencyUnavailable): + response.ServiceUnavailable(c, "聊天服务暂时不可用") + case errors.Is(err, ErrConversationNotFound): + response.Error(c, http.StatusNotFound, "chat_not_found", "会话不存在") + case errors.Is(err, ErrPermissionDenied): + response.Error(c, http.StatusForbidden, "permission_denied", "无权访问该会话") + case errors.Is(err, ErrInvalidMessage): + response.BadRequest(c, "消息内容不符合规则") + default: + response.Error(c, http.StatusInternalServerError, "internal_error", "聊天服务暂时不可用") + } +} diff --git a/backend/internal/modules/chat/repository.go b/backend/internal/modules/chat/repository.go new file mode 100644 index 0000000..b46fc42 --- /dev/null +++ b/backend/internal/modules/chat/repository.go @@ -0,0 +1,581 @@ +package chat + +import ( + "encoding/json" + "errors" + "fmt" + "strconv" + "time" + + "hfb_sys/backend/internal/model" + + "golang.org/x/crypto/bcrypt" + "gorm.io/datatypes" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type Repository struct { + db *gorm.DB +} + +const ( + defaultSupportUsername = "admin" + defaultSupportPassword = "admin123456" + defaultSupportNickname = "超级管理员" +) + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +func EnsureOrderConversation(tx *gorm.DB, order model.RentalOrder) (*model.ChatConversation, error) { + var existing model.ChatConversation + err := tx.Where("order_id = ?", order.ID).First(&existing).Error + if err == nil { + return &existing, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + + now := time.Now() + conversation := model.ChatConversation{ + OrderID: order.ID, + Type: "order_group", + Title: orderConversationTitle(order), + Status: "active", + } + if err := tx.Create(&conversation).Error; err != nil { + return nil, err + } + + participants := []model.ChatParticipant{ + { + ConversationID: conversation.ID, + ParticipantType: "user", + ParticipantID: order.RenterID, + Role: "renter", + JoinedAt: now, + }, + { + ConversationID: conversation.ID, + ParticipantType: "user", + ParticipantID: order.OwnerID, + Role: "owner", + JoinedAt: now, + }, + } + if supportID := defaultSupportAdminID(tx); supportID > 0 { + participants = append(participants, model.ChatParticipant{ + ConversationID: conversation.ID, + ParticipantType: "admin", + ParticipantID: supportID, + Role: "support", + JoinedAt: now, + }) + } + for _, participant := range participants { + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&participant).Error; err != nil { + return nil, err + } + } + + message := model.ChatMessage{ + ConversationID: conversation.ID, + SenderType: "system", + SenderRole: "system", + ContentType: "system", + Content: "订单已支付,群聊已创建。租客、号主和客服可在这里沟通交接与结账问题。", + AttachmentURLS: emptyJSONList(), + } + if err := tx.Create(&message).Error; err != nil { + return nil, err + } + conversation.LastMessageID = &message.ID + conversation.LastMessagePreview = truncatePreview(message.Content) + conversation.LastMessageAt = &message.CreatedAt + if err := tx.Save(&conversation).Error; err != nil { + return nil, err + } + return &conversation, nil +} + +func (r *Repository) ListConversations(principal Principal, page, pageSize int) (*PaginatedResult, error) { + page, pageSize = normalizePagination(page, pageSize) + var total int64 + countDB := r.db.Table("chat_conversations AS c"). + Joins("JOIN chat_participants AS cp ON cp.conversation_id = c.id"). + Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID) + if err := countDB.Count(&total).Error; err != nil { + return nil, err + } + + var rows []conversationRow + offset := (page - 1) * pageSize + err := r.conversationQuery(principal). + Order("COALESCE(c.last_message_at, c.created_at) DESC, c.id DESC"). + Offset(offset). + Limit(pageSize). + Scan(&rows).Error + if err != nil { + return nil, err + } + items := make([]ConversationDTO, 0, len(rows)) + for _, row := range rows { + items = append(items, row.toDTO(nil)) + } + return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil +} + +func (r *Repository) FindConversation(principal Principal, id uint64) (*ConversationDTO, error) { + var row conversationRow + err := r.conversationQuery(principal).Where("c.id = ?", id).First(&row).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrConversationNotFound + } + return nil, err + } + participants, err := r.participants(row.ID) + if err != nil { + return nil, err + } + dto := row.toDTO(participants) + return &dto, nil +} + +func (r *Repository) FindOrderConversation(userID uint64, orderID uint64) (*ConversationDTO, error) { + var row conversationRow + principal := Principal{Type: "user", ID: userID} + err := r.conversationQuery(principal).Where("c.order_id = ?", orderID).First(&row).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrConversationNotFound + } + return nil, err + } + participants, err := r.participants(row.ID) + if err != nil { + return nil, err + } + dto := row.toDTO(participants) + return &dto, nil +} + +func (r *Repository) Messages(principal Principal, conversationID uint64, page, pageSize int) (*PaginatedResult, error) { + page, pageSize = normalizePagination(page, pageSize) + if _, err := r.findParticipant(r.db, principal, conversationID, false); err != nil { + return nil, err + } + var total int64 + if err := r.db.Model(&model.ChatMessage{}).Where("conversation_id = ?", conversationID).Count(&total).Error; err != nil { + return nil, err + } + offset := (page - 1) * pageSize + var rows []model.ChatMessage + if err := r.db.Where("conversation_id = ?", conversationID). + Order("id ASC"). + Offset(offset). + Limit(pageSize). + Find(&rows).Error; err != nil { + return nil, err + } + items, err := r.toMessageDTOs(principal, rows) + if err != nil { + return nil, err + } + return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil +} + +func (r *Repository) SendMessage(principal Principal, conversationID uint64, req SendMessageRequest) (*MessageDTO, error) { + var messageID uint64 + err := r.db.Transaction(func(tx *gorm.DB) error { + participant, err := r.findParticipant(tx, principal, conversationID, true) + if err != nil { + return err + } + var conversation model.ChatConversation + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&conversation, conversationID).Error; err != nil { + return err + } + if conversation.Status != "active" { + return ErrPermissionDenied + } + message := model.ChatMessage{ + ConversationID: conversation.ID, + SenderType: principal.Type, + SenderID: principal.ID, + SenderRole: participant.Role, + ContentType: "text", + Content: req.Content, + AttachmentURLS: emptyJSONList(), + } + if err := tx.Create(&message).Error; err != nil { + return err + } + conversation.LastMessageID = &message.ID + conversation.LastMessagePreview = truncatePreview(message.Content) + conversation.LastMessageAt = &message.CreatedAt + if err := tx.Save(&conversation).Error; err != nil { + return err + } + now := time.Now() + if err := tx.Model(participant).Update("last_read_at", now).Error; err != nil { + return err + } + messageID = message.ID + return nil + }) + if err != nil { + return nil, err + } + var message model.ChatMessage + if err := r.db.First(&message, messageID).Error; err != nil { + return nil, err + } + items, err := r.toMessageDTOs(principal, []model.ChatMessage{message}) + if err != nil { + return nil, err + } + if len(items) == 0 { + return nil, ErrConversationNotFound + } + return &items[0], nil +} + +func (r *Repository) MarkRead(principal Principal, conversationID uint64) error { + return r.db.Transaction(func(tx *gorm.DB) error { + participant, err := r.findParticipant(tx, principal, conversationID, true) + if err != nil { + return err + } + now := time.Now() + return tx.Model(participant).Update("last_read_at", now).Error + }) +} + +func (r *Repository) conversationQuery(principal Principal) *gorm.DB { + return r.db.Table("chat_conversations AS c"). + Select(`c.id, c.order_id, c.type, c.title, c.status, c.last_message_id, + c.last_message_preview, c.last_message_at, c.created_at, c.updated_at, cp.role, + ( + SELECT COUNT(1) + FROM chat_messages AS cm + WHERE cm.conversation_id = c.id + AND NOT (cm.sender_type = ? AND cm.sender_id = ?) + AND (cp.last_read_at IS NULL OR cm.created_at > cp.last_read_at) + ) AS unread_count`, principal.Type, principal.ID). + Joins("JOIN chat_participants AS cp ON cp.conversation_id = c.id"). + Where("cp.participant_type = ? AND cp.participant_id = ?", principal.Type, principal.ID) +} + +func (r *Repository) findParticipant(tx *gorm.DB, principal Principal, conversationID uint64, lock bool) (*model.ChatParticipant, error) { + var participant model.ChatParticipant + db := tx + if lock { + db = db.Clauses(clause.Locking{Strength: "UPDATE"}) + } + err := db.Where("conversation_id = ? AND participant_type = ? AND participant_id = ?", conversationID, principal.Type, principal.ID). + First(&participant).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrPermissionDenied + } + return nil, err + } + return &participant, nil +} + +func (r *Repository) participants(conversationID uint64) ([]ParticipantDTO, error) { + var rows []model.ChatParticipant + if err := r.db.Where("conversation_id = ?", conversationID).Order("id ASC").Find(&rows).Error; err != nil { + return nil, err + } + userNames, userAvatars, adminNames, err := r.participantNames(rows) + if err != nil { + return nil, err + } + items := make([]ParticipantDTO, 0, len(rows)) + for _, row := range rows { + name := "系统" + avatar := "" + if row.ParticipantType == "user" { + name = userNames[row.ParticipantID] + avatar = userAvatars[row.ParticipantID] + } + if row.ParticipantType == "admin" { + name = adminNames[row.ParticipantID] + } + items = append(items, ParticipantDTO{ + ID: row.ID, + ConversationID: row.ConversationID, + ParticipantType: row.ParticipantType, + ParticipantID: row.ParticipantID, + Role: row.Role, + DisplayName: fallbackName(row.ParticipantType, row.ParticipantID, name), + AvatarURL: avatar, + LastReadAt: row.LastReadAt, + JoinedAt: row.JoinedAt, + }) + } + return items, nil +} + +func (r *Repository) toMessageDTOs(principal Principal, rows []model.ChatMessage) ([]MessageDTO, error) { + userIDs := make([]uint64, 0) + adminIDs := make([]uint64, 0) + for _, row := range rows { + if row.SenderType == "user" && row.SenderID > 0 { + userIDs = append(userIDs, row.SenderID) + } + if row.SenderType == "admin" && row.SenderID > 0 { + adminIDs = append(adminIDs, row.SenderID) + } + } + userNames, userAvatars, err := r.userNames(userIDs) + if err != nil { + return nil, err + } + adminNames, err := r.adminNames(adminIDs) + if err != nil { + return nil, err + } + items := make([]MessageDTO, 0, len(rows)) + for _, row := range rows { + name := "系统" + avatar := "" + if row.SenderType == "user" { + name = userNames[row.SenderID] + avatar = userAvatars[row.SenderID] + } + if row.SenderType == "admin" { + name = adminNames[row.SenderID] + } + items = append(items, MessageDTO{ + ID: row.ID, + ConversationID: row.ConversationID, + SenderType: row.SenderType, + SenderID: row.SenderID, + SenderRole: row.SenderRole, + SenderName: fallbackName(row.SenderType, row.SenderID, name), + SenderAvatar: avatar, + IsSelf: row.SenderType == principal.Type && row.SenderID == principal.ID, + ContentType: row.ContentType, + Content: row.Content, + AttachmentURLS: decodeStringList(row.AttachmentURLS), + CreatedAt: row.CreatedAt, + }) + } + return items, nil +} + +func (r *Repository) participantNames(rows []model.ChatParticipant) (map[uint64]string, map[uint64]string, map[uint64]string, error) { + userIDs := make([]uint64, 0) + adminIDs := make([]uint64, 0) + for _, row := range rows { + if row.ParticipantType == "user" { + userIDs = append(userIDs, row.ParticipantID) + } + if row.ParticipantType == "admin" { + adminIDs = append(adminIDs, row.ParticipantID) + } + } + userNames, userAvatars, err := r.userNames(userIDs) + if err != nil { + return nil, nil, nil, err + } + adminNames, err := r.adminNames(adminIDs) + if err != nil { + return nil, nil, nil, err + } + return userNames, userAvatars, adminNames, nil +} + +func (r *Repository) userNames(ids []uint64) (map[uint64]string, map[uint64]string, error) { + names := map[uint64]string{} + avatars := map[uint64]string{} + if len(ids) == 0 { + return names, avatars, nil + } + var users []model.User + if err := r.db.Where("id IN ?", uniqueIDs(ids)).Find(&users).Error; err != nil { + return nil, nil, err + } + for _, user := range users { + name := user.Nickname + if name == "" { + name = user.Phone + } + names[user.ID] = name + avatars[user.ID] = user.AvatarURL + } + return names, avatars, nil +} + +func (r *Repository) adminNames(ids []uint64) (map[uint64]string, error) { + names := map[uint64]string{} + if len(ids) == 0 { + return names, nil + } + var admins []model.AdminUser + if err := r.db.Where("id IN ?", uniqueIDs(ids)).Find(&admins).Error; err != nil { + return nil, err + } + for _, admin := range admins { + name := admin.Nickname + if name == "" { + name = admin.Username + } + names[admin.ID] = name + } + return names, nil +} + +type conversationRow struct { + ID uint64 + OrderID uint64 + Type string + Title string + Status string + Role string + LastMessageID *uint64 + LastMessagePreview string + LastMessageAt *time.Time + UnreadCount int64 + CreatedAt time.Time + UpdatedAt time.Time +} + +func (row conversationRow) toDTO(participants []ParticipantDTO) ConversationDTO { + return ConversationDTO{ + ID: row.ID, + OrderID: row.OrderID, + Type: row.Type, + Title: row.Title, + Status: row.Status, + Role: row.Role, + Participants: participants, + LastMessageID: row.LastMessageID, + LastMessagePreview: row.LastMessagePreview, + LastMessageAt: row.LastMessageAt, + UnreadCount: row.UnreadCount, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } +} + +func defaultSupportAdminID(tx *gorm.DB) uint64 { + var cfg model.SystemConfig + if err := tx.Where("`key` = ?", "chat.default_support_admin_id").First(&cfg).Error; err == nil { + id, parseErr := strconv.ParseUint(cfg.Value, 10, 64) + if parseErr == nil && id > 0 && adminActive(tx, id) { + return id + } + } + var admin model.AdminUser + if err := tx.Where("status = ?", "active").Order("id ASC").First(&admin).Error; err != nil { + return ensureDefaultSupportAdmin(tx) + } + return admin.ID +} + +func ensureDefaultSupportAdmin(tx *gorm.DB) uint64 { + var count int64 + if err := tx.Model(&model.AdminUser{}).Count(&count).Error; err != nil || count > 0 { + return 0 + } + hash, err := bcrypt.GenerateFromPassword([]byte(defaultSupportPassword), bcrypt.DefaultCost) + if err != nil { + return 0 + } + admin := model.AdminUser{ + Username: defaultSupportUsername, + PasswordHash: string(hash), + Nickname: defaultSupportNickname, + Status: "active", + } + if err := tx.Create(&admin).Error; err != nil { + return 0 + } + return admin.ID +} + +func adminActive(tx *gorm.DB, id uint64) bool { + var count int64 + if err := tx.Model(&model.AdminUser{}).Where("id = ? AND status = ?", id, "active").Count(&count).Error; err != nil { + return false + } + return count > 0 +} + +func orderConversationTitle(order model.RentalOrder) string { + if order.OrderNo == "" { + return fmt.Sprintf("订单群聊 #%d", order.ID) + } + return "订单群聊 " + order.OrderNo +} + +func normalizePagination(page, pageSize int) (int, int) { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + if pageSize > 100 { + pageSize = 100 + } + return page, pageSize +} + +func truncatePreview(content string) string { + runes := []rune(content) + if len(runes) <= 80 { + return content + } + return string(runes[:80]) +} + +func fallbackName(participantType string, id uint64, name string) string { + if name != "" { + return name + } + switch participantType { + case "admin": + return "客服" + case "system": + return "系统" + default: + return fmt.Sprintf("用户%d", id) + } +} + +func uniqueIDs(ids []uint64) []uint64 { + seen := map[uint64]bool{} + result := make([]uint64, 0, len(ids)) + for _, id := range ids { + if id == 0 || seen[id] { + continue + } + seen[id] = true + result = append(result, id) + } + return result +} + +func emptyJSONList() datatypes.JSON { + raw, _ := json.Marshal([]string{}) + return datatypes.JSON(raw) +} + +func decodeStringList(raw datatypes.JSON) []string { + if len(raw) == 0 { + return []string{} + } + var items []string + if err := json.Unmarshal(raw, &items); err != nil { + return []string{} + } + return items +} diff --git a/backend/internal/modules/chat/service.go b/backend/internal/modules/chat/service.go new file mode 100644 index 0000000..b477298 --- /dev/null +++ b/backend/internal/modules/chat/service.go @@ -0,0 +1,70 @@ +package chat + +import ( + "errors" + "strings" +) + +var ( + ErrDependencyUnavailable = errors.New("dependency unavailable") + ErrConversationNotFound = errors.New("conversation not found") + ErrPermissionDenied = errors.New("permission denied") + ErrInvalidMessage = errors.New("invalid message") +) + +type Service struct { + repo *Repository +} + +func NewService(repo *Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) ListConversations(principal Principal, page, pageSize int) (*PaginatedResult, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.ListConversations(principal, page, pageSize) +} + +func (s *Service) FindConversation(principal Principal, id uint64) (*ConversationDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.FindConversation(principal, id) +} + +func (s *Service) FindOrderConversation(userID uint64, orderID uint64) (*ConversationDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.FindOrderConversation(userID, orderID) +} + +func (s *Service) Messages(principal Principal, conversationID uint64, page, pageSize int) (*PaginatedResult, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.Messages(principal, conversationID, page, pageSize) +} + +func (s *Service) SendMessage(principal Principal, conversationID uint64, req SendMessageRequest) (*MessageDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + req.Content = strings.TrimSpace(req.Content) + if conversationID == 0 || req.Content == "" { + return nil, ErrInvalidMessage + } + if len([]rune(req.Content)) > 1000 { + return nil, ErrInvalidMessage + } + return s.repo.SendMessage(principal, conversationID, req) +} + +func (s *Service) MarkRead(principal Principal, conversationID uint64) error { + if s.repo == nil { + return ErrDependencyUnavailable + } + return s.repo.MarkRead(principal, conversationID) +} diff --git a/backend/internal/modules/order/repository.go b/backend/internal/modules/order/repository.go index 0575959..5f3c5f9 100644 --- a/backend/internal/modules/order/repository.go +++ b/backend/internal/modules/order/repository.go @@ -10,6 +10,7 @@ import ( "time" "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/chat" "hfb_sys/backend/internal/modules/notification" "hfb_sys/backend/internal/modules/wallet" @@ -237,6 +238,9 @@ func (r *Repository) Pay(userID uint64, orderID uint64) error { order.HandoffStatus = "pending_owner" listing.Status = "rented" account.Status = "rented" + if _, err := chat.EnsureOrderConversation(tx, order); err != nil { + return err + } orderID := order.ID if err := notification.Append(tx, notification.Entry{ diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 8fd9d63..d52b428 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -9,6 +9,7 @@ import ( "hfb_sys/backend/internal/modules/admindashboard" "hfb_sys/backend/internal/modules/adminuser" "hfb_sys/backend/internal/modules/auth" + "hfb_sys/backend/internal/modules/chat" "hfb_sys/backend/internal/modules/dispute" filemodule "hfb_sys/backend/internal/modules/file" "hfb_sys/backend/internal/modules/listing" @@ -95,6 +96,12 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { } notificationService := notification.NewService(notificationRepo) notificationHandler := notification.NewHandler(notificationService) + var chatRepo *chat.Repository + if deps.DB != nil { + chatRepo = chat.NewRepository(deps.DB) + } + chatService := chat.NewService(chatRepo) + chatHandler := chat.NewHandler(chatService) var disputeRepo *dispute.Repository if deps.DB != nil { disputeRepo = dispute.NewRepository(deps.DB) @@ -166,6 +173,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { orderRoutes.POST("", orderHandler.Create) orderRoutes.GET("", orderHandler.List) orderRoutes.GET("/:id", orderHandler.Detail) + orderRoutes.GET("/:id/chat", chatHandler.OrderConversation) orderRoutes.POST("/:id/pay", orderHandler.Pay) orderRoutes.POST("/:id/cancel", orderHandler.Cancel) orderRoutes.POST("/:id/handoff", orderHandler.SubmitHandoff) @@ -205,6 +213,15 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { notificationRoutes.POST("/:id/read", notificationHandler.MarkRead) } + chatRoutes := api.Group("/chats", requireAuth) + { + chatRoutes.GET("", chatHandler.List) + chatRoutes.GET("/:id", chatHandler.Detail) + chatRoutes.GET("/:id/messages", chatHandler.Messages) + chatRoutes.POST("/:id/messages", chatHandler.Send) + chatRoutes.POST("/:id/read", chatHandler.MarkRead) + } + realnameRoutes := api.Group("/realname", requireAuth) { realnameRoutes.POST("/start", realnameHandler.Start) @@ -246,6 +263,11 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.GET("/system-configs", systemConfigHandler.List) adminRoutes.PUT("/system-configs/:key", systemConfigHandler.Update) adminRoutes.GET("/audit-logs", adminAuditHandler.List) + adminRoutes.GET("/chats", chatHandler.AdminList) + adminRoutes.GET("/chats/:id", chatHandler.AdminDetail) + adminRoutes.GET("/chats/:id/messages", chatHandler.AdminMessages) + adminRoutes.POST("/chats/:id/messages", chatHandler.AdminSend) + adminRoutes.POST("/chats/:id/read", chatHandler.AdminMarkRead) } } diff --git a/backend/migrations/000002_chat.sql b/backend/migrations/000002_chat.sql new file mode 100644 index 0000000..12e7f1a --- /dev/null +++ b/backend/migrations/000002_chat.sql @@ -0,0 +1,47 @@ +CREATE TABLE IF NOT EXISTS chat_conversations ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + order_id BIGINT UNSIGNED NOT NULL, + type VARCHAR(32) NOT NULL DEFAULT 'order_group', + title VARCHAR(128) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'active', + last_message_id BIGINT UNSIGNED NULL, + last_message_preview VARCHAR(255) NOT NULL DEFAULT '', + last_message_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_chat_conversations_order_id (order_id), + KEY idx_chat_conversations_last_message_at (last_message_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS chat_participants ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + conversation_id BIGINT UNSIGNED NOT NULL, + participant_type VARCHAR(16) NOT NULL, + participant_id BIGINT UNSIGNED NOT NULL, + role VARCHAR(32) NOT NULL, + last_read_at DATETIME NULL, + joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_chat_participants_member (conversation_id, participant_type, participant_id), + KEY idx_chat_participants_participant (participant_type, participant_id), + KEY idx_chat_participants_conversation (conversation_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS chat_messages ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + conversation_id BIGINT UNSIGNED NOT NULL, + sender_type VARCHAR(16) NOT NULL, + sender_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + sender_role VARCHAR(32) NOT NULL DEFAULT '', + content_type VARCHAR(32) NOT NULL DEFAULT 'text', + content TEXT NULL, + attachment_urls JSON NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_chat_messages_conversation_id (conversation_id, id), + KEY idx_chat_messages_created_at (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO system_configs (`key`, `value`, description) +VALUES ('chat.default_support_admin_id', '1', '订单群聊默认接入的客服管理员 ID') +ON DUPLICATE KEY UPDATE description = VALUES(description); diff --git a/frontend/src/api/chats.ts b/frontend/src/api/chats.ts new file mode 100644 index 0000000..69c9954 --- /dev/null +++ b/frontend/src/api/chats.ts @@ -0,0 +1,108 @@ +import { apiClient } from './client' +import type { ApiResponse, PaginatedResult } from './types' + +export interface ChatParticipant { + id: number + conversation_id: number + participant_type: 'user' | 'admin' + participant_id: number + role: 'renter' | 'owner' | 'support' + display_name: string + avatar_url: string + last_read_at?: string + joined_at: string +} + +export interface ChatConversation { + id: number + order_id: number + type: string + title: string + status: string + role: 'renter' | 'owner' | 'support' + participants?: ChatParticipant[] + last_message_id?: number + last_message_preview: string + last_message_at?: string + unread_count: number + created_at: string + updated_at: string +} + +export interface ChatMessage { + id: number + conversation_id: number + sender_type: 'user' | 'admin' | 'system' + sender_id: number + sender_role: 'renter' | 'owner' | 'support' | 'system' + sender_name: string + sender_avatar: string + is_self: boolean + content_type: 'text' | 'system' + content: string + attachment_urls: string[] + created_at: string +} + +export async function fetchChats(page = 1, pageSize = 20) { + const { data } = await apiClient.get>>('/chats', { + params: { page, page_size: pageSize }, + }) + return data.data +} + +export async function fetchChat(id: number) { + const { data } = await apiClient.get>(`/chats/${id}`) + return data.data +} + +export async function fetchOrderChat(orderId: number) { + const { data } = await apiClient.get>(`/orders/${orderId}/chat`) + return data.data +} + +export async function fetchChatMessages(id: number, page = 1, pageSize = 100) { + const { data } = await apiClient.get>>(`/chats/${id}/messages`, { + params: { page, page_size: pageSize }, + }) + return data.data +} + +export async function sendChatMessage(id: number, content: string) { + const { data } = await apiClient.post>(`/chats/${id}/messages`, { content }) + return data.data +} + +export async function markChatRead(id: number) { + const { data } = await apiClient.post>(`/chats/${id}/read`) + return data.data +} + +export async function fetchAdminChats(page = 1, pageSize = 50) { + const { data } = await apiClient.get>>('/admin/chats', { + params: { page, page_size: pageSize }, + }) + return data.data +} + +export async function fetchAdminChat(id: number) { + const { data } = await apiClient.get>(`/admin/chats/${id}`) + return data.data +} + +export async function fetchAdminChatMessages(id: number, page = 1, pageSize = 100) { + const { data } = await apiClient.get>>(`/admin/chats/${id}/messages`, { + params: { page, page_size: pageSize }, + }) + return data.data +} + +export async function sendAdminChatMessage(id: number, content: string) { + const { data } = await apiClient.post>(`/admin/chats/${id}/messages`, { content }) + return data.data +} + +export async function markAdminChatRead(id: number) { + const { data } = await apiClient.post>(`/admin/chats/${id}/read`) + return data.data +} diff --git a/frontend/src/layouts/AdminLayout.vue b/frontend/src/layouts/AdminLayout.vue index 01734bc..556b438 100644 --- a/frontend/src/layouts/AdminLayout.vue +++ b/frontend/src/layouts/AdminLayout.vue @@ -1,5 +1,5 @@ + + + + diff --git a/frontend/src/views/mobile/MobileChatView.vue b/frontend/src/views/mobile/MobileChatView.vue new file mode 100644 index 0000000..51db75e --- /dev/null +++ b/frontend/src/views/mobile/MobileChatView.vue @@ -0,0 +1,338 @@ + + + + + diff --git a/frontend/src/views/mobile/MobileMessagesView.vue b/frontend/src/views/mobile/MobileMessagesView.vue index 2d591e1..1b7702c 100644 --- a/frontend/src/views/mobile/MobileMessagesView.vue +++ b/frontend/src/views/mobile/MobileMessagesView.vue @@ -1,47 +1,41 @@ @@ -188,163 +135,182 @@ function getNotificationBadge(type: string) { diff --git a/frontend/src/views/mobile/MobileOrderDetailView.vue b/frontend/src/views/mobile/MobileOrderDetailView.vue index 082b793..2f18b0f 100644 --- a/frontend/src/views/mobile/MobileOrderDetailView.vue +++ b/frontend/src/views/mobile/MobileOrderDetailView.vue @@ -3,6 +3,7 @@ import { computed, onMounted, ref } from "vue"; import { useRoute, useRouter } from "vue-router"; import { showToast, showDialog } from "vant"; +import { fetchOrderChat } from "@/api/chats"; import { createDispute } from "@/api/disputes"; import { uploadFile } from "@/api/files"; import { @@ -38,6 +39,7 @@ const acceptingCheckout = ref(false); const rejectingCheckout = ref(false); const disputing = ref(false); const uploadingEvidence = ref(false); +const openingChat = ref(false); const order = ref(null); const handoffRecords = ref([]); const handoffContent = ref(""); @@ -506,6 +508,19 @@ function linesToList(value: string) { .filter(Boolean); } +async function openOrderChat() { + if (!order.value || openingChat.value) return; + openingChat.value = true; + try { + const chat = await fetchOrderChat(order.value.id); + router.push(`/m/chats/${chat.id}`); + } catch { + showToast({ message: "订单群聊暂不可用", icon: "warning-o" }); + } finally { + openingChat.value = false; + } +} + /* Status Theme Color Mapping */ function getStatusTagType(status: string) { if (["completed", "received"].includes(status)) return "success"; @@ -523,7 +538,10 @@ function getStatusTagType(status: string) {

订单详情

- + + @@ -1070,7 +1088,8 @@ function getStatusTagType(status: string) { text-align: center; } -.back-btn { +.back-btn, +.chat-btn { display: grid; width: 36px; height: 36px; @@ -1081,6 +1100,10 @@ function getStatusTagType(status: string) { cursor: pointer; } +.chat-btn:disabled { + color: #a1a1aa; +} + .header-spacer { width: 36px; } diff --git a/scripts/dev.sh b/scripts/dev.sh index f1efdaf..29528f8 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -71,9 +71,23 @@ init_database() { if [[ "${table_count}" == "0" ]]; then log "首次启动,初始化数据库结构..." - docker exec -i hfb-mysql mysql -uhfb -psecret hfb_sys < "${ROOT_DIR}/backend/migrations/000001_init.sql" + local migration + for migration in "${ROOT_DIR}"/backend/migrations/*.sql; do + log "执行迁移:$(basename "${migration}")" + docker exec -i hfb-mysql mysql -uhfb -psecret hfb_sys < "${migration}" + done else - log "数据库结构已存在,跳过迁移" + local chat_table_count + chat_table_count="$( + docker exec hfb-mysql mysql -uhfb -psecret -N -s -e \ + "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'hfb_sys' AND table_name = 'chat_conversations';" 2>/dev/null + )" + if [[ "${chat_table_count}" == "0" ]]; then + log "补充聊天表迁移:000002_chat.sql" + docker exec -i hfb-mysql mysql -uhfb -psecret hfb_sys < "${ROOT_DIR}/backend/migrations/000002_chat.sql" + else + log "数据库结构已存在,跳过迁移" + fi fi }