Files
gochat/docs/product/07-design-auth-and-realtime.md
T
rogee 0dabb8cfa5 docs: 整理文档目录结构 — 清理过时文档、归集功能子目录、统一命名规范
清理:
- 删除 34 份过时文档(gap reports/QA临时报告/验收报告/阶段性文档)
- 删除 docs/.hermes/skills 第三方 skills 副本(16 文件)
- 删除 skills-lock.json

目录归集:
- 根目录仅保留 README.md 索引
- product/ — 产品与架构设计(PRD + ARCHITECTURE + P2设计文档 + AI/企业路线图)
- tracking/ — Chatwoot parity 开发跟踪
- requirements/ — M01-M12 模块需求
- plans/ — 历史实现计划
- parity/ — 路由 parity 与前端契约
- qa/ — QA 报告与测试计划
- ops/ — 运维部署

命名规范:
- 全小写 kebab-case,禁止全大写文件名
- product/tracking/ops 用 NN- 序号前缀
- requirements 用 MNN- 两位零填充模块号
- plans/qa 用 YYYY-MM-DD- 日期前缀
- requirements M1-M9 零填充为 M01-M09(修复字典序)

同步更新:
- backend/cmd/route_parity/main.go 路径默认值
- backend/scripts/parity_frontend_smoke.sh 报告路径
- 所有 docs 内部交叉引用
- .gitignore 排除编译产物 (backend/gochat, backend/route_parity)
- 新增迁移 000052/000053
- 前端 WS 相关修改
2026-07-09 14:53:27 +08:00

