From 635c1760ad18742efd1b33131a9bab68e403b010 Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 16 Aug 2026 12:19:32 +0800 Subject: [PATCH] feat(message-gateway): add user bind and unbind APIs --- docs/docs.go | 254 +++++++++++++++++++ docs/swagger.json | 254 +++++++++++++++++++ docs/swagger.yaml | 155 +++++++++++ internal/apps/message_gateway/errs.go | 16 ++ internal/apps/message_gateway/handlers.go | 136 ++++++++++ internal/apps/message_gateway/logics.go | 158 ++++++++++++ internal/apps/message_gateway/logics_test.go | 106 ++++++++ internal/apps/message_gateway/routers.go | 22 ++ internal/router/v1/message_gateway.go | 14 + internal/router/v1/v1.go | 3 + 10 files changed, 1118 insertions(+) create mode 100644 internal/apps/message_gateway/errs.go create mode 100644 internal/apps/message_gateway/handlers.go create mode 100644 internal/apps/message_gateway/logics.go create mode 100644 internal/apps/message_gateway/logics_test.go create mode 100644 internal/apps/message_gateway/routers.go create mode 100644 internal/router/v1/message_gateway.go diff --git a/docs/docs.go b/docs/docs.go index c98cead3..658e7fad 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -4574,6 +4574,205 @@ const docTemplate = `{ } } }, + "/api/v1/message-gateway/bindings": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "Returns the current user's bound messaging channels", + "produces": [ + "application/json" + ], + "tags": [ + "message-gateway" + ], + "summary": "List message gateway bindings", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/message_gateway.BindingDTO" + } + } + } + } + ] + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "Binds the current user to a platform identity using a one-time pairing code", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "message-gateway" + ], + "summary": "Bind a messaging channel", + "parameters": [ + { + "description": "bind body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/message_gateway.BindRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/message_gateway.BindingDTO" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/message-gateway/bindings/{id}": { + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "Removes a binding owned by the current user", + "produces": [ + "application/json" + ], + "tags": [ + "message-gateway" + ], + "summary": "Unbind a messaging channel", + "parameters": [ + { + "type": "integer", + "description": "binding id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/message-gateway/channels": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "Returns enabled system bots the current user can pair with", + "produces": [ + "application/json" + ], + "tags": [ + "message-gateway" + ], + "summary": "List enabled messaging channels", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/message_gateway.PublicChannelDTO" + } + } + } + } + ] + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, "/api/v1/oauth/callback": { "post": { "description": "接收前端传回的 state 和 code,完成 OAuth/OIDC 认证并建立会话。支持登录(login)和账号绑定(bind)两种场景。", @@ -6432,6 +6631,46 @@ const docTemplate = `{ } } }, + "message_gateway.BindRequest": { + "type": "object", + "properties": { + "channel_id": { + "type": "string" + }, + "code": { + "type": "string" + } + } + }, + "message_gateway.BindingDTO": { + "type": "object", + "properties": { + "channel_id": { + "type": "string", + "example": "0" + }, + "channel_name": { + "type": "string" + }, + "channel_type": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "string", + "example": "0" + }, + "platform_user_id": { + "type": "string" + }, + "user_id": { + "type": "string", + "example": "0" + } + } + }, "message_gateway.ChannelDTO": { "type": "object", "properties": { @@ -6537,6 +6776,21 @@ const docTemplate = `{ } } }, + "message_gateway.PublicChannelDTO": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "0" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, "message_gateway.UpdateChannelRequest": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 08f794c0..6e49f134 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -4567,6 +4567,205 @@ } } }, + "/api/v1/message-gateway/bindings": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "Returns the current user's bound messaging channels", + "produces": [ + "application/json" + ], + "tags": [ + "message-gateway" + ], + "summary": "List message gateway bindings", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/message_gateway.BindingDTO" + } + } + } + } + ] + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "Binds the current user to a platform identity using a one-time pairing code", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "message-gateway" + ], + "summary": "Bind a messaging channel", + "parameters": [ + { + "description": "bind body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/message_gateway.BindRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/message_gateway.BindingDTO" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/message-gateway/bindings/{id}": { + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "Removes a binding owned by the current user", + "produces": [ + "application/json" + ], + "tags": [ + "message-gateway" + ], + "summary": "Unbind a messaging channel", + "parameters": [ + { + "type": "integer", + "description": "binding id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/message-gateway/channels": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "Returns enabled system bots the current user can pair with", + "produces": [ + "application/json" + ], + "tags": [ + "message-gateway" + ], + "summary": "List enabled messaging channels", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/message_gateway.PublicChannelDTO" + } + } + } + } + ] + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, "/api/v1/oauth/callback": { "post": { "description": "接收前端传回的 state 和 code,完成 OAuth/OIDC 认证并建立会话。支持登录(login)和账号绑定(bind)两种场景。", @@ -6425,6 +6624,46 @@ } } }, + "message_gateway.BindRequest": { + "type": "object", + "properties": { + "channel_id": { + "type": "string" + }, + "code": { + "type": "string" + } + } + }, + "message_gateway.BindingDTO": { + "type": "object", + "properties": { + "channel_id": { + "type": "string", + "example": "0" + }, + "channel_name": { + "type": "string" + }, + "channel_type": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "string", + "example": "0" + }, + "platform_user_id": { + "type": "string" + }, + "user_id": { + "type": "string", + "example": "0" + } + } + }, "message_gateway.ChannelDTO": { "type": "object", "properties": { @@ -6530,6 +6769,21 @@ } } }, + "message_gateway.PublicChannelDTO": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "0" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, "message_gateway.UpdateChannelRequest": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 90b95197..2398208e 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -327,6 +327,33 @@ definitions: date: type: string type: object + message_gateway.BindRequest: + properties: + channel_id: + type: string + code: + type: string + type: object + message_gateway.BindingDTO: + properties: + channel_id: + example: "0" + type: string + channel_name: + type: string + channel_type: + type: string + created_at: + type: string + id: + example: "0" + type: string + platform_user_id: + type: string + user_id: + example: "0" + type: string + type: object message_gateway.ChannelDTO: properties: app_id: @@ -396,6 +423,16 @@ definitions: type: type: string type: object + message_gateway.PublicChannelDTO: + properties: + id: + example: "0" + type: string + name: + type: string + type: + type: string + type: object message_gateway.UpdateChannelRequest: properties: app_id: @@ -4252,6 +4289,124 @@ paths: summary: Sample Hello API tags: - custom + /api/v1/message-gateway/bindings: + get: + description: Returns the current user's bound messaging channels + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/message_gateway.BindingDTO' + type: array + type: object + "401": + description: Unauthorized + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: List message gateway bindings + tags: + - message-gateway + post: + consumes: + - application/json + description: Binds the current user to a platform identity using a one-time + pairing code + parameters: + - description: bind body + in: body + name: request + required: true + schema: + $ref: '#/definitions/message_gateway.BindRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/message_gateway.BindingDTO' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.Any' + "409": + description: Conflict + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: Bind a messaging channel + tags: + - message-gateway + /api/v1/message-gateway/bindings/{id}: + delete: + description: Removes a binding owned by the current user + parameters: + - description: binding id + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.Any' + "403": + description: Forbidden + schema: + $ref: '#/definitions/response.Any' + "404": + description: Not Found + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: Unbind a messaging channel + tags: + - message-gateway + /api/v1/message-gateway/channels: + get: + description: Returns enabled system bots the current user can pair with + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/message_gateway.PublicChannelDTO' + type: array + type: object + "401": + description: Unauthorized + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: List enabled messaging channels + tags: + - message-gateway /api/v1/oauth/{source}/authorize: get: description: 根据指定认证源名称发起 OAuth 授权,支持 purpose 参数用于区分登录和账号绑定场景。认证源必须已启用。 diff --git a/internal/apps/message_gateway/errs.go b/internal/apps/message_gateway/errs.go new file mode 100644 index 00000000..feb46b52 --- /dev/null +++ b/internal/apps/message_gateway/errs.go @@ -0,0 +1,16 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package message_gateway + +import "errors" + +var ( + errCodeInvalid = errors.New("invalid or expired pairing code") + errChannelMismatch = errors.New("pairing code does not match channel") + errPlatformAlreadyBound = errors.New("this platform account is already bound") + errBindingNotFound = errors.New("binding not found") + errBindingForbidden = errors.New("cannot unbind another user's binding") + errChannelIDRequired = errors.New("channel_id is required") + errChannelDisabled = errors.New("channel is not enabled") +) diff --git a/internal/apps/message_gateway/handlers.go b/internal/apps/message_gateway/handlers.go new file mode 100644 index 00000000..b30470ea --- /dev/null +++ b/internal/apps/message_gateway/handlers.go @@ -0,0 +1,136 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package message_gateway + +import ( + "errors" + "net/http" + "strconv" + + "github.com/Rain-kl/Wavelet/internal/apps/oauth" + "github.com/Rain-kl/Wavelet/internal/model" + "github.com/Rain-kl/Wavelet/internal/shared/response" + "github.com/gin-gonic/gin" +) + +func currentUser(c *gin.Context) (*model.User, bool) { + return oauth.GetFromContext[*model.User](c, oauth.UserObjKey) +} + +// ListChannels lists enabled channels a user can bind. +// @Summary List enabled messaging channels +// @Description Returns enabled system bots the current user can pair with +// @Tags message-gateway +// @Produce json +// @Security SessionCookie +// @Success 200 {object} response.Any{data=[]PublicChannelDTO} +// @Failure 401 {object} response.Any +// @Router /api/v1/message-gateway/channels [get] +func ListChannels(c *gin.Context) { + if user, ok := currentUser(c); !ok || user == nil { + response.AbortUnauthorized(c, "login required") + return + } + rows, err := listEnabledPublicChannels(c.Request.Context()) + if err != nil { + response.AbortInternal(c, err.Error()) + return + } + c.JSON(http.StatusOK, response.OK(rows)) +} + +// ListBindings lists the current user's bot bindings. +// @Summary List message gateway bindings +// @Description Returns the current user's bound messaging channels +// @Tags message-gateway +// @Produce json +// @Security SessionCookie +// @Success 200 {object} response.Any{data=[]BindingDTO} +// @Failure 401 {object} response.Any +// @Router /api/v1/message-gateway/bindings [get] +func ListBindings(c *gin.Context) { + user, ok := currentUser(c) + if !ok || user == nil { + response.AbortUnauthorized(c, "login required") + return + } + rows, err := listUserBindings(c.Request.Context(), user.ID) + if err != nil { + response.AbortInternal(c, err.Error()) + return + } + c.JSON(http.StatusOK, response.OK(rows)) +} + +// BindBinding consumes a pairing code and binds the platform identity. +// @Summary Bind a messaging channel +// @Description Binds the current user to a platform identity using a one-time pairing code +// @Tags message-gateway +// @Accept json +// @Produce json +// @Security SessionCookie +// @Param request body BindRequest true "bind body" +// @Success 200 {object} response.Any{data=BindingDTO} +// @Failure 400 {object} response.Any +// @Failure 409 {object} response.Any +// @Router /api/v1/message-gateway/bindings [post] +func BindBinding(c *gin.Context) { + user, ok := currentUser(c) + if !ok || user == nil { + response.AbortUnauthorized(c, "login required") + return + } + var req BindRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + dto, err := bindChannel(c.Request.Context(), user.ID, req) + if err != nil { + if errors.Is(err, errPlatformAlreadyBound) { + response.AbortConflict(c, err.Error()) + return + } + response.AbortBadRequest(c, err.Error()) + return + } + c.JSON(http.StatusOK, response.OK(dto)) +} + +// UnbindBinding removes the current user's binding. +// @Summary Unbind a messaging channel +// @Description Removes a binding owned by the current user +// @Tags message-gateway +// @Produce json +// @Security SessionCookie +// @Param id path int true "binding id" +// @Success 200 {object} response.Any +// @Failure 403 {object} response.Any +// @Failure 404 {object} response.Any +// @Router /api/v1/message-gateway/bindings/{id} [delete] +func UnbindBinding(c *gin.Context) { + user, ok := currentUser(c) + if !ok || user == nil { + response.AbortUnauthorized(c, "login required") + return + } + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.AbortBadRequest(c, "invalid binding id") + return + } + if err := unbindChannel(c.Request.Context(), user.ID, id); err != nil { + if errors.Is(err, errBindingNotFound) { + response.AbortNotFound(c, err.Error()) + return + } + if errors.Is(err, errBindingForbidden) { + response.AbortForbidden(c, err.Error()) + return + } + response.AbortInternal(c, err.Error()) + return + } + c.JSON(http.StatusOK, response.OKNil()) +} diff --git a/internal/apps/message_gateway/logics.go b/internal/apps/message_gateway/logics.go new file mode 100644 index 00000000..514ed867 --- /dev/null +++ b/internal/apps/message_gateway/logics.go @@ -0,0 +1,158 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package message_gateway + +import ( + "context" + "errors" + "strconv" + "strings" + "time" + + "github.com/Rain-kl/Wavelet/internal/model" + "github.com/Rain-kl/Wavelet/internal/repository" + pkgmg "github.com/Rain-kl/Wavelet/pkg/message_gateway" + "gorm.io/gorm" +) + +// BindRequest is the user bind body. +type BindRequest struct { + ChannelID string `json:"channel_id"` + Code string `json:"code"` +} + +// BindingDTO is a user-facing binding row. +type BindingDTO struct { + ID uint64 `json:"id,string"` + UserID uint64 `json:"user_id,string"` + ChannelID uint64 `json:"channel_id,string"` + ChannelName string `json:"channel_name"` + ChannelType string `json:"channel_type"` + PlatformUserID string `json:"platform_user_id"` + CreatedAt time.Time `json:"created_at"` +} + +func bindChannel(ctx context.Context, userID uint64, req BindRequest) (BindingDTO, error) { + channelID, err := strconv.ParseUint(strings.TrimSpace(req.ChannelID), 10, 64) + if err != nil || channelID == 0 { + return BindingDTO{}, errChannelIDRequired + } + code := pkgmg.NormalizeCode(req.Code) + if code == "" { + return BindingDTO{}, errCodeInvalid + } + pairing, err := repository.GetPairingCode(ctx, code) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return BindingDTO{}, errCodeInvalid + } + return BindingDTO{}, err + } + if !pairing.ExpiresAt.After(time.Now()) { + return BindingDTO{}, errCodeInvalid + } + if pairing.ChannelID != channelID { + return BindingDTO{}, errChannelMismatch + } + ch, err := repository.GetMessageChannel(ctx, channelID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return BindingDTO{}, errCodeInvalid + } + return BindingDTO{}, err + } + if !ch.Enabled { + return BindingDTO{}, errChannelDisabled + } + + existing, err := repository.GetBindingByChannelPlatform(ctx, channelID, pairing.PlatformUserID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return BindingDTO{}, err + } + if err == nil && existing != nil { + if existing.UserID != userID { + return BindingDTO{}, errPlatformAlreadyBound + } + _ = repository.DeletePairingCode(ctx, pairing.Code) + return toBindingDTO(existing, ch), nil + } + + row := &model.MessageBinding{ + UserID: userID, + ChannelID: channelID, + PlatformUserID: pairing.PlatformUserID, + } + if err := repository.CreateMessageBinding(ctx, row); err != nil { + return BindingDTO{}, err + } + if err := repository.DeletePairingCode(ctx, pairing.Code); err != nil { + return BindingDTO{}, err + } + return toBindingDTO(row, ch), nil +} + +// PublicChannelDTO is an enabled channel a user can bind to. +type PublicChannelDTO struct { + ID uint64 `json:"id,string"` + Name string `json:"name"` + Type string `json:"type"` +} + +func listEnabledPublicChannels(ctx context.Context) ([]PublicChannelDTO, error) { + rows, err := repository.ListEnabledMessageChannels(ctx) + if err != nil { + return nil, err + } + out := make([]PublicChannelDTO, 0, len(rows)) + for _, row := range rows { + out = append(out, PublicChannelDTO{ID: row.ID, Name: row.Name, Type: row.Type}) + } + return out, nil +} + +func listUserBindings(ctx context.Context, userID uint64) ([]BindingDTO, error) { + rows, err := repository.ListBindingsByUser(ctx, userID) + if err != nil { + return nil, err + } + out := make([]BindingDTO, 0, len(rows)) + for i := range rows { + ch, err := repository.GetMessageChannel(ctx, rows[i].ChannelID) + if err != nil { + out = append(out, toBindingDTO(&rows[i], nil)) + continue + } + out = append(out, toBindingDTO(&rows[i], ch)) + } + return out, nil +} + +func unbindChannel(ctx context.Context, userID, bindingID uint64) error { + row, err := repository.GetMessageBinding(ctx, bindingID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errBindingNotFound + } + return err + } + if row.UserID != userID { + return errBindingForbidden + } + return repository.DeleteMessageBinding(ctx, bindingID) +} + +func toBindingDTO(row *model.MessageBinding, ch *model.MessageChannel) BindingDTO { + dto := BindingDTO{ + ID: row.ID, + UserID: row.UserID, + ChannelID: row.ChannelID, + PlatformUserID: row.PlatformUserID, + CreatedAt: row.CreatedAt, + } + if ch != nil { + dto.ChannelName = ch.Name + dto.ChannelType = ch.Type + } + return dto +} diff --git a/internal/apps/message_gateway/logics_test.go b/internal/apps/message_gateway/logics_test.go new file mode 100644 index 00000000..a7d9185b --- /dev/null +++ b/internal/apps/message_gateway/logics_test.go @@ -0,0 +1,106 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package message_gateway + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + "github.com/Rain-kl/Wavelet/internal/model" + "github.com/Rain-kl/Wavelet/internal/repository" + "github.com/Rain-kl/Wavelet/internal/testhelper" + "gorm.io/gorm" +) + +func seedChannel(t *testing.T, ctx context.Context) *model.MessageChannel { + t.Helper() + ch := &model.MessageChannel{ + Name: "tg", + Type: model.MessageChannelTypeTelegram, + OwnerScope: model.MessageOwnerScopeSystem, + Enabled: true, + } + if err := repository.CreateMessageChannel(ctx, ch); err != nil { + t.Fatalf("CreateMessageChannel() error = %v", err) + } + return ch +} + +func TestBind_ExpiredCode(t *testing.T) { + _, _, cleanup := testhelper.SetupTestEnvironment(t) + defer cleanup() + ctx := context.Background() + ch := seedChannel(t, ctx) + if _, err := repository.UpsertPairingCode(ctx, ch.ID, "u1", "ABCD2345", time.Now().Add(-time.Minute)); err != nil { + t.Fatalf("UpsertPairingCode() error = %v", err) + } + _, err := bindChannel(ctx, 1, BindRequest{ChannelID: fmt.Sprint(ch.ID), Code: "ABCD-2345"}) + if err == nil { + t.Fatal("bindChannel() error = nil, want expired code") + } +} + +func TestBind_HappyPathDeletesCode(t *testing.T) { + _, _, cleanup := testhelper.SetupTestEnvironment(t) + defer cleanup() + ctx := context.Background() + ch := seedChannel(t, ctx) + if _, err := repository.UpsertPairingCode(ctx, ch.ID, "plat-1", "ABCD2345", time.Now().Add(15*time.Minute)); err != nil { + t.Fatalf("UpsertPairingCode() error = %v", err) + } + dto, err := bindChannel(ctx, 42, BindRequest{ChannelID: fmt.Sprint(ch.ID), Code: "abcd-2345"}) + if err != nil { + t.Fatalf("bindChannel() error = %v", err) + } + if dto.PlatformUserID != "plat-1" || dto.ChannelID != ch.ID || dto.UserID != 42 { + t.Fatalf("bindChannel() dto = %+v", dto) + } + _, err = repository.GetPairingCode(ctx, "ABCD2345") + if !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("GetPairingCode() error = %v, want not found", err) + } +} + +func TestBind_ConflictOtherUser(t *testing.T) { + _, _, cleanup := testhelper.SetupTestEnvironment(t) + defer cleanup() + ctx := context.Background() + ch := seedChannel(t, ctx) + if err := repository.CreateMessageBinding(ctx, &model.MessageBinding{ + UserID: 7, ChannelID: ch.ID, PlatformUserID: "plat-1", + }); err != nil { + t.Fatalf("CreateMessageBinding() error = %v", err) + } + if _, err := repository.UpsertPairingCode(ctx, ch.ID, "plat-1", "ABCD2345", time.Now().Add(15*time.Minute)); err != nil { + t.Fatalf("UpsertPairingCode() error = %v", err) + } + _, err := bindChannel(ctx, 42, BindRequest{ChannelID: fmt.Sprint(ch.ID), Code: "ABCD2345"}) + if !errors.Is(err, errPlatformAlreadyBound) { + t.Fatalf("bindChannel() error = %v, want %v", err, errPlatformAlreadyBound) + } +} + +func TestUnbind_OnlyOwnBinding(t *testing.T) { + _, _, cleanup := testhelper.SetupTestEnvironment(t) + defer cleanup() + ctx := context.Background() + ch := seedChannel(t, ctx) + b := &model.MessageBinding{UserID: 7, ChannelID: ch.ID, PlatformUserID: "plat-1"} + if err := repository.CreateMessageBinding(ctx, b); err != nil { + t.Fatalf("CreateMessageBinding() error = %v", err) + } + if err := unbindChannel(ctx, 42, b.ID); err == nil { + t.Fatal("unbindChannel() error = nil, want forbidden") + } + if err := unbindChannel(ctx, 7, b.ID); err != nil { + t.Fatalf("unbindChannel() own binding error = %v", err) + } + _, err := repository.GetMessageBinding(ctx, b.ID) + if !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("GetMessageBinding() error = %v, want deleted", err) + } +} diff --git a/internal/apps/message_gateway/routers.go b/internal/apps/message_gateway/routers.go new file mode 100644 index 00000000..00cc4b53 --- /dev/null +++ b/internal/apps/message_gateway/routers.go @@ -0,0 +1,22 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package message_gateway provides user bind/unbind APIs and credential helpers. +package message_gateway + +import ( + "github.com/Rain-kl/Wavelet/internal/apps/oauth" + "github.com/gin-gonic/gin" +) + +// RegisterUserRoutes mounts login-required bind/unbind APIs. +func RegisterUserRoutes(apiV1Router *gin.RouterGroup) { + g := apiV1Router.Group("/message-gateway") + g.Use(oauth.LoginRequired()) + { + g.GET("/channels", ListChannels) + g.GET("/bindings", ListBindings) + g.POST("/bindings", BindBinding) + g.DELETE("/bindings/:id", UnbindBinding) + } +} diff --git a/internal/router/v1/message_gateway.go b/internal/router/v1/message_gateway.go new file mode 100644 index 00000000..19c1545a --- /dev/null +++ b/internal/router/v1/message_gateway.go @@ -0,0 +1,14 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + appgw "github.com/Rain-kl/Wavelet/internal/apps/message_gateway" + "github.com/gin-gonic/gin" +) + +// RegisterMessageGatewayUserRoutes mounts user bind/unbind APIs. +func RegisterMessageGatewayUserRoutes(apiV1Router *gin.RouterGroup) { + appgw.RegisterUserRoutes(apiV1Router) +} diff --git a/internal/router/v1/v1.go b/internal/router/v1/v1.go index d21ca794..295006cb 100644 --- a/internal/router/v1/v1.go +++ b/internal/router/v1/v1.go @@ -16,6 +16,9 @@ func RegisterV1Routes(apiV1Router *gin.RouterGroup, apiGroup *gin.RouterGroup) { // 2. Admin routes RegisterAdminRoutes(apiV1Router) + // 3. Message gateway user bind/unbind + RegisterMessageGatewayUserRoutes(apiV1Router) + // 3. Product domain routes: RegisterXxxRoutes(apiV1Router) — see skill new-api // 4. Scaffold sample only (optional demo under /api/v1/custom) RegisterCustomRoutes(apiV1Router)