# 群聊优化方案：发布群 + 企业微信群二维码（单群方案）

> 日期：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)
}
```

**效果**：
- 租客 A：2026-01-01 加入，看到 2026-01-01 ~ 2026-01-15 的消息
- 租客 A 订单终态后移出
- 租客 B：2026-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（数据层 + 二维码池管理），为核心功能打好基础。