557 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# P2E — GoChat 认证授权与实时通信架构设计
> 版本: v1.1 | 更新日期: 2026-07-09
> 参照: Chatwoot DeviseTokenAuth / Pundit / ActionCable / Dispatcher / Listener
> 状态:设计文档,认证授权与 WebSocket 已落地。当前 auth 包含 JWT/OAuth/MFA/SAML/RBAC/Platform Auth。
---
## 1. JWT 认证体系
### 1.1 对比 Chatwoot DeviseTokenAuth
| 特性 | Chatwoot (Rails) | GoChat (Go) |
|---|---|---|
| 认证库 | DeviseTokenAuth gem | 自实现 JWT middleware |
| Token存储 | 多token机制(client_id+token对) | 单JWT + Refresh token |
| Token传递 | HTTP headers(access-token/client/uid) | Authorization: Bearer <jwt> |
| Token刷新 | 每次请求自动刷新(竞态锁定) | Refresh token 端点显式刷新 |
| 多账户 | Devise scope切换 | JWT claims 含 account_id |
| MFA | Devise two_factor_authentication | TOTP 验证中间件 |
| OAuth | Omniauth callbacks | OAuth2 redirect flow |
| SAML | Devise_saml_authenticatable | 自实现 SAML SP(企业版) |
### 1.2 JWT Token 结构
```go
// token/jwt.go
type Claims struct {
UserID uint `json:"user_id"`
AccountID uint `json:"account_id"` // 当前活跃账户
Role string `json:"role"` // agent/administrator/custom_role
Provider string `json:"provider"` // email/google/saml
CustomRoleID uint `json:"custom_role_id,omitempty"` // 企业版自定义角色
jwt.RegisteredClaims
}
```
**Access Token**: 15分钟过期,含完整Claims
**Refresh Token**: 7天过期,仅含 UserID + Provider,存储在Redis
### 1.3 认证流程
```
1. 用户登录 POST /api/v1/auth/login
→ 验证 email+password (bcrypt)
→ 查询 AccountUser 获取角色
→ 生成 Access Token + Refresh Token
→ 返回 {user, access_token, refresh_token}
2. 切换账户 POST /api/v1/auth/switch_account
→ JWT claims.account_id 更换
→ 重新签发 Access Token(新 account_id)
3. Token刷新 POST /api/v1/auth/refresh
→ 验证 Refresh Token
→ 重新签发 Access Token
4. OAuth登录 GET /api/v1/auth/:provider/callback
→ Google OAuth2 redirect → callback
→ 创建/查找 User + AccountUser
→ 签发 JWT
5. 企业版SAML (见 §4)
```
### 1.4 认证中间件
```go
// middleware/auth.go
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
token := extractBearerToken(c)
claims, err := ValidateAccessToken(token)
if err != nil {
c.AbortWithStatusJSON(401, gin.H{"error": "unauthenticated"})
return
}
c.Set("current_user_id", claims.UserID)
c.Set("current_account_id", claims.AccountID)
c.Set("current_role", claims.Role)
c.Set("current_custom_role_id", claims.CustomRoleID)
c.Next()
}
}
```
**认证级别分层:**
- `PublicAPI` — 无认证(WebWidget嵌入、CSAT提交、Portal文章)
- `AuthenticatedAPI` — AuthMiddleware(绝大多数API)
- `AdminAPI` — AuthMiddleware + RoleCheck("administrator")(账户设置、团队管理等)
- `EnterpriseAPI` — AuthMiddleware + FeatureFlagCheck("enterprise_*")(企业版功能)
- `SuperAdminAPI` — SuperAdmin middleware(平台管理API)
---
## 2. RBAC 权限系统
### 2.1 对比 Chatwoot Pundit + CustomRole
| 特性 | Chatwoot | GoChat |
|---|---|---|
| 权限框架 | Pundit (Policy类) | 中间件+Policy函数 |
| 内置角色 | agent / administrator | agent / administrator |
| 自定义角色 | CustomRole (企业版, 6权限维度) | CustomRole (企业版, 同6维度) |
| Policy检查 | `authorize @resource` in controller | `policy.Check(user, resource, action)` middleware |
| 范围过滤 | `scope = Policy::Scope.resolve` | `policy.Scope(user, resource)` 返回过滤条件 |
### 2.2 角色体系
```go
// model/role.go
type Role string
const (
RoleAgent Role = "agent"
RoleAdministrator Role = "administrator"
)
// 企业版 CustomRole
type CustomRole struct {
ID uint `gorm:"primaryKey"`
AccountID uint `gorm:"index"`
Name string `gorm:"size:255"`
Permissions Permissions `gorm:"type:jsonb"` // 6维度权限
}
type Permissions struct {
ConversationManage PermissionLevel `json:"conversation_manage"` // full/read
ConversationDelete PermissionLevel `json:"conversation_delete"` // full/none
ContactManage PermissionLevel `json:"contact_manage"` // full/read
ReportManage PermissionLevel `json:"report_manage"` // full/none
KnowledgeBaseManage PermissionLevel `json:"knowledge_base_manage"` // full/read
AutomationManage PermissionLevel `json:"automation_manage"` // full/none
}
type PermissionLevel string
const (
PermissionFull PermissionLevel = "full"
PermissionRead PermissionLevel = "read"
PermissionNone PermissionLevel = "none"
)
```
### 2.3 权限检查实现
```go
// policy/base.go
type PolicyContext struct {
UserID uint
AccountID uint
Role Role
CustomRoleID uint
Permissions Permissions // 企业版加载
}
func (pc *PolicyContext) Can(action string, resource string) bool {
// administrator → 全权限
if pc.Role == RoleAdministrator {
return true
}
// agent → 基本权限
if pc.Role == RoleAgent {
return agentDefaultPermissions[action][resource]
}
// custom_role → 查权限矩阵
return checkCustomPermission(pc.Permissions, action, resource)
}
// 在中间件中注入
func PolicyMiddleware(resource string, action string) gin.HandlerFunc {
return func(c *gin.Context) {
pc := buildPolicyContext(c)
if !pc.Can(action, resource) {
c.AbortWithStatusJSON(403, gin.H{"error": "unauthorized"})
return
}
c.Set("policy_context", pc)
c.Next()
}
}
```
### 2.4 与Chatwoot对比的关键简化
1. **去除Pundit Policy类** — 每个资源一个Policy文件(如AccountPolicy, ConversationPolicy),GoChat统一为一个PolicyContext+权限矩阵
2. **Scope过滤合并** — Chatwoot的Policy::Scope(返回过滤后的数据集),GoChat用PolicyContext.Scope()返回GORM WHERE条件
3. **CustomRole权限合并** — Chatwoot的AccountUser.role + CustomRole双判断,GoChat在JWT claims中直接合并
---
## 3. AccountUser 多账户角色切换
### 3.1 模型设计
```go
// model/account_user.go
type AccountUser struct {
ID uint `gorm:"primaryKey"`
AccountID uint `gorm:"index;not null"`
UserID uint `gorm:"index;not null"`
Role Role `gorm:"size:255;not null;default:'agent'"`
CustomRoleID uint `gorm:"index"` // 企业版
Availability string `gorm:"size:255;default:'offline'"` // online/offline/busy
AutoOffline bool `gorm:"default:false"`
AgentCapacityID uint `gorm:"index"` // 企业版容量策略
ActiveAt *time.Time
InvitedByID uint
CreatedAt time.Time
UpdatedAt time.Time
}
```
### 3.2 切换流程
```
1. 用户登录 → 默认选择第一个AccountUser对应的账户
2. JWT claims.account_id = 选中的账户ID
3. 切换账户 → POST /api/v1/auth/switch_account {account_id: N}
→ 验证 AccountUser 存在
→ 重新签发JWT(新account_id + 对应role)
→ 前端刷新所有数据
4. 每个API请求 → JWT claims决定当前账户上下文
```
对比Chatwoot:Chatwoot通过`Current.account`全局变量切换,GoChat通过JWT claims显式传递,避免全局状态。
---
## 4. Redis Pub/Sub 实时通信架构
### 4.1 对比 Chatwoot ActionCable
| 特性 | Chatwoot (ActionCable) | GoChat (Redis Pub/Sub + WebSocket) |
|---|---|---|
| 连接管理 | ActionCable Server (Rails内置) | gorilla/websocket + Redis subscriber |
| Channel订阅 | `ConversationChannel`, `AccountChannel` | Redis topic: `account:{id}`, `conversation:{id}` |
| 消息推送 | `broadcast_to` | Redis PUBLISH → WebSocket send |
| 连接认证 | `connect` 方法验证cookie | WebSocket握手时验证JWT |
| 并发 | 每连接一个Redis subscriber | 每账户一个Redis subscriber(共享) |
| 重连 | 客户端自动重连 | 客户端重连+服务器Redis重订阅 |
### 4.2 WebSocket 连接架构
```go
// realtime/hub.go
type Hub struct {
// account_id → set of websocket connections
AccountConns map[uint]*AccountRoom
// conversation_id → set of websocket connections
ConvConns map[uint]*ConvRoom
// Redis subscriber
RedisSub *redis.PubSub
mu sync.RWMutex
}
type AccountRoom struct {
AccountID uint
Conns map[uint]*WSConn // user_id → connection
Sub *redis.PubSub // 订阅 account:{id} topic
}
type WSConn struct {
UserID uint
AccountID uint
Conn *websocket.Conn
Send chan []byte
}
```
### 4.3 Redis Topic 设计
```
Topics:
account:{account_id} — 账户级事件(新对话、通知、状态变化)
conversation:{conv_id} — 对话级事件(新消息、状态流转、打字状态)
inbox:{inbox_id} — Inbox级事件(新对话分配)
user:{user_id} — 用户级事件(个人通知)
captain:{assistant_id} — AI助手事件(企业版)
copilot:{user_id}:{conv_id} — Copilot建议推送(企业版)
```
### 4.4 事件消息格式
```go
// realtime/event.go
type RealtimeEvent struct {
Type string `json:"type"` // message_created, conversation_updated, etc.
ActorID uint `json:"actor_id"`
Resource string `json:"resource"` // conversation, message, notification, etc.
Data json.RawMessage `json:"data"` // 资源JSON
Timestamp time.Time `json:"timestamp"`
}
```
### 4.5 连接流程
```
1. 客户端 WebSocket握手 → ws://host/cable?token=<jwt>
→ 验证JWT → 提取user_id+account_id
→ 注册到Hub.AccountConns[account_id]
2. 客户端订阅对话 → subscribe {conversation_id: N}
→ Hub注册到ConvConns[N]
→ Redis SUBSCRIBE conversation:N
3. 事件到达 → Redis消息 → Hub.Dispatch()
→ 根据topic路由到对应Room
→ Room广播到所有连接的WSConn
→ WSConn.Send channel → websocket.WriteJSON
4. 断连 → Hub注销 → Redis UNSUBSCRIBE
```
---
## 5. 事件分发器设计
### 5.1 对比 Chatwoot Dispatcher/Listener
Chatwoot 采用 **Dispatcher → Listener** 模式:
```
Dispatcher.dispatch(event_name, timestamp, event_data)
→ 构造 Event对象
→ Redis PUBLISH "chatwoot_events:{account_id}"
→ 各Listener订阅Redis channel
→ Listener#process(event) 执行业务逻辑
```
**12个Listener:**
- ActionCableListener → WebSocket推送
- AgentBotListener → 触发Bot响应
- AutomationRuleListener → 触发自动化规则
- CampaignListener → 触发营销活动
- CsatSurveyListener → 发送满意度调查
- HookListener → 触发自定义Webhook
- InstallationWebhookListener → 触发平台Webhook
- NotificationListener → 创建通知
- ParticipationListener → 更新参与状态
- ReportingEventListener → 记录报告事件
- WebhookListener → 发送Webhook回调
- BaseListener → 提供订阅基础设施
### 5.2 GoChat Event Dispatcher 设计
```go
// event/dispatcher.go
// 事件类型枚举
type EventType string
const (
EventMessageCreated EventType = "message.created"
EventMessageUpdated EventType = "message.updated"
EventConversationCreated EventType = "conversation.created"
EventConversationUpdated EventType = "conversation.updated"
EventConversationAssigned EventType = "conversation.assigned"
EventContactCreated EventType = "contact.created"
EventContactUpdated EventType = "contact.updated"
EventAgentAssigned EventType = "agent.assigned"
EventCSATSubmitted EventType = "csat.submitted"
// ... 更多
)
type Event struct {
Type EventType `json:"type"`
AccountID uint `json:"account_id"`
Data json.RawMessage `json:"data"`
Timestamp time.Time `json:"timestamp"`
}
type Dispatcher struct {
Redis *redis.Client
}
func (d *Dispatcher) Dispatch(event Event) error {
// 1. 发布到Redis(异步Listener消费)
topic := fmt.Sprintf("events:account:%d", event.AccountID)
payload, _ := json.Marshal(event)
return d.Redis.Publish(ctx, topic, payload).Err()
}
```
### 5.3 Listener Handler 注册
```go
// event/listener.go
type EventHandler func(event Event) error
type ListenerHub struct {
handlers map[EventType][]EventHandler
}
func RegisterHandler(eventType EventType, handler EventHandler) {
globalHub.handlers[eventType] = append(globalHub.handlers[eventType], handler)
}
// GoChat的Listener注册(替代Chatwoot的独立Listener文件)
func init() {
RegisterHandler(EventMessageCreated, HandleRealtimePush)
RegisterHandler(EventMessageCreated, HandleNotification)
RegisterHandler(EventMessageCreated, HandleWebhook)
RegisterHandler(EventMessageCreated, HandleAutomationRule)
RegisterHandler(EventMessageCreated, HandleReporting)
RegisterHandler(EventMessageCreated, HandleAgentBot)
RegisterHandler(EventConversationCreated, HandleAutoAssignment)
RegisterHandler(EventConversationCreated, HandleRealtimePush)
// ...
}
```
### 5.4 Redis Consumer 启动
```go
// event/consumer.go
func StartConsumer(accountID uint) {
sub := redis.Subscribe(ctx, fmt.Sprintf("events:account:%d", accountID))
for msg := range sub.Channel() {
event := parseEvent(msg.Payload)
handlers := globalHub.handlers[event.Type]
for _, h := range handlers {
go h(event) // 异步执行,不阻塞
}
}
}
```
### 5.5 对比总结
| 方面 | Chatwoot | GoChat |
|---|---|---|
| 事件发布 | Dispatcher → Redis PUBLISH | Dispatcher → Redis PUBLISH(相同) |
| 事件消费 | 12个独立Listener类 | Handler函数注册表(更轻量) |
| 执行方式 | Sidekiq异步Job | goroutine异步执行 |
| 注册方式 | Rails autoload | init()函数静态注册 |
| 水平扩展 | Sidekiq worker进程 | Consumer goroutine per account |
---
## 6. 企业版 SAML SSO 设计
### 6.1 对比 Chatwoot
Chatwoot企业版SAML实现:
- `AccountSamlSettings` 模型存储IdP配置
- `DeviseSamlAuthenticatable` gem处理SAML流程
- `SamlUserBuilder` 构建User对象
- `Saml::UpdateAccountUsersProviderJob` 批量更新provider
### 6.2 GoChat SAML SP 实现
```go
// enterprise/auth/saml.go
type AccountSamlSettings struct {
ID uint `gorm:"primaryKey"`
AccountID uint `gorm:"uniqueIndex;not null"`
IdpEntityID string `gorm:"size:512"`
IdpSsoTargetURL string `gorm:"size:512"`
IdpSloTargetURL string `gorm:"size:512"`
IdpCertificate string `gorm:"type:text"`
SpEntityID string `gorm:"size:512"`
SpAssertionURL string `gorm:"size:512"` // 自动生成
SpX509Certificate string `gorm:"type:text"` // 自签证书
SpPrivateKey string `gorm:"type:text"` // RSA私钥
RoleMappings json.RawMessage `gorm:"type:jsonb"` // SAML属性→角色映射
Active bool `gorm:"default:true"`
}
// SAML 流程:
// 1. SP-initiated: GET /api/v1/auth/saml/{account_id}/login
// → 生成SAML AuthnRequest → 重定向到IdP
// 2. IdP回调: POST /api/v1/auth/saml/{account_id}/callback
// → 解析SAML Response → 验证签名
// → SamlUserBuilder.Create/Find → 签发JWT
// 3. SLO: GET /api/v1/auth/saml/{account_id}/logout
// → 生成SAML LogoutRequest → 重定向到IdP SLO URL
type SamlUserBuilder struct {
Settings AccountSamlSettings
}
func (b *SamlUserBuilder) Build(samlResponse *SamlResponse) (*User, error) {
email := samlResponse.GetAttribute("email")
name := samlResponse.GetAttribute("name")
role := b.mapRole(samlResponse.GetAttribute("role"))
// 查找或创建User
user, err := FindOrCreateUserByEmail(email, name, "saml")
// 创建/更新AccountUser
EnsureAccountUser(user.ID, b.Settings.AccountID, role)
return user, err
}
```
---
## 7. 架构全景图
```
┌─────────────────────────────────────────────────────┐
│ GoChat 单体架构 │
│ │
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ Auth │ │ RBAC │ │ API │ │Event │ │
│ │ JWT │ │Polic │ │ Gin │ │Disp │ │
│ └──────┘ └──────┘ └──────┘ └──────┘ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ Service Layer │ │
│ │ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │ │
│ │ │Conv│ │Msg │ │Cntc│ │Auto│ │ │
│ │ └────┘ └────┘ └────┘ └────┘ │ │
│ │ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │ │
│ │ │Rprt│ │Ntfy│ │Team│ │Capn│ │ │
│ │ └────┘ └────┘ └────┘ └────┘ │ │
│ └─────────────────────────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────┐ ┌──────────┐ ┌───────────┐ │
│ │ GORM/ │ │ Redis │ │ Channel │ │
│ │ PgSQL │ │ Pub/Sub │ │ Registry │ │
│ └─────────┘ └──────────┘ └───────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ WebSocket Hub + Event Listener │ │
│ └─────────────────────────────────────┘ │
│ │
│ 企业版模块: │
│ ┌──────────┐ ┌────────┐ ┌──────┐ ┌──────┐ │
│ │ SAML SSO │ │CustomR │ │ SLA │ │Call │ │
│ └──────────┘ └────────┘ └──────┘ └──────┘ │
│ ┌──────────┐ ┌────────┐ ┌──────┐ │
│ │ Captain │ │Copilot │ │Audit │ │
│ └──────────┘ └────────┘ └──────┘ │
└─────────────────────────────────────────────────────┘
```
---
## 8. 关键设计决策总结
| # | 决策 | 原因 | 对比Chatwoot |
|---|---|---|---|
| 1 | JWT替代DeviseTokenAuth | Go无对应gem,JWT更标准 | 多token→单token+refresh |
| 2 | PolicyContext替代Pundit | 统一权限检查入口 | 12个Policy类→1个PolicyContext |
| 3 | Handler注册表替代Listener类 | Go无Rails autoload,函数注册更轻量 | 12个Listener→Handler map |
| 4 | JWT claims传递账户上下文 | 避免全局状态 | Current.account全局→JWT claims |
| 5 | Redis Pub/Sub+WebSocket | Go生态成熟选择 | ActionCable→自定义WS Hub |
| 6 | goroutine异步替代Sidekiq | 单体架构内异步足够 | Sidekiq进程→goroutine |
| 7 | 共享Redis subscriber | 减少Redis连接数 | 每连接1subscriber→每账户1 |
| 8 | 企业版SAML自实现 | Go无成熟SAML SP库 | DeviseSamlAuthenticatable→自实现 |
---
> **下一步:** P2B(数据库设计)和 P2C(路由设计)完成后,P2架构设计阶段完整收官。