Initialize LineUp app server
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
package modules
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// AgentModule is the app-server-facing control plane for external Agent
|
||||
// adapters. Message delivery remains in MessageModule; this module makes the
|
||||
// adapter's liveness and delivery cursor observable without exposing Hermes.
|
||||
type AgentModule struct {
|
||||
vp *viper.Viper
|
||||
mu sync.RWMutex
|
||||
entries map[string]agentHeartbeat
|
||||
}
|
||||
|
||||
type agentHeartbeat struct {
|
||||
Status string `json:"status"`
|
||||
LastMessageSeq uint64 `json:"last_message_seq"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type agentHeartbeatRequest struct {
|
||||
Status string `json:"status" binding:"required"`
|
||||
LastMessageSeq uint64 `json:"last_message_seq"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
|
||||
func NewAgentModule(vp *viper.Viper) *AgentModule {
|
||||
return &AgentModule{vp: vp, entries: make(map[string]agentHeartbeat)}
|
||||
}
|
||||
|
||||
func (m *AgentModule) RegisterRoutes(r *gin.Engine) {
|
||||
r.GET("/agents/:uid/health", m.handleHealth)
|
||||
r.POST("/agents/:uid/heartbeat", m.handleHeartbeat)
|
||||
}
|
||||
|
||||
func (m *AgentModule) configuredSecret() string {
|
||||
return m.vp.GetString("agent.shared_secret")
|
||||
}
|
||||
|
||||
func (m *AgentModule) authorize(c *gin.Context) bool {
|
||||
secret := m.configuredSecret()
|
||||
if secret == "" {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Agent heartbeat 未配置 shared_secret"})
|
||||
return false
|
||||
}
|
||||
provided := c.GetHeader("X-LineUp-Agent-Secret")
|
||||
if subtle.ConstantTimeCompare([]byte(provided), []byte(secret)) != 1 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Agent heartbeat 认证失败"})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *AgentModule) handleHeartbeat(c *gin.Context) {
|
||||
if !m.authorize(c) {
|
||||
return
|
||||
}
|
||||
var req agentHeartbeatRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: status必填"})
|
||||
return
|
||||
}
|
||||
entry := agentHeartbeat{
|
||||
Status: req.Status, LastMessageSeq: req.LastMessageSeq, Detail: req.Detail, UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.entries[c.Param("uid")] = entry
|
||||
m.mu.Unlock()
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "updated_at": entry.UpdatedAt})
|
||||
}
|
||||
|
||||
func (m *AgentModule) handleHealth(c *gin.Context) {
|
||||
uid := c.Param("uid")
|
||||
m.mu.RLock()
|
||||
entry, found := m.entries[uid]
|
||||
m.mu.RUnlock()
|
||||
if !found {
|
||||
c.JSON(http.StatusOK, gin.H{"uid": uid, "status": "offline", "known": false})
|
||||
return
|
||||
}
|
||||
// A worker normally reports once per poll. Keep a generous threshold so a
|
||||
// slow Hermes turn does not look dead while it is still being supervised.
|
||||
online := time.Since(entry.UpdatedAt) <= 45*time.Second
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"uid": uid, "status": entry.Status, "known": true, "online": online,
|
||||
"last_message_seq": entry.LastMessageSeq, "detail": entry.Detail, "updated_at": entry.UpdatedAt,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package modules
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
func TestAgentHeartbeatRequiresConfiguredSecret(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
vp := viper.New()
|
||||
vp.Set("agent.shared_secret", "test-secret")
|
||||
router := gin.New()
|
||||
NewAgentModule(vp).RegisterRoutes(router)
|
||||
|
||||
body := []byte(`{"status":"running","last_message_seq":42}`)
|
||||
unauthorized := httptest.NewRequest(http.MethodPost, "/agents/agent_hermes_main/heartbeat", bytes.NewReader(body))
|
||||
unauthorized.Header.Set("Content-Type", "application/json")
|
||||
unauthorizedResult := httptest.NewRecorder()
|
||||
router.ServeHTTP(unauthorizedResult, unauthorized)
|
||||
if unauthorizedResult.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauthorized status = %d, want %d", unauthorizedResult.Code, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
heartbeat := httptest.NewRequest(http.MethodPost, "/agents/agent_hermes_main/heartbeat", bytes.NewReader(body))
|
||||
heartbeat.Header.Set("Content-Type", "application/json")
|
||||
heartbeat.Header.Set("X-LineUp-Agent-Secret", "test-secret")
|
||||
heartbeatResult := httptest.NewRecorder()
|
||||
router.ServeHTTP(heartbeatResult, heartbeat)
|
||||
if heartbeatResult.Code != http.StatusOK {
|
||||
t.Fatalf("heartbeat status = %d, body = %s", heartbeatResult.Code, heartbeatResult.Body.String())
|
||||
}
|
||||
|
||||
health := httptest.NewRequest(http.MethodGet, "/agents/agent_hermes_main/health", nil)
|
||||
healthResult := httptest.NewRecorder()
|
||||
router.ServeHTTP(healthResult, health)
|
||||
if healthResult.Code != http.StatusOK {
|
||||
t.Fatalf("health status = %d, body = %s", healthResult.Code, healthResult.Body.String())
|
||||
}
|
||||
if !bytes.Contains(healthResult.Body.Bytes(), []byte(`"online":true`)) {
|
||||
t.Fatalf("health body = %s, want online=true", healthResult.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package modules
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/lineup/app-server/internal/wkapi"
|
||||
)
|
||||
|
||||
// ChannelModule handles channel creation and query.
|
||||
type ChannelModule struct {
|
||||
wk *wkapi.Client
|
||||
}
|
||||
|
||||
// NewChannelModule creates a new ChannelModule.
|
||||
func NewChannelModule(wk *wkapi.Client) *ChannelModule {
|
||||
return &ChannelModule{wk: wk}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers channel routes.
|
||||
func (m *ChannelModule) RegisterRoutes(r *gin.Engine) {
|
||||
r.POST("/channels", m.handleCreate)
|
||||
r.GET("/channels/:channel_id", m.handleGet)
|
||||
r.POST("/channels/subscriber_add", m.handleSubscriberAdd)
|
||||
}
|
||||
|
||||
type createChannelRequest struct {
|
||||
ChannelID string `json:"channel_id" binding:"required"`
|
||||
ChannelType uint8 `json:"channel_type" binding:"required"`
|
||||
Subscribers []string `json:"subscribers"`
|
||||
}
|
||||
|
||||
func (m *ChannelModule) handleCreate(c *gin.Context) {
|
||||
var req createChannelRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: channel_id和channel_type必填"})
|
||||
return
|
||||
}
|
||||
if err := m.wk.UpsertChannel(req.ChannelID, req.ChannelType, req.Subscribers); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建频道失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"channel_id": req.ChannelID,
|
||||
"channel_type": req.ChannelType,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *ChannelModule) handleGet(c *gin.Context) {
|
||||
channelID := c.Param("channel_id")
|
||||
_ = channelID
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"channel_id": channelID,
|
||||
"channel_type": 2,
|
||||
})
|
||||
}
|
||||
|
||||
type subscriberAddRequest struct {
|
||||
ChannelID string `json:"channel_id" binding:"required"`
|
||||
ChannelType uint8 `json:"channel_type" binding:"required"`
|
||||
UIDs []string `json:"uids" binding:"required"`
|
||||
}
|
||||
|
||||
func (m *ChannelModule) handleSubscriberAdd(c *gin.Context) {
|
||||
var req subscriberAddRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: channel_id, channel_type, uids必填"})
|
||||
return
|
||||
}
|
||||
if err := m.wk.SubscriberAdd(req.ChannelID, req.ChannelType, req.UIDs); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "添加订阅者失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package modules
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/lineup/app-server/internal"
|
||||
"github.com/lineup/app-server/internal/wkapi"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
}
|
||||
|
||||
// ClientModule manages user WebSocket connections for real-time push.
|
||||
type ClientModule struct {
|
||||
hub *internal.Hub
|
||||
wk *wkapi.Client
|
||||
}
|
||||
|
||||
// NewClientModule creates a new ClientModule.
|
||||
func NewClientModule(hub *internal.Hub, wk *wkapi.Client) *ClientModule {
|
||||
return &ClientModule{hub: hub, wk: wk}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers WebSocket routes.
|
||||
func (m *ClientModule) RegisterRoutes(r *gin.Engine) {
|
||||
r.GET("/ws", m.handleWS)
|
||||
}
|
||||
|
||||
func (m *ClientModule) handleWS(c *gin.Context) {
|
||||
token := c.Query("token")
|
||||
uid := c.Query("uid")
|
||||
if token == "" || uid == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: token和uid必填"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify token with WuKongIM (register if not exists, or validate)
|
||||
if err := m.wk.RegisterToken(uid, token); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token验证失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
log.Printf("WebSocket升级失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
m.hub.Register(uid, conn)
|
||||
log.Printf("客户端连接: uid=%s", uid)
|
||||
|
||||
// Send a welcome message
|
||||
welcome, _ := json.Marshal(map[string]interface{}{
|
||||
"event": "connected",
|
||||
"uid": uid,
|
||||
})
|
||||
conn.WriteMessage(websocket.TextMessage, welcome)
|
||||
|
||||
// Read loop (keep connection alive, handle pings)
|
||||
go m.readLoop(uid, conn)
|
||||
}
|
||||
|
||||
func (m *ClientModule) readLoop(uid string, conn *websocket.Conn) {
|
||||
defer func() {
|
||||
m.hub.Unregister(uid)
|
||||
conn.Close()
|
||||
log.Printf("客户端断开: uid=%s", uid)
|
||||
}()
|
||||
|
||||
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
conn.SetPongHandler(func(string) error {
|
||||
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
return nil
|
||||
})
|
||||
|
||||
for {
|
||||
_, _, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package modules
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/lineup/app-server/internal/wkapi"
|
||||
)
|
||||
|
||||
// MessageModule handles message sending and syncing.
|
||||
type MessageModule struct {
|
||||
wk *wkapi.Client
|
||||
}
|
||||
|
||||
// NewMessageModule creates a new MessageModule.
|
||||
func NewMessageModule(wk *wkapi.Client) *MessageModule {
|
||||
return &MessageModule{wk: wk}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers message routes.
|
||||
func (m *MessageModule) RegisterRoutes(r *gin.Engine) {
|
||||
r.POST("/messages/send", m.handleSend)
|
||||
r.GET("/messages/sync", m.handleSync)
|
||||
r.POST("/messages/sync", m.handleSyncPost)
|
||||
}
|
||||
|
||||
type sendMessageRequest struct {
|
||||
FromUID string `json:"from_uid" binding:"required"`
|
||||
ChannelID string `json:"channel_id" binding:"required"`
|
||||
ChannelType uint8 `json:"channel_type" binding:"required"`
|
||||
Payload string `json:"payload" binding:"required"` // raw text, will be base64-encoded
|
||||
}
|
||||
|
||||
func (m *MessageModule) handleSend(c *gin.Context) {
|
||||
var req sendMessageRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: from_uid, channel_id, channel_type, payload必填"})
|
||||
return
|
||||
}
|
||||
|
||||
// Base64-encode the payload for WuKongIM
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte(req.Payload))
|
||||
|
||||
resp, err := m.wk.SendMessage(wkapi.SendMessageRequest{
|
||||
FromUID: req.FromUID,
|
||||
ChannelID: req.ChannelID,
|
||||
ChannelType: req.ChannelType,
|
||||
ClientMsgNo: fmt.Sprintf("msg_%d", time.Now().UnixNano()),
|
||||
Payload: encoded,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "发送消息失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message_id": resp.MessageID,
|
||||
"message_seq": resp.MessageSeq,
|
||||
})
|
||||
}
|
||||
|
||||
type syncMessagesRequest struct {
|
||||
ChannelID string `form:"channel_id" binding:"required"`
|
||||
ChannelType uint8 `form:"channel_type" binding:"required"`
|
||||
StartMessageSeq uint64 `form:"start_message_seq"`
|
||||
Limit int `form:"limit"`
|
||||
}
|
||||
|
||||
func (m *MessageModule) handleSync(c *gin.Context) {
|
||||
var req syncMessagesRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: channel_id, channel_type必填"})
|
||||
return
|
||||
}
|
||||
m.doSync(c, req.ChannelID, req.ChannelType, req.StartMessageSeq, req.Limit, "")
|
||||
}
|
||||
|
||||
type syncMessagesPostRequest struct {
|
||||
ChannelID string `json:"channel_id" binding:"required"`
|
||||
ChannelType uint8 `json:"channel_type" binding:"required"`
|
||||
LoginUID string `json:"login_uid"`
|
||||
StartMessageSeq uint64 `json:"start_message_seq"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
func (m *MessageModule) handleSyncPost(c *gin.Context) {
|
||||
var req syncMessagesPostRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: channel_id, channel_type必填"})
|
||||
return
|
||||
}
|
||||
m.doSync(c, req.ChannelID, req.ChannelType, req.StartMessageSeq, req.Limit, req.LoginUID)
|
||||
}
|
||||
|
||||
func (m *MessageModule) doSync(c *gin.Context, channelID string, channelType uint8, startSeq uint64, limit int, loginUID string) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
resp, err := m.wk.SyncChannelMessages(wkapi.SyncChannelMessagesRequest{
|
||||
LoginUID: loginUID,
|
||||
ChannelID: channelID,
|
||||
ChannelType: channelType,
|
||||
StartMessageSeq: startSeq,
|
||||
Limit: limit,
|
||||
PullMode: 1,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "同步消息失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Decode base64 payloads for the response
|
||||
type msgItem struct {
|
||||
MessageID uint64 `json:"message_id"`
|
||||
MessageSeq uint64 `json:"message_seq"`
|
||||
FromUID string `json:"from_uid"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
Payload string `json:"payload"`
|
||||
Timestamp int32 `json:"timestamp"`
|
||||
}
|
||||
messages := make([]msgItem, 0, len(resp.Messages))
|
||||
for _, m := range resp.Messages {
|
||||
decoded, _ := base64.StdEncoding.DecodeString(m.Payload)
|
||||
messages = append(messages, msgItem{
|
||||
MessageID: m.MessageID,
|
||||
MessageSeq: m.MessageSeq,
|
||||
FromUID: m.FromUID,
|
||||
ChannelID: m.ChannelID,
|
||||
Payload: string(decoded),
|
||||
Timestamp: m.Timestamp,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"start_message_seq": resp.StartMessageSeq,
|
||||
"end_message_seq": resp.EndMessageSeq,
|
||||
"more": resp.More,
|
||||
"messages": messages,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package modules
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/lineup/app-server/internal"
|
||||
"github.com/lineup/app-server/internal/wkapi"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// SetupRoutes 注册所有模块路由
|
||||
func SetupRoutes(r *gin.Engine, vp *viper.Viper) {
|
||||
// 共享依赖
|
||||
hub := internal.NewHub()
|
||||
wk := wkapi.New(vp.GetString("wukongim.apiURL"))
|
||||
|
||||
// 自动创建 Agent 频道
|
||||
agentChannelID := vp.GetString("agent.channelID")
|
||||
agentUID := vp.GetString("agent.uid")
|
||||
agentChannelType := uint8(vp.GetInt("agent.channelType"))
|
||||
if agentChannelType == 0 {
|
||||
agentChannelType = 2
|
||||
}
|
||||
if agentChannelID != "" && agentUID != "" {
|
||||
if err := wk.UpsertChannel(agentChannelID, agentChannelType, []string{agentUID}); err != nil {
|
||||
// ignore upsert error, channel may already exist
|
||||
_ = err
|
||||
}
|
||||
}
|
||||
|
||||
// 健康检查
|
||||
r.GET("/healthz", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
// 用户模块
|
||||
userModule := &UserModule{vp: vp, wk: wk}
|
||||
userModule.RegisterRoutes(r)
|
||||
|
||||
// Channel 模块
|
||||
channelModule := NewChannelModule(wk)
|
||||
channelModule.RegisterRoutes(r)
|
||||
|
||||
// Message 模块
|
||||
messageModule := NewMessageModule(wk)
|
||||
messageModule.RegisterRoutes(r)
|
||||
|
||||
// Agent 控制面:外部 Hermes Adapter 的存活与消费游标。
|
||||
agentModule := NewAgentModule(vp)
|
||||
agentModule.RegisterRoutes(r)
|
||||
|
||||
// Webhook 模块
|
||||
webhookModule := NewWebhookModule(hub)
|
||||
webhookModule.RegisterRoutes(r)
|
||||
|
||||
// Client WebSocket 模块
|
||||
clientModule := NewClientModule(hub, wk)
|
||||
clientModule.RegisterRoutes(r)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package modules
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/lineup/app-server/internal/wkapi"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// UserModule 用户模块: 登录 / 注册
|
||||
type UserModule struct {
|
||||
vp *viper.Viper
|
||||
wk *wkapi.Client
|
||||
}
|
||||
|
||||
// RegisterRoutes 注册路由
|
||||
func (m *UserModule) RegisterRoutes(r *gin.Engine) {
|
||||
r.POST("/login", m.handleLogin)
|
||||
}
|
||||
|
||||
// LoginRequest 登录请求
|
||||
type LoginRequest struct {
|
||||
Phone string `json:"phone" binding:"required"`
|
||||
Code string `json:"code" binding:"required"`
|
||||
}
|
||||
|
||||
// LoginResponse 登录响应
|
||||
type LoginResponse struct {
|
||||
UID string `json:"uid"`
|
||||
Token string `json:"token"`
|
||||
IMAddr string `json:"im_addr"` // WuKongIM WS地址
|
||||
AgentUID string `json:"agent_uid"` // Agent的UID
|
||||
ChannelID string `json:"channel_id"` // Agent频道ID
|
||||
ChannelType uint8 `json:"channel_type"` // Agent频道类型
|
||||
}
|
||||
|
||||
// handleLogin 处理登录请求
|
||||
func (m *UserModule) handleLogin(c *gin.Context) {
|
||||
var req LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: phone和code必填"})
|
||||
return
|
||||
}
|
||||
|
||||
// 生成用户 UID
|
||||
uid := fmt.Sprintf("user_%s", req.Phone)
|
||||
|
||||
// 生成 Token (随机32字节)
|
||||
token := generateToken()
|
||||
|
||||
// 注册 Token 到 WuKongIM
|
||||
if err := m.wk.RegisterToken(uid, token); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("注册Token失败: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
// 确保 Agent 频道存在并将用户加入订阅者
|
||||
agentChannelID := m.vp.GetString("agent.channelID")
|
||||
agentUID := m.vp.GetString("agent.uid")
|
||||
agentChannelType := uint8(m.vp.GetInt("agent.channelType"))
|
||||
if agentChannelType == 0 {
|
||||
agentChannelType = 2
|
||||
}
|
||||
// 创建/更新频道,确保 Agent 和用户都是订阅者
|
||||
m.wk.UpsertChannel(agentChannelID, agentChannelType, []string{agentUID})
|
||||
// 将用户加入频道订阅者
|
||||
m.wk.SubscriberAdd(agentChannelID, agentChannelType, []string{uid})
|
||||
|
||||
// 获取 WebSocket 地址
|
||||
route, err := m.wk.GetRoute()
|
||||
wsAddr := m.vp.GetString("wukongim.wsAddr")
|
||||
if err == nil && route.WSAddr != "" {
|
||||
wsAddr = route.WSAddr
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, LoginResponse{
|
||||
UID: uid,
|
||||
Token: token,
|
||||
IMAddr: wsAddr,
|
||||
AgentUID: m.vp.GetString("agent.uid"),
|
||||
ChannelID: m.vp.GetString("agent.channelID"),
|
||||
ChannelType: uint8(m.vp.GetInt("agent.channelType")),
|
||||
})
|
||||
}
|
||||
|
||||
// generateToken 生成32字节随机Token
|
||||
func generateToken() string {
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package modules
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/lineup/app-server/internal"
|
||||
)
|
||||
|
||||
// WebhookModule receives WuKongIM 3.0 HTTP webhook callbacks.
|
||||
type WebhookModule struct {
|
||||
hub *internal.Hub
|
||||
}
|
||||
|
||||
// NewWebhookModule creates a new WebhookModule.
|
||||
func NewWebhookModule(hub *internal.Hub) *WebhookModule {
|
||||
return &WebhookModule{hub: hub}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers webhook routes.
|
||||
func (m *WebhookModule) RegisterRoutes(r *gin.Engine) {
|
||||
r.POST("/v1/webhook", m.handleWebhook)
|
||||
}
|
||||
|
||||
// wkMsgResp mirrors WuKongIM 3.0's msg.notify message format.
|
||||
type wkMsgResp struct {
|
||||
Header wkMsgHeader `json:"header"`
|
||||
Setting uint8 `json:"setting"`
|
||||
MessageID uint64 `json:"message_id"`
|
||||
MessageSeq uint64 `json:"message_seq"`
|
||||
FromUID string `json:"from_uid"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
ChannelType uint8 `json:"channel_type"`
|
||||
Timestamp int32 `json:"timestamp"`
|
||||
Payload string `json:"payload"` // base64-encoded
|
||||
}
|
||||
|
||||
type wkMsgHeader struct {
|
||||
NoPersist int `json:"no_persist"`
|
||||
RedDot int `json:"red_dot"`
|
||||
SyncOnce int `json:"sync_once"`
|
||||
}
|
||||
|
||||
// wkOfflineResp mirrors WuKongIM 3.0's msg.offline format.
|
||||
type wkOfflineResp struct {
|
||||
wkMsgResp
|
||||
ToUIDs []string `json:"to_uids"`
|
||||
Compress string `json:"compress"`
|
||||
CompressToUIDs string `json:"compress_to_uids"`
|
||||
SourceID int64 `json:"source_id"`
|
||||
}
|
||||
|
||||
func (m *WebhookModule) handleWebhook(c *gin.Context) {
|
||||
event := c.Query("event")
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "读取请求体失败"})
|
||||
return
|
||||
}
|
||||
|
||||
switch event {
|
||||
case "msg.notify":
|
||||
m.handleMsgNotify(body)
|
||||
case "msg.offline":
|
||||
m.handleMsgOffline(body)
|
||||
case "user.onlinestatus":
|
||||
m.handleOnlineStatus(body)
|
||||
default:
|
||||
// Unknown event, still return 200 per WK webhook contract
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
func (m *WebhookModule) handleMsgNotify(data []byte) {
|
||||
var msgs []wkMsgResp
|
||||
if err := json.Unmarshal(data, &msgs); err != nil {
|
||||
return
|
||||
}
|
||||
for _, msg := range msgs {
|
||||
m.pushToClient(msg.ChannelID, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *WebhookModule) handleMsgOffline(data []byte) {
|
||||
var msg wkOfflineResp
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
return
|
||||
}
|
||||
// Push to each offline recipient
|
||||
for _, uid := range msg.ToUIDs {
|
||||
m.pushToClient(uid, msg.wkMsgResp)
|
||||
}
|
||||
// Handle gzip-compressed UIDs
|
||||
if msg.Compress == "gzip" && len(msg.CompressToUIDs) > 0 {
|
||||
decoded, err := base64.StdEncoding.DecodeString(msg.CompressToUIDs)
|
||||
if err == nil {
|
||||
var uids []string
|
||||
if json.Unmarshal(decoded, &uids) == nil {
|
||||
for _, uid := range uids {
|
||||
m.pushToClient(uid, msg.wkMsgResp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *WebhookModule) handleOnlineStatus(data []byte) {
|
||||
// Online status format: ["uid-flag-status", ...]
|
||||
var statuses []string
|
||||
if err := json.Unmarshal(data, &statuses); err != nil {
|
||||
return
|
||||
}
|
||||
// For now, just log. Could be used to notify clients.
|
||||
_ = statuses
|
||||
}
|
||||
|
||||
// pushToClient decodes the payload and pushes to the connected client.
|
||||
func (m *WebhookModule) pushToClient(uid string, msg wkMsgResp) {
|
||||
if m.hub == nil {
|
||||
return
|
||||
}
|
||||
decoded, _ := base64.StdEncoding.DecodeString(msg.Payload)
|
||||
pushMsg := map[string]interface{}{
|
||||
"event": "message",
|
||||
"message_id": msg.MessageID,
|
||||
"message_seq": msg.MessageSeq,
|
||||
"from_uid": msg.FromUID,
|
||||
"channel_id": msg.ChannelID,
|
||||
"channel_type": msg.ChannelType,
|
||||
"payload": string(decoded),
|
||||
"timestamp": msg.Timestamp,
|
||||
}
|
||||
pushData, _ := json.Marshal(pushMsg)
|
||||
if err := m.hub.PushToClient(uid, pushData); err != nil {
|
||||
// Client not connected, that's OK
|
||||
_ = err
|
||||
}
|
||||
// Also push to the other participant if it's a person channel
|
||||
// This ensures both sides get the message
|
||||
if msg.ChannelType == 1 && msg.FromUID != uid {
|
||||
_ = m.hub.PushToClient(msg.FromUID, pushData)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure fmt is used (for potential future logging)
|
||||
var _ = fmt.Sprintf
|
||||
Reference in New Issue
Block a user