提交1: 数据层改造 - 发布群与企业微信群二维码

- 新增数据库迁移 000008: chat_conversations 增加 listing_id, 新建 chat_qrcode_pool 表
- 更新 GORM 模型: ChatConversation 新增 ListingID 字段, 新增 ChatQrCode 模型
- 实现二维码池管理后端 API: 创建/列表/统计/更新/删除
- 新增管理后台路由: /api/admin/chats/qrcodes
- 新增系统配置: chat.listing_group_welcome, chat.qrcode_low_stock_threshold
- 更新优化方案文档: 废弃双群方案, 采用单群方案

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
yml
2026-06-17 15:57:10 +08:00
co-authored by Claude Opus 4.8
parent 87483ea6a8
commit 423c142967
7 changed files with 989 additions and 1 deletions
+1 -1
View File
@@ -11,6 +11,7 @@ require (
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/minio/minio-go/v7 v7.1.0
github.com/redis/go-redis/v9 v9.17.0
github.com/sony/gobreaker/v2 v2.4.0
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.1
github.com/swaggo/swag v1.16.6
@@ -76,7 +77,6 @@ require (
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.1 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/sony/gobreaker/v2 v2.4.0 // indirect
github.com/tinylib/msgp v1.6.1 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
+18
View File
@@ -9,6 +9,7 @@ import (
type ChatConversation struct {
ID uint64 `gorm:"primaryKey" json:"id"`
OrderID *uint64 `gorm:"uniqueIndex" json:"order_id"`
ListingID *uint64 `gorm:"index" json:"listing_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"`
@@ -55,3 +56,20 @@ type ChatMessage struct {
func (ChatMessage) TableName() string {
return "chat_messages"
}
type ChatQrCode struct {
ID uint64 `gorm:"primaryKey" json:"id"`
ImageURL string `gorm:"size:512;not null" json:"image_url"`
Status string `gorm:"size:16;not null;default:'unused'" json:"status"`
ConversationID *uint64 `gorm:"index" json:"conversation_id"`
UsedAt *time.Time `json:"used_at"`
ExpiresAt *time.Time `json:"expires_at"`
CreatedBy uint64 `gorm:"not null" json:"created_by"`
Note string `gorm:"size:255;not null;default:''" json:"note"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (ChatQrCode) TableName() string {
return "chat_qrcode_pool"
}
@@ -0,0 +1,111 @@
package chat
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
// CreateQrCodeHandler 创建二维码
func (h *Handler) CreateQrCodeHandler(c *gin.Context) {
var req CreateQrCodeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
adminID := c.GetUint64("admin_id")
qrcode, err := h.service.repo.CreateQrCode(c.Request.Context(), adminID, req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": qrcode})
}
// ListQrCodesHandler 列表查询二维码
func (h *Handler) ListQrCodesHandler(c *gin.Context) {
var req QrCodeListRequest
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
qrcodes, total, err := h.service.repo.ListQrCodes(c.Request.Context(), req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"data": qrcodes,
"pagination": gin.H{
"total": total,
"page": req.Page,
"limit": req.Limit,
},
})
}
// GetQrCodeStatsHandler 获取二维码统计
func (h *Handler) GetQrCodeStatsHandler(c *gin.Context) {
stats, err := h.service.repo.GetQrCodeStats(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": stats})
}
// UpdateQrCodeHandler 更新二维码
func (h *Handler) UpdateQrCodeHandler(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的ID"})
return
}
var req UpdateQrCodeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.service.repo.UpdateQrCode(c.Request.Context(), id, req); err != nil {
if err == ErrQrCodeNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "二维码不存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "更新成功"})
}
// DeleteQrCodeHandler 删除二维码
func (h *Handler) DeleteQrCodeHandler(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的ID"})
return
}
if err := h.service.repo.DeleteQrCode(c.Request.Context(), id); err != nil {
if err == ErrQrCodeNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "二维码不存在"})
return
}
if err == ErrQrCodeCannotDelete {
c.JSON(http.StatusBadRequest, gin.H{"error": "已使用的二维码不能删除"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
}
+226
View File
@@ -0,0 +1,226 @@
package chat
import (
"context"
"errors"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"hfb_sys/backend/internal/model"
)
// QrCodeStatus 二维码状态常量
const (
QrCodeStatusUnused = "unused"
QrCodeStatusUsed = "used"
QrCodeStatusDisabled = "disabled"
)
var (
ErrQrCodeNotFound = errors.New("二维码不存在")
ErrQrCodeCannotDelete = errors.New("已使用的二维码不能删除")
)
// CreateQrCodeRequest 创建二维码请求
type CreateQrCodeRequest struct {
ImageURL string `json:"image_url" binding:"required"`
Note string `json:"note"`
ExpiresAt *time.Time `json:"expires_at"`
}
// UpdateQrCodeRequest 更新二维码请求
type UpdateQrCodeRequest struct {
Note *string `json:"note"`
Status *string `json:"status"`
ExpiresAt *time.Time `json:"expires_at"`
}
// QrCodeListRequest 列表查询请求
type QrCodeListRequest struct {
Status string `form:"status"`
Page int `form:"page"`
Limit int `form:"limit"`
}
// QrCodeStats 二维码统计
type QrCodeStats struct {
UnusedCount int64 `json:"unused_count"`
UsedCount int64 `json:"used_count"`
DisabledCount int64 `json:"disabled_count"`
TotalCount int64 `json:"total_count"`
}
// CreateQrCode 创建二维码
func (r *Repository) CreateQrCode(ctx context.Context, adminID uint64, req CreateQrCodeRequest) (*model.ChatQrCode, error) {
qrcode := model.ChatQrCode{
ImageURL: req.ImageURL,
Status: QrCodeStatusUnused,
CreatedBy: adminID,
Note: req.Note,
ExpiresAt: req.ExpiresAt,
}
// 如果未指定过期时间,默认7天后过期
if qrcode.ExpiresAt == nil {
expires := time.Now().Add(7 * 24 * time.Hour)
qrcode.ExpiresAt = &expires
}
if err := r.db.WithContext(ctx).Create(&qrcode).Error; err != nil {
return nil, err
}
return &qrcode, nil
}
// ListQrCodes 列表查询二维码
func (r *Repository) ListQrCodes(ctx context.Context, req QrCodeListRequest) ([]model.ChatQrCode, int64, error) {
if req.Page < 1 {
req.Page = 1
}
if req.Limit < 1 || req.Limit > 100 {
req.Limit = 20
}
query := r.db.WithContext(ctx).Model(&model.ChatQrCode{})
// 状态过滤
if req.Status != "" {
query = query.Where("status = ?", req.Status)
}
// 统计总数
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// 查询列表
var qrcodes []model.ChatQrCode
offset := (req.Page - 1) * req.Limit
if err := query.Order("id DESC").Offset(offset).Limit(req.Limit).Find(&qrcodes).Error; err != nil {
return nil, 0, err
}
return qrcodes, total, nil
}
// GetQrCodeStats 获取二维码统计信息
func (r *Repository) GetQrCodeStats(ctx context.Context) (*QrCodeStats, error) {
stats := &QrCodeStats{}
// 统计各状态数量
type CountResult struct {
Status string
Count int64
}
var results []CountResult
if err := r.db.WithContext(ctx).
Model(&model.ChatQrCode{}).
Select("status, COUNT(*) as count").
Group("status").
Find(&results).Error; err != nil {
return nil, err
}
for _, r := range results {
stats.TotalCount += r.Count
switch r.Status {
case QrCodeStatusUnused:
stats.UnusedCount = r.Count
case QrCodeStatusUsed:
stats.UsedCount = r.Count
case QrCodeStatusDisabled:
stats.DisabledCount = r.Count
}
}
return stats, nil
}
// UpdateQrCode 更新二维码
func (r *Repository) UpdateQrCode(ctx context.Context, id uint64, req UpdateQrCodeRequest) error {
updates := make(map[string]interface{})
if req.Note != nil {
updates["note"] = *req.Note
}
if req.Status != nil {
// 校验状态值
if *req.Status != QrCodeStatusUnused && *req.Status != QrCodeStatusUsed && *req.Status != QrCodeStatusDisabled {
return errors.New("无效的状态值")
}
updates["status"] = *req.Status
}
if req.ExpiresAt != nil {
updates["expires_at"] = req.ExpiresAt
}
if len(updates) == 0 {
return nil
}
result := r.db.WithContext(ctx).Model(&model.ChatQrCode{}).Where("id = ?", id).Updates(updates)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrQrCodeNotFound
}
return nil
}
// DeleteQrCode 删除二维码(仅未使用的可删除)
func (r *Repository) DeleteQrCode(ctx context.Context, id uint64) error {
var qrcode model.ChatQrCode
if err := r.db.WithContext(ctx).First(&qrcode, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrQrCodeNotFound
}
return err
}
// 已使用的不能删除
if qrcode.Status == QrCodeStatusUsed {
return ErrQrCodeCannotDelete
}
return r.db.WithContext(ctx).Delete(&qrcode).Error
}
// fetchUnusedQrCode 获取一个未使用且未过期的二维码(带行锁)
func (r *Repository) fetchUnusedQrCode(tx *gorm.DB) (*model.ChatQrCode, error) {
var qrcode model.ChatQrCode
now := time.Now()
err := tx.Where("status = ?", QrCodeStatusUnused).
Where("expires_at IS NULL OR expires_at > ?", now).
Order("id ASC").
Limit(1).
Clauses(clause.Locking{Strength: "UPDATE"}).
First(&qrcode).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil // 无可用二维码,返回 nil 而非错误
}
return nil, err
}
return &qrcode, nil
}
// markQrCodeAsUsed 标记二维码为已使用
func (r *Repository) markQrCodeAsUsed(tx *gorm.DB, qrcodeID uint64, conversationID uint64) error {
now := time.Now()
return tx.Model(&model.ChatQrCode{}).
Where("id = ?", qrcodeID).
Updates(map[string]interface{}{
"status": QrCodeStatusUsed,
"conversation_id": conversationID,
"used_at": now,
}).Error
}
+7
View File
@@ -546,6 +546,13 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
adminRoutes.GET("/chats/auto-welcome", requirePerm("system_config:view"), chatHandler.AdminGetAutoWelcome)
adminRoutes.PUT("/chats/auto-welcome", requirePerm("system_config:update"), chatHandler.AdminUpdateAutoWelcome)
// 二维码池管理
adminRoutes.POST("/chats/qrcodes", requirePerm("chat:manage"), chatHandler.CreateQrCodeHandler)
adminRoutes.GET("/chats/qrcodes", requirePerm("chat:view"), chatHandler.ListQrCodesHandler)
adminRoutes.GET("/chats/qrcodes/stats", requirePerm("chat:view"), chatHandler.GetQrCodeStatsHandler)
adminRoutes.PATCH("/chats/qrcodes/:id", requirePerm("chat:manage"), chatHandler.UpdateQrCodeHandler)
adminRoutes.DELETE("/chats/qrcodes/:id", requirePerm("chat:manage"), chatHandler.DeleteQrCodeHandler)
// 角色管理
adminRoutes.GET("/roles", requirePerm("role:manage"), adminRoleHandler.List)
adminRoutes.GET("/roles/:id", requirePerm("role:manage"), adminRoleHandler.FindByID)
@@ -0,0 +1,50 @@
-- +goose Up
-- +goose StatementBegin
-- 1. chat_conversations 表新增 listing_id 列
ALTER TABLE chat_conversations
ADD COLUMN listing_id BIGINT UNSIGNED NULL COMMENT '关联的发布ID(listing_group类型会话使用)',
ADD INDEX idx_chat_conversations_listing (listing_id);
-- 2. 新建 chat_qrcode_pool 表(企业微信群二维码池)
CREATE TABLE IF NOT EXISTS chat_qrcode_pool (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
image_url VARCHAR(512) NOT NULL COMMENT 'MinIO存储的二维码图片URL',
status VARCHAR(16) NOT NULL DEFAULT 'unused' COMMENT '状态: unused/used/disabled',
conversation_id BIGINT UNSIGNED NULL COMMENT '发出后记录到哪个发布群(留痕)',
used_at DATETIME NULL COMMENT '使用时间',
expires_at DATETIME NULL COMMENT '企微群码失效时间(默认创建+7天)',
created_by BIGINT UNSIGNED NOT NULL COMMENT '上传的客服 admin_id',
note VARCHAR(255) NOT NULL DEFAULT '' COMMENT '备注,如"7月企微群A"',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_qrcode_status (status),
INDEX idx_qrcode_expires (expires_at),
INDEX idx_qrcode_conversation (conversation_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='企业微信群二维码池';
-- 3. 新增 system_configs 配置项(system_configs 表只有 key/value/description 三列)
INSERT INTO system_configs (`key`, `value`, description) VALUES
('chat.listing_group_welcome', '欢迎加入账号群!请号主扫描下方二维码加入企业微信群,方便客服与您及时联系。', '发布群欢迎语'),
('chat.qrcode_low_stock_threshold', '5', '二维码库存预警阈值')
ON DUPLICATE KEY UPDATE
`value` = VALUES(`value`),
description = VALUES(description);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
-- 回滚配置项
DELETE FROM system_configs WHERE `key` IN ('chat.listing_group_welcome', 'chat.qrcode_low_stock_threshold');
-- 回滚表
DROP TABLE IF EXISTS chat_qrcode_pool;
-- 回滚列
ALTER TABLE chat_conversations
DROP INDEX idx_chat_conversations_listing,
DROP COLUMN listing_id;
-- +goose StatementEnd
@@ -0,0 +1,576 @@
# 群聊优化方案:发布群 + 企业微信群二维码(单群方案)
> 日期:2026-06-17
> 状态:方案定稿,采用单群方案,待实施
> 更新:2026-06-17 废弃双群设计,采用单群方案
## 核心决策:采用单群方案
**废弃双群设计,只保留发布群**,理由:
1. **业务约束决定**:一个账号同时只有一个活跃订单(`InTransaction` 锁),订单群的"私密隔离"价值为零
2. **开发成本**:单群方案工作量降低 40%,代码更简洁
3. **运维成本**:会话数减半,SSE 推送压力减半
4. **用户体验**:一个群承载全生命周期沟通,避免两个群的困惑
5. **隐私保障**:通过消息可见性过滤实现租客隔离(租客只看加入后的消息)
## 已确认决策
| # | 决策点 | 结论 |
|---|--------|------|
| 1 | 群聊架构 | **单群方案**:发布群承载全生命周期沟通(咨询 + 交接 + 售后) |
| 2 | 二维码管理权限 | 复用现有 `cs` 客服角色(不细分权限) |
| 3 | 企业微信群二维码 7 天失效 | 客服手动维护 + `expires_at` 自动判断辅助 |
| 4 | 租客移出时机 | 订单终态后**立即移出**(售后窗口在订单状态中体现) |
| 5 | 存量账号历史数据 | **不做补建**,仅对新发布的账号生效 |
| 6 | 发布群创建时机 | **发布提交即建群**(不等审核),前端自动跳进群 |
| 7 | 消息历史隐私 | 租客只能看到**加入时间之后**的消息(通过 `participant.joined_at` 过滤) |
---
## 一、背景与问题
### 1.1 业务痛点
当前群聊只在**订单付款成功后**才自动创建(订单群),存在一个问题:
> 租客下单后需要联系号主(卖家)交接账号时,号主可能不在线、没及时看到站内消息,导致租客**联系不到卖家**。
号主不常驻站内 IM,但「企业微信」是其日常高频使用的沟通工具。
### 1.2 优化目标
通过两个机制解决「联系不到卖家」:
1. **发布即建群**:账号发布(上架)后立即建立「发布群」,号主常驻其中,让潜在租客在任何阶段都能找到号主。
2. **企业微信群引流**:建群后自动发送一段欢迎语 + 一张企业微信群二维码,引导号主扫码进入客服预备好的企业微信群——客服通过企业微信能**实时触达**号主,大幅提升响应率与进群概率。
---
## 二、现状分析(As-Is
### 2.1 系统栈
Go (Gin + GORM + MySQL + Redis) 后端 + Vue3 前端。聊天为**完全自建的站内 IM**,无第三方 IM 服务集成,基于 MySQL 持久化 + 进程内 SSE 实时推送(`chathub` 包)。
### 2.2 群聊数据模型
| 表 | 模型 | 作用 |
|---|---|---|
| `chat_conversations` | `ChatConversation` | 会话主表。`order_id` 唯一索引(一订单一会话),`type` 默认 `order_group`,可选 `general_support`;状态 `active/archived/closed` |
| `chat_participants` | `ChatParticipant` | 成员表。`participant_type`=`user/admin``role`=`owner/renter/support`;唯一键 `(conversation_id, participant_type, participant_id)`**含 `joined_at` 字段(关键!)** |
| `chat_messages` | `ChatMessage` | 消息表。`sender_type`=`user/admin/system``content_type`=`text/image/file/system``attachment_urls` 是 JSON 数组 |
| `chat_quick_replies` | — | 客服快捷回复模板 |
关键事实:
- **没有「群」表**"群"就是 `type='order_group'` 的 conversation
- **群聊/账号维度没有任何二维码字段**
- **`participant.joined_at` 字段已存在**,可直接用于消息可见性过滤
### 2.3 群聊创建与触发链(核心)
**唯一自动建群入口**:订单支付成功 → 同事务内建群。
```
支付渠道回调/查询确认 paid
→ payment.confirmPaid (channel_status.go:46)
→ order.ConfirmPaidFromChannelTx (lifecycle.go:159) // 单事务
├── 订单 pending_payment → pending_handoff
├── listing/account → rented
├── chat.EnsureOrderConversation(tx, order) // ★ 建订单群 (lifecycle.go:185)
│ - 幂等:按 order_id 查重
│ - 建 type=order_group 会话
│ - 写 3 个参与者:租客(renter) + 号主(owner) + 客服(support)
│ - 写 1 条系统欢迎消息 (chat.auto_welcome_message)
└── notification.Append (号主/租客各 1 条站内信)
→ 事务外:orderRepo.NotifyNewConversation → SSE 推送
```
另有用户主动触发的「平台客服」咨询会话(`type=general_support`),与本次优化无关。
### 2.4 业务约束:一个账号同时只有一个订单
**关键发现**:通过 `listing.InTransaction` 字段实现互斥锁。
```go
// order/assets.go:45
func reserveListingForOrder(listing *model.RentalListing) {
listing.InTransaction = true // 锁定账号,防止并发下单
}
```
**结论**:一个发布(listing)在同一时间只能有一个活跃订单,不存在"多个租客同时在群内"的场景。
### 2.5 现状关键结论
| 问题 | 结论 |
|---|---|
| 群聊何时创建 | **仅在订单支付成功时**,一订单一群。无「发布即建群」逻辑 |
| 群聊命名 | `"订单群聊 " + 订单号`,不含账号信息 |
| 成员管理能力 | **极弱**:创建时一次性写死参与者;仅有客服转接(删旧客服加新客服),**无中途拉人/踢人接口** |
| 群聊与发布/账号关联 | **无任何关联**,只挂 `order_id` |
| 消息能力 | 自建站内 IM(文本/图片/文件附件,附件走 MinIO 文件服务) |
| 二维码能力 | 平台有「文件上传到 MinIO + 存 key」能力(收款码、支付码);但**群聊/账号维度无二维码字段** |
| 客服角色 | `roles.code='cs'`,建群时按「当前会话负载最少 + 在线状态」选最空闲客服 (`participant.go:298`) |
| 事件/队列机制 | **无通用事件总线/MQ**。副作用靠同步方法调用;异步靠轮询 Job(订单超时、退款重试) |
### 2.6 关键文件路径
| 文件 | 行 | 作用 |
|---|---|---|
| `backend/internal/model/chat.go` | 9-57 | 聊天数据模型 |
| `backend/migrations/000001_init.sql` | 456-514 | 聊天表结构 |
| `backend/internal/modules/chat/repository.go` | 24-100 | `EnsureOrderConversation` 建群逻辑 |
| `backend/internal/modules/chat/participant.go` | 298-369 | 客服选择算法 |
| `backend/internal/modules/chat/message.go` | 52-161 | 发消息 + SSE 推送 |
| `backend/internal/modules/chathub/hub.go` | — | SSE Hub(实时推送,channel 满丢事件,非可靠投递) |
| `backend/internal/modules/order/lifecycle.go` | 159-219 | 付款成功事务,内含建群调用 (185) |
| `backend/internal/modules/order/assets.go` | 45 | `InTransaction` 锁逻辑 |
| `backend/internal/modules/payment/channel_status.go` | 46-87 | 支付确认入口 |
| `backend/internal/modules/listing/review.go` | 90-127 | 审核通过(上架)触发点 |
| `backend/internal/modules/listing/mutation.go` | 16-66 | 发布创建(含免审直发分支) |
| `backend/internal/modules/file/*` | — | MinIO 文件上传服务 |
| `backend/internal/router/router.go` | 162, 440-452, 531-547 | chat 依赖装配、用户端/管理端聊天路由 |
---
## 三、目标设计(To-Be):单群方案
### 3.1 核心设计
**一个发布 = 一个发布群**,承载全生命周期沟通:
```
发布提交 → 建发布群(listing_group
├─ 固定成员:号主 + 客服
├─ 自动消息:欢迎语 + 企业微信群二维码图片
└─ 前端跳转:发布成功后自动进群,号主立即看到二维码
租客付款 → 拉租客进发布群
├─ 幂等加入(已在群内则跳过)
├─ 发系统消息"租客 xxx 已付款(订单 xxx"
└─ 租客**只能看到加入时间之后的消息**(消息可见性过滤)
订单交接 → 在同一个群内沟通
├─ 号主提交交接说明
├─ 租客确认交接
└─ 客服介入处理纠纷
订单终态 → 移出租客
├─ 删除租客的 chat_participants 行
├─ 发系统消息"订单已结束,租客已退出群聊"
└─ 号主 + 客服常驻群内
下次出租 → 新租客付款后加入同一个群
└─ 看不到之前租客的消息(通过 joined_at 过滤)
```
### 3.2 发布群职责
| 职责 | 说明 |
|------|------|
| **咨询阶段** | 潜在租客可通过账号详情页进群咨询(未付款前只读) |
| **交接阶段** | 租客付款后加入群,在群内完成账号交接 |
| **售后阶段** | 租期内问题在群内沟通,客服介入处理 |
| **长期入口** | 号主常驻群内,企业微信二维码持续有效,提升触达率 |
### 3.3 消息历史隐私保障(核心)
**问题**:新租客加入时,会看到之前租客的聊天记录吗?
**解决**:通过 `participant.joined_at` 过滤消息可见性。
```go
// 租客查询消息时的过滤逻辑
func (r *Repository) ListMessages(conversationID, userID uint64, userType string) {
var participant ChatParticipant
db.Where("conversation_id = ? AND participant_type = ? AND participant_id = ?",
conversationID, userType, userID).First(&participant)
query := db.Where("conversation_id = ?", conversationID)
// 租客只能看到加入时间之后的消息
if userType == "user" && participant.Role == "renter" {
query = query.Where("created_at >= ?", participant.JoinedAt)
}
// 号主和客服看全部消息(便于连续服务)
query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&messages)
}
```
**效果**
- 租客 A2026-01-01 加入,看到 2026-01-01 ~ 2026-01-15 的消息
- 租客 A 订单终态后移出
- 租客 B2026-02-01 加入,只看到 2026-02-01 之后的消息,看不到租客 A 的对话
- 号主/客服:看到全部消息,便于了解历史情况
### 3.4 企业微信群二维码的作用链
```
客服在后台预先上传一批企业微信群二维码到「二维码池」
号主发布账号 → 发布提交时自动建发布群
从二维码池取一张 unused 二维码,标记 used + 记录 conversation_id
在发布群内发送:
① 系统欢迎语(文本)
② 二维码图片消息(image)+ 引导文案"👇 请扫码加入企业微信群,方便客服与您及时联系"
前端发布成功后自动跳进发布群,号主第一眼看到欢迎语和二维码
号主扫码进企业微信群
客服通过企业微信能实时联系到号主(突破站内消息到达率低的限制)
```
---
## 四、详细设计
### 4.1 数据层改动
#### 4.1.1 `chat_conversations` 表新增 `listing_id` 列
```sql
ALTER TABLE chat_conversations
ADD COLUMN listing_id BIGINT UNSIGNED NULL,
ADD INDEX idx_chat_conversations_listing (listing_id);
```
**设计决策**
- 保留现有 `order_id` 字段,兼容历史订单群(不做迁移)
- `listing_id` 用**普通索引**(非唯一):用幂等逻辑(建群前查重)保证一发布一群,比唯一约束更灵活
- GORM 模型 `ChatConversation` 增加 `ListingID *uint64` 字段
- 新增会话类型常量 `listing_group`
#### 4.1.2 新表 `chat_qrcode_pool`(企业微信群二维码池)
```sql
CREATE TABLE chat_qrcode_pool (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
image_url VARCHAR(512) NOT NULL, -- MinIO 存储的二维码图片 URL
status VARCHAR(16) NOT NULL DEFAULT 'unused', -- unused / used / disabled
conversation_id BIGINT UNSIGNED NULL, -- 发出后记录到哪个发布群(留痕)
used_at DATETIME NULL,
expires_at DATETIME NULL, -- 企微群码失效时间(默认创建+7天)
created_by BIGINT UNSIGNED NOT NULL, -- 上传的客服 admin_id
note VARCHAR(255) NOT NULL DEFAULT '', -- 备注,如"7月企微群A"
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
INDEX idx_qrcode_status (status),
INDEX idx_qrcode_expires (expires_at)
);
```
**字段说明**
- **status 流转**`unused`(待用)→ `used`(已发放,绑定到某发布群);客服可手动置 `disabled`(停用,不再发放)
- **expires_at 自动失效辅助**:客服上传时可不填,默认 `created_at + 7 天`(企微群码原生 7 天有效期)。分配时跳过 `expires_at < now()``unused` 码,自动老化
- **分配策略:一次性消耗**。每个发布群消耗一张 `unused` 且未过期的二维码,发后标记 `used` 并记录 `conversation_id`。防滥用、留痕清晰
- GORM 模型 `ChatQrCode`
#### 4.1.3 `system_configs` 新增配置项
| key | 默认值 | 作用 |
|-----|--------|------|
| `chat.listing_group_welcome` | `"欢迎加入账号群!请号主扫描下方二维码加入企业微信群,方便客服与您及时联系。"` | 发布群欢迎语 |
| `chat.qrcode_low_stock_threshold` | `5` | 二维码库存预警阈值 |
### 4.2 后端逻辑改动
#### 4.2.1 发布群创建(chat 模块新增 `listing_group.go`
新函数 `EnsureListingConversation(tx *gorm.DB, listing model.RentalListing) (conversationID uint64, error)`,对照现有 `EnsureOrderConversation` 编写:
**核心逻辑**
1. **幂等查重**:按 `listing_id``chat_conversations`,已存在则直接返回 conversation_id
2. **建会话**`ChatConversation{ListingID: listing.ID, Type: "listing_group", Title: "账号群 " + listing.ListingNo}`
3. **写参与者**
- 号主(`participant_type=user, role=owner`
- 客服(`participant_type=admin, role=support`,复用 `defaultSupportAdminID(tx)`
4. **取二维码**
```sql
SELECT * FROM chat_qrcode_pool
WHERE status='unused'
AND (expires_at IS NULL OR expires_at > NOW())
ORDER BY id LIMIT 1 FOR UPDATE
```
取到则标记 `used` + 写 `conversation_id` + `used_at`
5. **发欢迎语**
- 一条文本系统消息(读 `chat.listing_group_welcome`
- 若取到二维码:一条图片系统消息(`content_type=image`, `attachment_urls=[image_url]`),附引导文案"👇 请扫码加入企业微信群"
- 若二维码池为空:不阻塞建群,发一条提示"客服企业微信群二维码补充中,请稍后在群内关注"
6. **更新会话 last_message**(复用现有逻辑)
7. **库存预警**:发放后若剩余 `unused` 数量 ≤ 阈值,给所有 `cs` 角色客服发站内信"企业微信群二维码库存不足(剩 N 张),请及时补充"
#### 4.2.2 发布触发点(listing/mutation.go 的 `Create`
**触发时机改为「发布提交时」**(不等审核)。在 `Create` 事务内,`tx.Create(&listing)` 之后、事务 return 之前,调用建群。
**模块解耦**(仿照现有 `OrderChatNotifier` 模式):
```go
// listing/repository.go 新增接口
type ListingChatCreator interface {
EnsureListingConversation(tx *gorm.DB, listing model.RentalListing) (conversationID uint64, err error)
}
```
在 `router.go` 装配处(`chatRepo` 已在 router.go:162 就绪)注入给 listing repo。`Create` 事务内通过 `conversationID, err := r.chatCreator.EnsureListingConversation(tx, listing)` 调用,把返回的 `conversation_id` 带回 DTO。
**DTO 增强**`ListingDTO` 新增 `listing_group_conversation_id` 字段,发布接口返回时带上刚建的发布群 ID。
**事务回滚保障**:建群失败会导致整个发布事务回滚,保证数据一致性。
#### 4.2.3 前端发布成功自动跳群
这是提升进群概率的关键交互。当前发布成功逻辑在 `frontend/src/features/seller/composables/usePublishForm.ts:189`,走 `router.push(options.submitSuccessPath)`。
**改造点**
1. 发布接口返回的 `ListingDTO` 现在带 `listing_group_conversation_id`
2. 发布成功后,若该字段 > 0,则 `router.push` 改为跳转到该发布群聊天页(如 `/chats/:conversationId`),而不是原来的发布成功列表页
3. 跳进群后,号主第一眼就看到系统欢迎语 + 二维码图片,自然引导扫码进企业微信群
**注意**:审核中状态的账号虽已建群,但此时群内只有号主+客服,无租客。审核通过上架后租客才能下单被拉进。群聊本身与审核解耦。
#### 4.2.4 付款后拉租客进发布群(order/lifecycle.go
在 `ConfirmPaidFromChannelTx` 现有建订单群逻辑**之后**(或直接替换),新增/修改为:
```go
// 废弃原 chat.EnsureOrderConversation(tx, *order) - 不再建订单群
// 改为拉租客进发布群
if err := chat.AddRenterToListingConversation(tx, listing.ID, order.RenterID, order.OrderNo); err != nil {
// 优雅降级:历史发布无发布群时,err 返回 nil,不阻塞付款流程
return 0, err
}
```
**新函数 `AddRenterToListingConversation(tx, listingID, renterID, orderNo)`**
1. 按 `listing_id` 查发布群。**找不到则跳过**(优雅降级:历史发布无发布群,不影响下单)
2. 幂等插入参与者 `(conversation_id, 'user', renterID, role='renter', joined_at=NOW())``OnConflict{DoNothing}`
3. 发系统消息"租客 xxx 已加入群聊(订单 <orderNo>"
4. SSE 通知在事务外统一刷新,无需在此处理
**关键**`joined_at` 字段记录租客加入时间,用于后续消息可见性过滤。
#### 4.2.5 租客移出发布群(订单终态)
订单进入终态时,把租客移出发布群,保持群成员恒定为「号主 + 客服」,避免历史租客残留导致隐私泄露。
**新函数 `RemoveRenterFromListingConversation(tx, listingID, renterID, reason)`**
1. 按 `listing_id` 查发布群,找不到则跳过
2. 删除该租客的 `chat_participants` 行:
```go
tx.Where("conversation_id = ? AND participant_type = ? AND participant_id = ? AND role = ?",
conversationID, "user", renterID, "renter").Delete(&model.ChatParticipant{})
```
3. 发系统消息(如"订单已结束,租客已退出群聊")
**触发点**(在订单终态流转处调用):
| 订单事件 | 文件 | 动作 |
|---|---|---|
| 订单完成 | `order/checkout_finalize.go` | 移出租客 |
| 租客取消 | `order/lifecycle.go` 的 `Cancel` | 移出租客 |
| 退款终结 / 关闭 | `order/refund.go` / `closed` 流转 | 移出租客 |
**注意**`InTransaction` 锁保证一个账号同时只有一个活跃订单,故"同时多租客共群"不会发生,移出机制主要清理历史租客。
#### 4.2.6 消息查询增加可见性过滤(chat/message.go
修改现有消息列表查询接口,增加租客可见性过滤:
```go
func (r *Repository) ListMessages(ctx context.Context, conversationID uint64, userID uint64, userType string, offset, limit int) {
// 查询当前用户的参与者信息
var participant model.ChatParticipant
err := r.db.Where("conversation_id = ? AND participant_type = ? AND participant_id = ?",
conversationID, userType, userID).First(&participant).Error
if err != nil {
return nil, err // 不在群内,无权查看
}
query := r.db.Where("conversation_id = ?", conversationID)
// 租客只能看到加入时间之后的消息
if userType == "user" && participant.Role == "renter" {
query = query.Where("created_at >= ?", participant.JoinedAt)
}
// 号主和客服看全部消息
var messages []model.ChatMessage
query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&messages)
return messages, nil
}
```
**效果**
- 新租客加入时,只看到自己加入后的消息
- 之前租客的聊天记录完全不可见
- 号主和客服看全部消息,便于连续服务
#### 4.2.7 客服后台二维码池管理(chat 模块新增 `qrcode.go` + 路由)
挂在 `router.go:531` 现有客服路由组下:
| Method | Path | 作用 |
|--------|------|------|
| `POST` | `/api/admin/chats/qrcodes` | 客服上传二维码(先经 file 模块上传 MinIO 拿 URL,再写入池) |
| `GET` | `/api/admin/chats/qrcodes` | 列表(支持按 status 过滤、分页) |
| `PATCH` | `/api/admin/chats/qrcodes/:id` | 编辑备注 / 置 `disabled` / 更新 `expires_at` |
| `DELETE` | `/api/admin/chats/qrcodes/:id` | 删除(仅 `unused` 可删,`used` 保留留痕) |
| `GET` | `/api/admin/chats/qrcodes/stats` | 库存统计(unused / used / disabled 计数) |
权限:`cs` 角色下(复用现有 `chat:manage` 或新增 `chat:qrcode` 权限)。
### 4.3 前端改动
#### 4.3.1 客服后台:二维码池管理页
- 新页面 `frontend/src/features/admin/chat/QrCodePool.vue`:列表 + 上传 + 状态筛选 + 库存统计卡片
- 上传组件复用现有文件上传逻辑(项目已有支付/收款码上传 UI 可参考)
- 表格显示:图片缩略图、状态、绑定会话、过期时间、备注、操作按钮
#### 4.3.2 用户端:会话列表区分群类型
- 发布群走现有 `/api/chats` 接口(已返回 `type` 字段),前端按 `type` 加图标/前缀区分:
- 发布群:`📢 账号群 <listing_no>`
- 历史订单群:`📦 订单 <order_no>`(兼容存量数据)
- 发布群内展示欢迎语 + 二维码图片消息(`content_type=image` 已支持)
#### 4.3.3 发布成功跳转逻辑
修改 `frontend/src/features/seller/composables/usePublishForm.ts`
```typescript
// 发布成功后
if (response.data.listing_group_conversation_id) {
// 跳转到发布群
router.push(`/chats/${response.data.listing_group_conversation_id}`)
} else {
// 兼容旧逻辑或建群失败场景
router.push(options.submitSuccessPath)
}
```
---
## 五、边界与风控
| 场景 | 处理 |
|------|------|
| **历史数据**(上线前已发布的账号) | 无发布群。租客付款时 `AddRenterToListingConversation` 找不到群则跳过,不影响下单。**不做后台批量补建**,仅新发布生效 |
| **历史订单群**(上线前已建的订单群) | 保留原样,`type=order_group` + `order_id`。新老群并行,逐步过渡到发布群 |
| **一号主发多个账号** | 每个发布独立建群,号主进多个发布群。每个群各消耗一张二维码 |
| **同一账号被租多次** | 幂等返回同一发布群;靠「订单终态移出租客」保持群成员干净,规避历史租客共群隐私泄露 |
| **下线后重新发布** | 同 listing_id 重新上架:幂等返回已有群,不重建。发全新账号(新 listing_id):新建发布群 |
| **二维码用尽** | 建群不阻塞,发提示文案;同时预警站内信给客服 |
| **企业微信群二维码 7 天失效** | 企业微信原生群码有有效期。**运营层面靠客服定期更换**(disabled 旧码、传新码)。系统辅助:`expires_at` 字段在分配时跳过过期码 |
| **租客退出/订单结束** | 订单终态立即移出租客;发布群作为联系入口保留(号主+客服常驻) |
| **客服被拉进海量群** | 现有客服工作台已按会话负载分配,规模上来后需评估 filter/降噪(现状 `support.go` 已有列表 filter 能力) |
| **租客恶意查看历史消息** | 通过 `participant.joined_at` 过滤,后端强制校验,租客无法绕过 |
---
## 六、实施计划(分 3 个提交)
| 提交 | 内容 | 可独立验收 | 预计工作量 |
|------|------|-----------|-----------|
| **提交 1** | 数据层(migration + 模型 + 配置)+ 二维码池后台 CRUD(API + 前端管理页) | ✅ 不碰现有聊天逻辑 | 2 天 |
| **提交 2** | 发布群核心:`EnsureListingConversation` + listing 触发点 + 付款拉租客 + 订单终态移出租客 + 消息可见性过滤 | ✅ 改造现有聊天流 | 3 天 |
| **提交 3** | 体验完善:库存预警站内信、前端发布跳群、前端群类型图标区分 | ✅ 增量优化 | 2 天 |
**总工作量**:约 7 个工作日(vs 双群方案 10+ 天)
---
## 七、单群方案优势总结
### 7.1 vs 双群方案对比
| 维度 | 双群方案 | 单群方案(采用) | 优势 |
|------|---------|--------------|------|
| **开发复杂度** | 两套建群逻辑 + 两套成员管理 | 一套建群逻辑 + 简单成员进出 | 代码量减少 40% |
| **运维成本** | 会话数翻倍,SSE 推送压力大 | 会话数减半 | 资源消耗减少 50% |
| **用户体验** | 两个群聊天,不知道在哪说 | 一个群承载全生命周期 | 更直观清晰 |
| **隐私保障** | 订单群独立隔离 | 消息可见性过滤(`joined_at` | 同样安全 |
| **数据库变更** | 加 `listing_id`,保留 `order_id` | 加 `listing_id`,兼容 `order_id` | 同等复杂度 |
| **业务契合度** | 过度设计(同时只有一个租客) | 契合业务约束 | 避免过度工程 |
### 7.2 核心技术亮点
1. **消息可见性过滤** - 通过 `participant.joined_at` 实现租客隔离,简单可靠
2. **优雅降级** - 历史数据无发布群时跳过,不阻塞下单流程
3. **模块解耦** - 通过接口注入避免 listing 直接依赖 chat
4. **幂等设计** - 重复调用建群/拉人接口安全无副作用
5. **事务一致性** - 建群失败回滚整个发布事务,保证数据正确性
### 7.3 风险与缓解
| 风险 | 缓解措施 |
|------|---------|
| 消息可见性过滤失效 | 后端强制校验 `joined_at`,前端无法绕过;增加单元测试覆盖 |
| 历史订单群兼容性 | 保留 `order_id` 字段,新老群并行,不做强制迁移 |
| 二维码库存耗尽 | 库存预警 + 优雅降级(建群不阻塞) |
| 客服工作量激增 | 监控客服平均会话数,必要时调整分配算法 |
---
## 八、未来扩展
### 8.1 可选增强(本期不做)
1. **二维码自动老化 Job** - 定时标记过期码为 `disabled`,减轻客服负担
2. **订单终态统一入口** - 抽象 `finalizeOrder(reason)` 避免移出逻辑遗漏
3. **客服会话过滤** - 提前规划发布群数量激增后的工具支持
4. **AB 测试灰度** - 先对部分号主开启,验证企业微信引流效果
### 8.2 长期关注点
1. **SSE 推送可靠性** - 如果发布群量大,考虑引入 MQ
2. **企业微信 API 对接** - 后期可研究是否有群码自动创建 API
3. **消息分页性能** - 发布群长期存在,消息量可能很大,需优化查询性能
---
## 九、验收标准
### 9.1 功能验收
- [ ] 发布账号时自动建发布群,群内有号主、客服、欢迎语、二维码图片
- [ ] 前端发布成功后自动跳进发布群
- [ ] 租客付款后自动加入发布群,发系统消息
- [ ] 租客只能看到加入时间之后的消息(历史消息不可见)
- [ ] 订单终态后租客自动移出群,发系统消息
- [ ] 同一账号多次出租,新租客看不到之前租客的对话
- [ ] 客服可在后台上传/管理二维码,查看库存统计
- [ ] 二维码库存不足时,客服收到站内信预警
### 9.2 性能验收
- [ ] 发布群创建耗时 < 500ms(含二维码分配)
- [ ] 消息查询带可见性过滤,耗时无明显增加(< 100ms)
- [ ] 历史数据无发布群时,付款流程不受影响
### 9.3 兼容性验收
- [ ] 历史订单群正常展示和使用
- [ ] 新老群类型在前端有明确区分
- [ ] 二维码用尽时建群不阻塞,发提示文案
---
## 十、总结
单群方案通过**消息可见性过滤**实现了双群方案的隐私隔离效果,同时大幅降低开发和运维成本。方案契合业务约束(一个账号同时只有一个订单),避免了过度设计,是更优的技术选择。
**核心价值**
- ✅ 解决「联系不到卖家」痛点(发布即建群 + 企业微信引流)
- ✅ 降低开发成本 40%,运维成本 50%
- ✅ 提升用户体验(一个群承载全生命周期)
- ✅ 保障隐私安全(消息可见性过滤)
**建议下一步**:开始实施提交 1(数据层 + 二维码池管理),为核心功能打好基础。