refactor(msg_gateway): restructure and rename message_gateway aligned with custom_example

This commit is contained in:
ryan
2026-09-02 22:50:04 +08:00
parent 87e3bfd0e6
commit 8395dd5019
57 changed files with 2150 additions and 2120 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ Wavelet 的消息推送机制采用了**元数据驱动 + 统一触发器 + 异
| 目录/包名 | 职责定位 | 包含内容与设计细节 | | 目录/包名 | 职责定位 | 包含内容与设计细节 |
| :--- | :--- | :--- | | :--- | :--- | :--- |
| **`backend/plugins/domain/message_gateway/push/`** | 推送基础设施层 | 静态定义、不依赖系统数据库和任何框架。定义了统一接口 `Pusher` 和多实现(Lark, Webhook, Email 等),提供配置验证及发送功能。 | | **`backend/plugins/domain/msg_gateway/push/`** | 推送基础设施层 | 静态定义、不依赖系统数据库和任何框架。定义了统一接口 `Pusher` 和多实现(Lark, Webhook, Email 等),提供配置验证及发送功能。 |
| **`internal/apps/admin/push/`** | 通知服务与后台任务层 | 包含以下核心文件:<br>1. `events.go`:定义通知事件的结构模型(`NotificationMessage`, `EventMetadata`)、内置事件的动态注册中心(`BuiltInEvents` 及 `RegisterBuiltInEvent` 函数)以及统一触发器类 `EventTrigger`(包括其底层的派发引擎逻辑)。<br>2. `tasks.go`:定义 Asynq 后台异步发送任务、处理器 `PushHandler` 及其校验逻辑,并记录推送历史审计。<br>3. `routers.go`:管理端接口,负责获取事件配置列表和更新配置。 | | **`internal/apps/admin/push/`** | 通知服务与后台任务层 | 包含以下核心文件:<br>1. `events.go`:定义通知事件的结构模型(`NotificationMessage`, `EventMetadata`)、内置事件的动态注册中心(`BuiltInEvents` 及 `RegisterBuiltInEvent` 函数)以及统一触发器类 `EventTrigger`(包括其底层的派发引擎逻辑)。<br>2. `tasks.go`:定义 Asynq 后台异步发送任务、处理器 `PushHandler` 及其校验逻辑,并记录推送历史审计。<br>3. `routers.go`:管理端接口,负责获取事件配置列表和更新配置。 |
| **`internal/apps/admin/push/custom_events/`** | 自定义通知事件包 | 事件元数据定义与 push 侧处理逻辑;**一个 Go 文件代表一个事件**。在 `register.go` 统一装配,禁止 `init()` 副作用。 | | **`internal/apps/admin/push/custom_events/`** | 自定义通知事件包 | 事件元数据定义与 push 侧处理逻辑;**一个 Go 文件代表一个事件**。在 `register.go` 统一装配,禁止 `init()` 副作用。 |
| **`internal/listener/`** | 域事件分发层 | 核心域发射事件(如 `EmitAdminLoggedIn`),push 在 bootstrap 阶段通过 `OnAdminLoggedIn` 订阅,避免 auth/user 直接依赖 push。 | | **`internal/listener/`** | 域事件分发层 | 核心域发射事件(如 `EmitAdminLoggedIn`),push 在 bootstrap 阶段通过 `OnAdminLoggedIn` 订阅,避免 auth/user 直接依赖 push。 |
+2 -2
View File
@@ -9,7 +9,7 @@ import (
"Wavelet/plugins/domain/admin" "Wavelet/plugins/domain/admin"
"Wavelet/plugins/domain/auth" "Wavelet/plugins/domain/auth"
"Wavelet/plugins/domain/cap" "Wavelet/plugins/domain/cap"
"Wavelet/plugins/domain/message_gateway" "Wavelet/plugins/domain/msg_gateway"
"Wavelet/plugins/domain/risk_control" "Wavelet/plugins/domain/risk_control"
"Wavelet/plugins/domain/system" "Wavelet/plugins/domain/system"
"Wavelet/plugins/domain/upload" "Wavelet/plugins/domain/upload"
@@ -105,7 +105,7 @@ func newWaveletApp(profile core.Profile, opts ...core.AppOption) *core.App {
admin.New(), admin.New(),
user.New(), user.New(),
auth.New(), auth.New(),
message_gateway.New(), msg_gateway.New(),
risk_control.New(), risk_control.New(),
upload.New(), upload.New(),
cap.New(), cap.New(),
+2 -2
View File
@@ -82,8 +82,8 @@ func TestNewWaveletAppProfiles(t *testing.T) {
_, ok = app.Plugin("user") _, ok = app.Plugin("user")
assert.True(t, ok, "user plugin missing") assert.True(t, ok, "user plugin missing")
_, ok = app.Plugin("message_gateway") _, ok = app.Plugin("msg_gateway")
assert.True(t, ok, "message_gateway plugin missing") assert.True(t, ok, "msg_gateway plugin missing")
_, ok = app.Plugin("risk_control") _, ok = app.Plugin("risk_control")
assert.True(t, ok, "risk_control plugin missing") assert.True(t, ok, "risk_control plugin missing")
+418 -414
View File
File diff suppressed because it is too large Load Diff
+418 -414
View File
File diff suppressed because it is too large Load Diff
+283 -280
View File
@@ -204,6 +204,265 @@ definitions:
ttl_minutes: ttl_minutes:
type: integer type: integer
type: object type: object
do.BindRequest:
properties:
channel_id:
type: string
code:
type: string
type: object
do.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
do.ChannelDTO:
properties:
credentials:
additionalProperties:
type: string
type: object
enabled:
type: boolean
extra:
additionalProperties:
type: string
type: object
id:
example: "0"
type: string
name:
type: string
owner_id:
example: "0"
type: string
owner_scope:
type: string
type:
type: string
type: object
do.CreateChannelRequest:
properties:
credentials:
additionalProperties:
type: string
type: object
enabled:
type: boolean
extra:
additionalProperties:
type: string
type: object
name:
type: string
type:
type: string
type: object
do.CreatePushChannelRequest:
properties:
description:
type: string
enabled:
type: boolean
name:
type: string
other:
type: string
token:
type: string
type:
type: string
url:
type: string
required:
- name
- type
type: object
do.CreatePushEventRequest:
properties:
channels:
items:
type: string
type: array
enabled:
type: boolean
event_key:
type: string
targets:
items:
type: string
type: array
task_type:
type: string
template:
type: string
type: object
do.Definition:
properties:
fields:
items:
$ref: '#/definitions/do.Field'
type: array
type:
type: string
type: object
do.Field:
properties:
key:
type: string
required:
type: boolean
type:
type: string
type: object
do.PublicChannelDTO:
properties:
id:
example: "0"
type: string
name:
type: string
type:
type: string
type: object
do.TestPushChannelRequest:
properties:
name:
type: string
other:
type: string
target:
type: string
token:
type: string
type:
type: string
url:
type: string
type: object
do.TestPushRequest:
properties:
config:
$ref: '#/definitions/push.Config'
target:
type: string
required:
- config
type: object
do.UpdateChannelRequest:
properties:
credentials:
additionalProperties:
type: string
type: object
enabled:
type: boolean
extra:
additionalProperties:
type: string
type: object
name:
type: string
type: object
do.UpdatePushChannelRequest:
properties:
description:
type: string
enabled:
type: boolean
other:
type: string
token:
type: string
type:
type: string
url:
type: string
required:
- type
type: object
do.UpdatePushEventRequest:
properties:
channels:
items:
type: string
type: array
enabled:
type: boolean
targets:
items:
type: string
type: array
template:
type: string
required:
- template
type: object
entity.PushChannel:
properties:
created_at:
type: string
description:
type: string
enabled:
type: boolean
id:
type: integer
name:
type: string
other:
type: string
token:
type: string
type:
type: string
updated_at:
type: string
url:
type: string
type: object
entity.PushEvent:
properties:
channels:
items:
type: string
type: array
created_at:
type: string
enabled:
type: boolean
event_key:
type: string
id:
type: integer
name:
type: string
targets:
items:
type: string
type: array
task_type:
type: string
template:
type: string
updated_at:
type: string
type: object
handler.batchDownloadRequest: handler.batchDownloadRequest:
properties: properties:
ids: ids:
@@ -337,33 +596,6 @@ definitions:
total: total:
type: integer type: integer
type: object type: object
model.BindRequest:
properties:
channel_id:
type: string
code:
type: string
type: object
model.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
model.BrowserItem: model.BrowserItem:
properties: properties:
browser: browser:
@@ -371,87 +603,6 @@ definitions:
count: count:
type: integer type: integer
type: object type: object
model.ChannelDTO:
properties:
credentials:
additionalProperties:
type: string
type: object
enabled:
type: boolean
extra:
additionalProperties:
type: string
type: object
id:
example: "0"
type: string
name:
type: string
owner_id:
example: "0"
type: string
owner_scope:
type: string
type:
type: string
type: object
model.CreateChannelRequest:
properties:
credentials:
additionalProperties:
type: string
type: object
enabled:
type: boolean
extra:
additionalProperties:
type: string
type: object
name:
type: string
type:
type: string
type: object
model.CreatePushChannelRequest:
properties:
description:
type: string
enabled:
type: boolean
name:
type: string
other:
type: string
token:
type: string
type:
type: string
url:
type: string
required:
- name
- type
type: object
model.CreatePushEventRequest:
properties:
channels:
items:
type: string
type: array
enabled:
type: boolean
event_key:
type: string
targets:
items:
type: string
type: array
task_type:
type: string
template:
type: string
type: object
model.CreateScheduleRequest: model.CreateScheduleRequest:
properties: properties:
cron: cron:
@@ -569,15 +720,6 @@ definitions:
version: version:
type: string type: string
type: object type: object
model.Definition:
properties:
fields:
items:
$ref: '#/definitions/model.Field'
type: array
type:
type: string
type: object
model.DispatchTaskRequest: model.DispatchTaskRequest:
properties: properties:
end_time: end_time:
@@ -619,15 +761,6 @@ definitions:
description: '"select" 或 "exec"' description: '"select" 或 "exec"'
type: string type: string
type: object type: object
model.Field:
properties:
key:
type: string
required:
type: boolean
type:
type: string
type: object
model.ListUsersResponse: model.ListUsersResponse:
properties: properties:
total: total:
@@ -679,66 +812,6 @@ definitions:
description: 用于加载更早日志的 cursor description: 用于加载更早日志的 cursor
type: integer type: integer
type: object type: object
model.PublicChannelDTO:
properties:
id:
example: "0"
type: string
name:
type: string
type:
type: string
type: object
model.PushChannel:
properties:
created_at:
type: string
description:
type: string
enabled:
type: boolean
id:
type: integer
name:
type: string
other:
type: string
token:
type: string
type:
type: string
updated_at:
type: string
url:
type: string
type: object
model.PushEvent:
properties:
channels:
items:
type: string
type: array
created_at:
type: string
enabled:
type: boolean
event_key:
type: string
id:
type: integer
name:
type: string
targets:
items:
type: string
type: array
task_type:
type: string
template:
type: string
updated_at:
type: string
type: object
model.Schedule: model.Schedule:
properties: properties:
created_at: created_at:
@@ -910,30 +983,6 @@ definitions:
updated_at: updated_at:
type: string type: string
type: object type: object
model.TestPushChannelRequest:
properties:
name:
type: string
other:
type: string
target:
type: string
token:
type: string
type:
type: string
url:
type: string
type: object
model.TestPushRequest:
properties:
config:
$ref: '#/definitions/push.Config'
target:
type: string
required:
- config
type: object
model.TestSMTPRequest: model.TestSMTPRequest:
properties: properties:
smtp_host: smtp_host:
@@ -998,55 +1047,6 @@ definitions:
- max_size_mb - max_size_mb
- ttl_minutes - ttl_minutes
type: object type: object
model.UpdateChannelRequest:
properties:
credentials:
additionalProperties:
type: string
type: object
enabled:
type: boolean
extra:
additionalProperties:
type: string
type: object
name:
type: string
type: object
model.UpdatePushChannelRequest:
properties:
description:
type: string
enabled:
type: boolean
other:
type: string
token:
type: string
type:
type: string
url:
type: string
required:
- type
type: object
model.UpdatePushEventRequest:
properties:
channels:
items:
type: string
type: array
enabled:
type: boolean
targets:
items:
type: string
type: array
template:
type: string
required:
- template
type: object
model.UpdateScheduleRequest: model.UpdateScheduleRequest:
properties: properties:
cron: cron:
@@ -1269,6 +1269,9 @@ definitions:
key: key:
description: AppID 或 SMTP 用户名 description: AppID 或 SMTP 用户名
type: string type: string
other:
description: 附加配置 (如 ChatID / UserKey / 扩展 JSON)
type: string
secret: secret:
description: 签名密钥或 SMTP 密码/Token description: 签名密钥或 SMTP 密码/Token
type: string type: string
@@ -2006,7 +2009,7 @@ paths:
- properties: - properties:
data: data:
items: items:
$ref: '#/definitions/model.ChannelDTO' $ref: '#/definitions/do.ChannelDTO'
type: array type: array
type: object type: object
security: security:
@@ -2024,7 +2027,7 @@ paths:
name: request name: request
required: true required: true
schema: schema:
$ref: '#/definitions/model.CreateChannelRequest' $ref: '#/definitions/do.CreateChannelRequest'
produces: produces:
- application/json - application/json
responses: responses:
@@ -2035,7 +2038,7 @@ paths:
- $ref: '#/definitions/response.Any' - $ref: '#/definitions/response.Any'
- properties: - properties:
data: data:
$ref: '#/definitions/model.ChannelDTO' $ref: '#/definitions/do.ChannelDTO'
type: object type: object
"400": "400":
description: Bad Request description: Bad Request
@@ -2086,7 +2089,7 @@ paths:
name: request name: request
required: true required: true
schema: schema:
$ref: '#/definitions/model.UpdateChannelRequest' $ref: '#/definitions/do.UpdateChannelRequest'
produces: produces:
- application/json - application/json
responses: responses:
@@ -2097,7 +2100,7 @@ paths:
- $ref: '#/definitions/response.Any' - $ref: '#/definitions/response.Any'
- properties: - properties:
data: data:
$ref: '#/definitions/model.ChannelDTO' $ref: '#/definitions/do.ChannelDTO'
type: object type: object
"400": "400":
description: Bad Request description: Bad Request
@@ -2155,7 +2158,7 @@ paths:
- properties: - properties:
data: data:
items: items:
$ref: '#/definitions/model.Definition' $ref: '#/definitions/do.Definition'
type: array type: array
type: object type: object
security: security:
@@ -2177,7 +2180,7 @@ paths:
- properties: - properties:
data: data:
items: items:
$ref: '#/definitions/model.PushChannel' $ref: '#/definitions/entity.PushChannel'
type: array type: array
type: object type: object
security: security:
@@ -2195,7 +2198,7 @@ paths:
name: request name: request
required: true required: true
schema: schema:
$ref: '#/definitions/model.CreatePushChannelRequest' $ref: '#/definitions/do.CreatePushChannelRequest'
produces: produces:
- application/json - application/json
responses: responses:
@@ -2206,7 +2209,7 @@ paths:
- $ref: '#/definitions/response.Any' - $ref: '#/definitions/response.Any'
- properties: - properties:
data: data:
$ref: '#/definitions/model.PushChannel' $ref: '#/definitions/entity.PushChannel'
type: object type: object
security: security:
- SessionCookie: [] - SessionCookie: []
@@ -2251,7 +2254,7 @@ paths:
name: request name: request
required: true required: true
schema: schema:
$ref: '#/definitions/model.UpdatePushChannelRequest' $ref: '#/definitions/do.UpdatePushChannelRequest'
produces: produces:
- application/json - application/json
responses: responses:
@@ -2262,7 +2265,7 @@ paths:
- $ref: '#/definitions/response.Any' - $ref: '#/definitions/response.Any'
- properties: - properties:
data: data:
$ref: '#/definitions/model.PushChannel' $ref: '#/definitions/entity.PushChannel'
type: object type: object
security: security:
- SessionCookie: [] - SessionCookie: []
@@ -2295,7 +2298,7 @@ paths:
name: request name: request
required: true required: true
schema: schema:
$ref: '#/definitions/model.TestPushChannelRequest' $ref: '#/definitions/do.TestPushChannelRequest'
produces: produces:
- application/json - application/json
responses: responses:
@@ -2322,7 +2325,7 @@ paths:
- properties: - properties:
data: data:
items: items:
$ref: '#/definitions/model.PushEvent' $ref: '#/definitions/entity.PushEvent'
type: array type: array
type: object type: object
security: security:
@@ -2340,7 +2343,7 @@ paths:
name: request name: request
required: true required: true
schema: schema:
$ref: '#/definitions/model.CreatePushEventRequest' $ref: '#/definitions/do.CreatePushEventRequest'
produces: produces:
- application/json - application/json
responses: responses:
@@ -2351,7 +2354,7 @@ paths:
- $ref: '#/definitions/response.Any' - $ref: '#/definitions/response.Any'
- properties: - properties:
data: data:
$ref: '#/definitions/model.PushEvent' $ref: '#/definitions/entity.PushEvent'
type: object type: object
security: security:
- SessionCookie: [] - SessionCookie: []
@@ -2399,7 +2402,7 @@ paths:
name: request name: request
required: true required: true
schema: schema:
$ref: '#/definitions/model.UpdatePushEventRequest' $ref: '#/definitions/do.UpdatePushEventRequest'
produces: produces:
- application/json - application/json
responses: responses:
@@ -2501,7 +2504,7 @@ paths:
name: request name: request
required: true required: true
schema: schema:
$ref: '#/definitions/model.TestPushRequest' $ref: '#/definitions/do.TestPushRequest'
produces: produces:
- application/json - application/json
responses: responses:
@@ -4141,7 +4144,7 @@ paths:
- properties: - properties:
data: data:
items: items:
$ref: '#/definitions/model.BindingDTO' $ref: '#/definitions/do.BindingDTO'
type: array type: array
type: object type: object
"401": "401":
@@ -4164,7 +4167,7 @@ paths:
name: request name: request
required: true required: true
schema: schema:
$ref: '#/definitions/model.BindRequest' $ref: '#/definitions/do.BindRequest'
produces: produces:
- application/json - application/json
responses: responses:
@@ -4175,7 +4178,7 @@ paths:
- $ref: '#/definitions/response.Any' - $ref: '#/definitions/response.Any'
- properties: - properties:
data: data:
$ref: '#/definitions/model.BindingDTO' $ref: '#/definitions/do.BindingDTO'
type: object type: object
"400": "400":
description: Bad Request description: Bad Request
@@ -4233,7 +4236,7 @@ paths:
- properties: - properties:
data: data:
items: items:
$ref: '#/definitions/model.PublicChannelDTO' $ref: '#/definitions/do.PublicChannelDTO'
type: array type: array
type: object type: object
"401": "401":
@@ -19,7 +19,7 @@ import (
"Wavelet/core/contracts" "Wavelet/core/contracts"
"Wavelet/core/extpoints" "Wavelet/core/extpoints"
"Wavelet/plugins/domain/admin" "Wavelet/plugins/domain/admin"
"Wavelet/plugins/domain/message_gateway" "Wavelet/plugins/domain/msg_gateway"
"Wavelet/plugins/domain/user" "Wavelet/plugins/domain/user"
) )
@@ -122,7 +122,7 @@ func TestRoutesMountedBeforeAuthServiceAreGuarded(t *testing.T) {
core.WithPlugins( core.WithPlugins(
dbProvider(), dbProvider(),
user.New(), user.New(),
message_gateway.New(), msg_gateway.New(),
authProvider(), authProvider(),
), ),
) )
@@ -147,7 +147,7 @@ func TestAuthConsumersDeclareAuthDependency(t *testing.T) {
deps []reflect.Type deps []reflect.Type
}{ }{
{"user", user.New().Inject()}, {"user", user.New().Inject()},
{"message_gateway", message_gateway.New().Inject()}, {"msg_gateway", msg_gateway.New().Inject()},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
assert.Contains(t, tc.deps, want, assert.Contains(t, tc.deps, want,
@@ -194,7 +194,7 @@ func TestAuthGuardFailsClosed(t *testing.T) {
prefix string prefix string
}{ }{
{"user", user.New().Apply, http.MethodPost, "/api/v1/user/change-password"}, {"user", user.New().Apply, http.MethodPost, "/api/v1/user/change-password"},
{"message_gateway", message_gateway.New().Apply, http.MethodGet, "/api/v1/message-gateway"}, {"msg_gateway", msg_gateway.New().Apply, http.MethodGet, "/api/v1/message-gateway"},
{"admin", admin.New().Apply, http.MethodGet, "/api/v1/admin"}, {"admin", admin.New().Apply, http.MethodGet, "/api/v1/admin"},
} }
+19 -19
View File
@@ -9,7 +9,7 @@ import (
"Wavelet/pkg/idgen" "Wavelet/pkg/idgen"
"Wavelet/plugins/domain/admin" "Wavelet/plugins/domain/admin"
"Wavelet/plugins/domain/auth" "Wavelet/plugins/domain/auth"
"Wavelet/plugins/domain/message_gateway" "Wavelet/plugins/domain/msg_gateway"
"Wavelet/plugins/domain/risk_control" "Wavelet/plugins/domain/risk_control"
"Wavelet/plugins/domain/system" "Wavelet/plugins/domain/system"
"Wavelet/plugins/domain/user" "Wavelet/plugins/domain/user"
@@ -47,13 +47,13 @@ func setupTestDB(t *testing.T) *gorm.DB {
&user.AccessToken{}, &user.AccessToken{},
&auth.AuthSource{}, &auth.AuthSource{},
&auth.ExternalAccount{}, &auth.ExternalAccount{},
&message_gateway.MessageChannel{}, &msg_gateway.MessageChannel{},
&message_gateway.MessageBinding{}, &msg_gateway.MessageBinding{},
&message_gateway.MessagePairingCode{}, &msg_gateway.MessagePairingCode{},
&admin.SystemConfig{}, &admin.SystemConfig{},
&message_gateway.PushChannel{}, &msg_gateway.PushChannel{},
&message_gateway.PushEvent{}, &msg_gateway.PushEvent{},
&message_gateway.PushHistory{}, &msg_gateway.PushHistory{},
)) ))
db.SetDB(testDB) db.SetDB(testDB)
@@ -262,15 +262,15 @@ func TestMessageGatewayPlugin(t *testing.T) {
require.NoError(t, cache.New().Apply(ctx)) require.NoError(t, cache.New().Apply(ctx))
require.NoError(t, logger.New().Apply(ctx)) require.NoError(t, logger.New().Apply(ctx))
p := message_gateway.New() p := msg_gateway.New()
assert.Equal(t, "message_gateway", p.Name()) assert.Equal(t, "msg_gateway", p.Name())
assert.Equal(t, "message_gateway", p.Manifest().Name) assert.Equal(t, "msg_gateway", p.Manifest().Name)
require.NoError(t, p.Apply(ctx)) require.NoError(t, p.Apply(ctx))
// 1. Migrations // 1. Migrations
entry, ok := ctx.Migrations().Get("message_gateway") entry, ok := ctx.Migrations().Get("msg_gateway")
require.True(t, ok) require.True(t, ok)
assert.Equal(t, "message_gateway", entry.PluginID) assert.Equal(t, "msg_gateway", entry.PluginID)
// 2. Routes // 2. Routes
routes := ctx.Router().Routes() routes := ctx.Router().Routes()
@@ -287,24 +287,24 @@ func TestMessageGatewayPlugin(t *testing.T) {
assert.True(t, hasBindings) assert.True(t, hasBindings)
// 3. Tasks & Schedules // 3. Tasks & Schedules
taskDef, ok := ctx.Tasks().Get("message_gateway:push_notification") taskDef, ok := ctx.Tasks().Get("msg_gateway:push_notification")
require.True(t, ok) require.True(t, ok)
assert.Equal(t, 3, taskDef.Retry) assert.Equal(t, 3, taskDef.Retry)
schedDef, ok := ctx.Schedules().Get("message_gateway:cleanup_pairing_codes") schedDef, ok := ctx.Schedules().Get("msg_gateway:cleanup_pairing_codes")
require.True(t, ok) require.True(t, ok)
assert.Equal(t, "*/10 * * * *", schedDef.Spec) assert.Equal(t, "*/10 * * * *", schedDef.Spec)
// 4. EventBus Trigger // 4. EventBus Trigger
var receivedEvent message_gateway.PushNotificationEvent var receivedEvent msg_gateway.PushNotificationEvent
var eventFired bool var eventFired bool
ctx.Events().On("notification:push", func(c context.Context, e message_gateway.PushNotificationEvent) error { ctx.Events().On("notification:push", func(c context.Context, e msg_gateway.PushNotificationEvent) error {
eventFired = true eventFired = true
receivedEvent = e receivedEvent = e
return nil return nil
}) })
err := ctx.Events().Emit(context.Background(), "notification:push", message_gateway.PushNotificationEvent{ err := ctx.Events().Emit(context.Background(), "notification:push", msg_gateway.PushNotificationEvent{
UserID: 99, UserID: 99,
Channel: "telegram", Channel: "telegram",
Title: "System Alert", Title: "System Alert",
@@ -317,7 +317,7 @@ func TestMessageGatewayPlugin(t *testing.T) {
assert.Equal(t, "System Alert", receivedEvent.Title) assert.Equal(t, "System Alert", receivedEvent.Title)
// 5. Settings // 5. Settings
schema, ok := ctx.Settings().Get("message_gateway.pairing_code_expiry_minutes") schema, ok := ctx.Settings().Get("msg_gateway.pairing_code_expiry_minutes")
require.True(t, ok) require.True(t, ok)
assert.Equal(t, 15, schema.Default) assert.Equal(t, 15, schema.Default)
} }
@@ -480,7 +480,7 @@ func TestAllDomainPluginsCombined(t *testing.T) {
// Apply Domain plugins // Apply Domain plugins
require.NoError(t, auth.New().Apply(ctx)) require.NoError(t, auth.New().Apply(ctx))
require.NoError(t, user.New().Apply(ctx)) require.NoError(t, user.New().Apply(ctx))
require.NoError(t, message_gateway.New().Apply(ctx)) require.NoError(t, msg_gateway.New().Apply(ctx))
require.NoError(t, risk_control.New().Apply(ctx)) require.NoError(t, risk_control.New().Apply(ctx))
require.NoError(t, admin.New().Apply(ctx)) require.NoError(t, admin.New().Apply(ctx))
@@ -1,46 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package model
// Field is one admin form field.
type Field struct {
Key string `json:"key"`
Type string `json:"type"`
Required bool `json:"required"`
}
// Definition describes a channel type form.
type Definition struct {
Type string `json:"type"`
Fields []Field `json:"fields"`
}
// ChannelDTO represents a channel for admin consumption.
type ChannelDTO struct {
ID uint64 `json:"id,string"`
Name string `json:"name"`
Type string `json:"type"`
OwnerScope string `json:"owner_scope"`
OwnerID *uint64 `json:"owner_id,string,omitempty"`
Enabled bool `json:"enabled"`
Credentials map[string]string `json:"credentials"`
Extra map[string]string `json:"extra"`
}
// CreateChannelRequest is admin create payload.
type CreateChannelRequest struct {
Name string `json:"name"`
Type string `json:"type"`
Enabled *bool `json:"enabled"`
Credentials map[string]string `json:"credentials"`
Extra map[string]string `json:"extra"`
}
// UpdateChannelRequest is admin update payload.
type UpdateChannelRequest struct {
Name string `json:"name"`
Enabled *bool `json:"enabled"`
Credentials map[string]string `json:"credentials"`
Extra map[string]string `json:"extra"`
}
@@ -1,242 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package model defines the domain entities, DTOs, and schemas for message_gateway.
package model
import (
"Wavelet/plugins/domain/message_gateway/errs"
"errors"
"strings"
"time"
)
// Channel type and scope constants.
const (
ChannelTypeTelegram = "telegram"
ChannelTypeQQ = "qq"
MessageChannelTypeTelegram = "telegram"
MessageChannelTypeQQ = "qq"
MessageOwnerScopeSystem = "system"
TypeCustom = "custom"
TypeEmail = "email"
TypeTelegram = "telegram"
)
// Capability describes what an adapter can send and receive.
type Capability struct {
Text bool
Image bool
File bool
Reply bool
Group bool
}
// ChannelConfig is the decrypted runtime config passed to a factory.
type ChannelConfig struct {
ID uint64
Type string
Name string
Credentials map[string]string
Extra map[string]string
}
// Recipient is the outbound destination on a platform.
type Recipient struct {
ChatID string
PlatformUserID string
}
// Attachment is a downloaded inbound file sitting on local disk.
type Attachment struct {
Path string
FileName string
MIME string
Error string
}
// InboundMessage is a normalized private-chat message.
type InboundMessage struct {
ChannelID uint64
ChannelType string
PlatformUserID string
ChatID string
MessageID string
Text string
Attachments []Attachment
BindingUserID *uint64
}
// OutboundMessage is a reply or probe send.
type OutboundMessage struct {
Text string
ReplyToID string
Attachments []Attachment
}
// MessageChannel is an admin-configured messaging adapter.
type MessageChannel struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
Type string `json:"type" gorm:"size:32;not null"`
Name string `json:"name" gorm:"size:64;not null"`
OwnerScope string `json:"owner_scope" gorm:"size:32;not null;default:'system'"`
OwnerID *uint64 `json:"owner_id,omitempty"`
Credentials string `json:"credentials" gorm:"type:text;not null"`
Extra string `json:"extra" gorm:"type:text"`
Enabled bool `json:"enabled" gorm:"default:false;not null"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
}
// TableName 表名
func (MessageChannel) TableName() string {
return "w_message_channels"
}
// MessageBinding maps a platform user to a Wavelet user on one channel.
type MessageBinding struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
ChannelID uint64 `json:"channel_id" gorm:"not null;index"`
PlatformUserID string `json:"platform_user_id" gorm:"size:128;not null;index"`
UserID uint64 `json:"user_id" gorm:"not null;index"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
}
// TableName 表名
func (MessageBinding) TableName() string {
return "w_message_bindings"
}
// MessagePairingCode is a one-time bind code.
type MessagePairingCode struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
Code string `json:"code" gorm:"size:32;uniqueIndex;not null"`
ChannelID uint64 `json:"channel_id" gorm:"not null;index"`
PlatformUserID string `json:"platform_user_id" gorm:"size:128;not null;index"`
UserID uint64 `json:"user_id" gorm:"not null;index"`
ExpiresAt time.Time `json:"expires_at" gorm:"not null;index"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
}
// TableName 表名
func (MessagePairingCode) TableName() string {
return "w_message_pairing_codes"
}
// PushChannel 消息通道模型
type PushChannel struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
Name string `json:"name" gorm:"size:100;not null"`
Description string `json:"description" gorm:"size:255"`
Type string `json:"type" gorm:"size:50;not null;index"`
URL string `json:"url" gorm:"type:text"`
Token string `json:"token" gorm:"type:text"`
Other string `json:"other" gorm:"type:text"`
Enabled bool `json:"enabled" gorm:"index;not null;default:true"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"`
}
// TableName 指定 GORM 表名
func (PushChannel) TableName() string {
return "w_push_channels"
}
// Validate 验证与标准化字段
func (c *PushChannel) Validate() error {
c.Name = strings.TrimSpace(c.Name)
if c.Name == "" {
return errors.New(errs.ErrChannelNameRequired)
}
c.Type = strings.TrimSpace(c.Type)
if c.Type == "" {
return errors.New(errs.ErrChannelTypeRequired)
}
return nil
}
// PushEvent 系统通知事件模型
type PushEvent struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
EventKey string `json:"event_key" gorm:"uniqueIndex;size:80;not null"`
Name string `json:"name" gorm:"size:100;not null"`
TaskType string `json:"task_type" gorm:"size:100;index;not null;default:''"`
Channels []string `json:"channels" gorm:"type:text;serializer:json;not null"`
Targets []string `json:"targets" gorm:"type:text;serializer:json;not null"`
Template string `json:"template" gorm:"type:text;not null"`
Enabled bool `json:"enabled" gorm:"index;not null;default:false"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"`
}
// TableName 指定 GORM 表名
func (PushEvent) TableName() string {
return "w_push_events"
}
// Validate 验证 PushEvent 实体字段
func (e *PushEvent) Validate() error {
e.EventKey = strings.TrimSpace(e.EventKey)
if e.EventKey == "" {
return errors.New(errs.ErrEventKeyRequired)
}
e.Name = strings.TrimSpace(e.Name)
if e.Name == "" {
return errors.New(errs.ErrNameRequired)
}
return nil
}
// PushHistory 推送日志/历史实体
type PushHistory struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
EventKey string `json:"event_key" gorm:"size:80;not null;index"`
Channel string `json:"channel" gorm:"size:50;not null;index"`
Target string `json:"target" gorm:"size:255;not null"`
Title string `json:"title" gorm:"size:255;not null"`
Content string `json:"content" gorm:"type:text;not null"`
Level string `json:"level" gorm:"size:20;not null;default:'INFO'"`
Status string `json:"status" gorm:"size:20;not null;index"`
ErrorMsg string `json:"error_msg" gorm:"type:text"`
Payload string `json:"payload" gorm:"type:text"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"`
}
// TableName 指定 GORM 表名
func (PushHistory) TableName() string {
return "w_push_histories"
}
// 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"`
}
// 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"`
}
// PushNotificationEvent defines the payload for eventbus notification trigger.
type PushNotificationEvent struct {
UserID uint64 `json:"user_id"`
Channel string `json:"channel"`
Title string `json:"title"`
Content string `json:"content"`
Metadata map[string]any `json:"metadata,omitempty"`
}
@@ -7,8 +7,9 @@ package qq
import ( import (
"Wavelet/pkg/logger" "Wavelet/pkg/logger"
"Wavelet/pkg/util" "Wavelet/pkg/util"
"Wavelet/plugins/domain/message_gateway/model" "Wavelet/plugins/domain/msg_gateway/consts"
"Wavelet/plugins/domain/message_gateway/service" "Wavelet/plugins/domain/msg_gateway/model/do"
"Wavelet/plugins/domain/msg_gateway/service"
"context" "context"
"fmt" "fmt"
"strings" "strings"
@@ -33,7 +34,7 @@ type qqEvent struct {
// Adapter is an official QQ Bot C2C channel. // Adapter is an official QQ Bot C2C channel.
type Adapter struct { type Adapter struct {
cfg model.ChannelConfig cfg do.ChannelConfig
onInbound service.Handler onInbound service.Handler
api openapi.OpenAPI api openapi.OpenAPI
tokenSrc oauth2.TokenSource tokenSrc oauth2.TokenSource
@@ -43,7 +44,7 @@ type Adapter struct {
} }
// New constructs a QQ adapter. // New constructs a QQ adapter.
func New(cfg model.ChannelConfig, onInbound service.Handler) (service.Channel, error) { func New(cfg do.ChannelConfig, onInbound service.Handler) (service.Channel, error) {
if strings.TrimSpace(cfg.Credentials["app_id"]) == "" || strings.TrimSpace(cfg.Credentials["app_secret"]) == "" { if strings.TrimSpace(cfg.Credentials["app_id"]) == "" || strings.TrimSpace(cfg.Credentials["app_secret"]) == "" {
return nil, fmt.Errorf("qq: app_id and app_secret are required") return nil, fmt.Errorf("qq: app_id and app_secret are required")
} }
@@ -51,11 +52,11 @@ func New(cfg model.ChannelConfig, onInbound service.Handler) (service.Channel, e
} }
// Type returns qq. // Type returns qq.
func (a *Adapter) Type() string { return model.ChannelTypeQQ } func (a *Adapter) Type() string { return consts.ChannelTypeQQ }
// Capabilities reports C2C text/media support. // Capabilities reports C2C text/media support.
func (a *Adapter) Capabilities() model.Capability { func (a *Adapter) Capabilities() do.Capability {
return model.Capability{Text: true, Image: true, File: true, Reply: true} return do.Capability{Text: true, Image: true, File: true, Reply: true}
} }
// Connect starts the official WebSocket session (C2C intent). // Connect starts the official WebSocket session (C2C intent).
@@ -128,7 +129,7 @@ func (a *Adapter) Disconnect(_ context.Context) error {
} }
// Send posts a C2C text reply. // Send posts a C2C text reply.
func (a *Adapter) Send(ctx context.Context, to model.Recipient, msg model.OutboundMessage) error { func (a *Adapter) Send(ctx context.Context, to do.Recipient, msg do.OutboundMessage) error {
a.mu.Lock() a.mu.Lock()
api := a.api api := a.api
a.mu.Unlock() a.mu.Unlock()
@@ -152,9 +153,9 @@ func (a *Adapter) handleEvent(ctx context.Context, ev qqEvent) {
if disconnected || a.onInbound == nil { if disconnected || a.onInbound == nil {
return return
} }
_ = a.onInbound(ctx, model.InboundMessage{ _ = a.onInbound(ctx, do.InboundMessage{
ChannelID: a.cfg.ID, ChannelID: a.cfg.ID,
ChannelType: model.ChannelTypeQQ, ChannelType: consts.ChannelTypeQQ,
PlatformUserID: ev.UserID, PlatformUserID: ev.UserID,
ChatID: ev.UserID, ChatID: ev.UserID,
MessageID: ev.MessageID, MessageID: ev.MessageID,
@@ -4,14 +4,14 @@
package qq package qq
import ( import (
"Wavelet/plugins/domain/message_gateway/model" "Wavelet/plugins/domain/msg_gateway/model/do"
"context" "context"
"testing" "testing"
) )
func TestHandleEvent_DropsNonC2C(t *testing.T) { func TestHandleEvent_DropsNonC2C(t *testing.T) {
var got int var got int
a := &Adapter{onInbound: func(ctx context.Context, msg model.InboundMessage) error { a := &Adapter{onInbound: func(_ context.Context, _ do.InboundMessage) error {
got++ got++
return nil return nil
}} }}
@@ -22,8 +22,8 @@ func TestHandleEvent_DropsNonC2C(t *testing.T) {
} }
func TestHandleEvent_C2CText(t *testing.T) { func TestHandleEvent_C2CText(t *testing.T) {
var got model.InboundMessage var got do.InboundMessage
a := &Adapter{cfg: model.ChannelConfig{ID: 3}, onInbound: func(ctx context.Context, msg model.InboundMessage) error { a := &Adapter{cfg: do.ChannelConfig{ID: 3}, onInbound: func(_ context.Context, msg do.InboundMessage) error {
got = msg got = msg
return nil return nil
}} }}
@@ -34,7 +34,7 @@ func TestHandleEvent_C2CText(t *testing.T) {
} }
func TestNew_RequiresCreds(t *testing.T) { func TestNew_RequiresCreds(t *testing.T) {
_, err := New(model.ChannelConfig{}, nil) _, err := New(do.ChannelConfig{}, nil)
if err == nil { if err == nil {
t.Fatal("expected error") t.Fatal("expected error")
} }
@@ -7,8 +7,9 @@ package telegram
import ( import (
"Wavelet/pkg/logger" "Wavelet/pkg/logger"
"Wavelet/pkg/util" "Wavelet/pkg/util"
"Wavelet/plugins/domain/message_gateway/model" "Wavelet/plugins/domain/msg_gateway/consts"
"Wavelet/plugins/domain/message_gateway/service" "Wavelet/plugins/domain/msg_gateway/model/do"
"Wavelet/plugins/domain/msg_gateway/service"
"context" "context"
"fmt" "fmt"
"os" "os"
@@ -22,13 +23,13 @@ import (
// Adapter is a Telegram private-chat channel. // Adapter is a Telegram private-chat channel.
type Adapter struct { type Adapter struct {
cfg model.ChannelConfig cfg do.ChannelConfig
onInbound service.Handler onInbound service.Handler
bot *tele.Bot bot *tele.Bot
} }
// New constructs a Telegram adapter. Call service.Register from the runner. // New constructs a Telegram adapter. Call service.Register from the runner.
func New(cfg model.ChannelConfig, onInbound service.Handler) (service.Channel, error) { func New(cfg do.ChannelConfig, onInbound service.Handler) (service.Channel, error) {
if strings.TrimSpace(cfg.Credentials["bot_token"]) == "" { if strings.TrimSpace(cfg.Credentials["bot_token"]) == "" {
return nil, fmt.Errorf("telegram: bot_token is required") return nil, fmt.Errorf("telegram: bot_token is required")
} }
@@ -36,11 +37,11 @@ func New(cfg model.ChannelConfig, onInbound service.Handler) (service.Channel, e
} }
// Type returns telegram. // Type returns telegram.
func (a *Adapter) Type() string { return model.ChannelTypeTelegram } func (a *Adapter) Type() string { return consts.ChannelTypeTelegram }
// Capabilities reports private-chat media support. // Capabilities reports private-chat media support.
func (a *Adapter) Capabilities() model.Capability { func (a *Adapter) Capabilities() do.Capability {
return model.Capability{Text: true, Image: true, File: true, Reply: true} return do.Capability{Text: true, Image: true, File: true, Reply: true}
} }
// longPollWindow is how long Telegram may hold a getUpdates call open before // longPollWindow is how long Telegram may hold a getUpdates call open before
@@ -50,7 +51,7 @@ func (a *Adapter) Capabilities() model.Capability {
const longPollWindow = 10 * time.Second const longPollWindow = 10 * time.Second
// buildTeleSettings assembles the telebot settings. // buildTeleSettings assembles the telebot settings.
func buildTeleSettings(cfg model.ChannelConfig) tele.Settings { func buildTeleSettings(cfg do.ChannelConfig) tele.Settings {
pref := tele.Settings{ pref := tele.Settings{
Token: cfg.Credentials["bot_token"], Token: cfg.Credentials["bot_token"],
Poller: &tele.LongPoller{Timeout: longPollWindow}, Poller: &tele.LongPoller{Timeout: longPollWindow},
@@ -99,7 +100,7 @@ func (a *Adapter) Disconnect(_ context.Context) error {
} }
// Send replies to a private chat. // Send replies to a private chat.
func (a *Adapter) Send(_ context.Context, to model.Recipient, msg model.OutboundMessage) error { func (a *Adapter) Send(_ context.Context, to do.Recipient, msg do.OutboundMessage) error {
if a.bot == nil { if a.bot == nil {
return fmt.Errorf("telegram: not connected") return fmt.Errorf("telegram: not connected")
} }
@@ -118,9 +119,9 @@ func (a *Adapter) handleTeleMessage(ctx context.Context, m *tele.Message) {
if a.onInbound == nil { if a.onInbound == nil {
return return
} }
msg := model.InboundMessage{ msg := do.InboundMessage{
ChannelID: a.cfg.ID, ChannelID: a.cfg.ID,
ChannelType: model.ChannelTypeTelegram, ChannelType: consts.ChannelTypeTelegram,
PlatformUserID: strconv.FormatInt(m.Sender.ID, 10), PlatformUserID: strconv.FormatInt(m.Sender.ID, 10),
ChatID: strconv.FormatInt(m.Chat.ID, 10), ChatID: strconv.FormatInt(m.Chat.ID, 10),
MessageID: strconv.Itoa(m.ID), MessageID: strconv.Itoa(m.ID),
@@ -146,7 +147,7 @@ func (a *Adapter) handleTeleMessage(ctx context.Context, m *tele.Message) {
// downloadMedia fetches message media into a scratch directory, returned so the // downloadMedia fetches message media into a scratch directory, returned so the
// caller can remove it once the inbound handler no longer needs the paths. // caller can remove it once the inbound handler no longer needs the paths.
// An empty dir means nothing was downloaded. // An empty dir means nothing was downloaded.
func (a *Adapter) downloadMedia(m *tele.Message) (string, []model.Attachment) { func (a *Adapter) downloadMedia(m *tele.Message) (string, []do.Attachment) {
var files []*tele.File var files []*tele.File
var names []string var names []string
if m.Photo != nil { if m.Photo != nil {
@@ -166,16 +167,16 @@ func (a *Adapter) downloadMedia(m *tele.Message) (string, []model.Attachment) {
} }
dir, err := os.MkdirTemp("", "wg-tg-*") dir, err := os.MkdirTemp("", "wg-tg-*")
if err != nil { if err != nil {
return "", []model.Attachment{{Error: err.Error()}} return "", []do.Attachment{{Error: err.Error()}}
} }
out := make([]model.Attachment, 0, len(files)) out := make([]do.Attachment, 0, len(files))
for i, f := range files { for i, f := range files {
path := filepath.Join(dir, names[i]) path := filepath.Join(dir, names[i])
if err := a.bot.Download(f, path); err != nil { if err := a.bot.Download(f, path); err != nil {
out = append(out, model.Attachment{FileName: names[i], Error: err.Error()}) out = append(out, do.Attachment{FileName: names[i], Error: err.Error()})
continue continue
} }
out = append(out, model.Attachment{Path: path, FileName: names[i]}) out = append(out, do.Attachment{Path: path, FileName: names[i]})
} }
return dir, out return dir, out
} }
@@ -4,7 +4,7 @@
package telegram package telegram
import ( import (
"Wavelet/plugins/domain/message_gateway/model" "Wavelet/plugins/domain/msg_gateway/model/do"
"context" "context"
"testing" "testing"
"time" "time"
@@ -16,7 +16,7 @@ import (
// telebot 以 int(timeout/time.Second) 下发给 getUpdates。写成裸整数会被解释为 // telebot 以 int(timeout/time.Second) 下发给 getUpdates。写成裸整数会被解释为
// 纳秒,令 timeout=0,长轮询退化为对 Bot API 的空转轮询。 // 纳秒,令 timeout=0,长轮询退化为对 Bot API 的空转轮询。
func TestBuildTeleSettingsLongPollWindow(t *testing.T) { func TestBuildTeleSettingsLongPollWindow(t *testing.T) {
pref := buildTeleSettings(model.ChannelConfig{ pref := buildTeleSettings(do.ChannelConfig{
Credentials: map[string]string{"bot_token": "token"}, Credentials: map[string]string{"bot_token": "token"},
Extra: map[string]string{"base_url": "https://tg.example.com/api/"}, Extra: map[string]string{"base_url": "https://tg.example.com/api/"},
}) })
@@ -35,7 +35,7 @@ func TestBuildTeleSettingsLongPollWindow(t *testing.T) {
func TestHandleUpdate_DropsGroups(t *testing.T) { func TestHandleUpdate_DropsGroups(t *testing.T) {
var got int var got int
a := &Adapter{onInbound: func(ctx context.Context, msg model.InboundMessage) error { a := &Adapter{onInbound: func(_ context.Context, _ do.InboundMessage) error {
got++ got++
return nil return nil
}} }}
@@ -51,10 +51,10 @@ func TestHandleUpdate_DropsGroups(t *testing.T) {
} }
func TestHandleUpdate_PrivateText(t *testing.T) { func TestHandleUpdate_PrivateText(t *testing.T) {
var got model.InboundMessage var got do.InboundMessage
a := &Adapter{ a := &Adapter{
cfg: model.ChannelConfig{ID: 7, Type: "telegram"}, cfg: do.ChannelConfig{ID: 7, Type: "telegram"},
onInbound: func(ctx context.Context, msg model.InboundMessage) error { onInbound: func(_ context.Context, msg do.InboundMessage) error {
got = msg got = msg
return nil return nil
}, },
@@ -71,7 +71,7 @@ func TestHandleUpdate_PrivateText(t *testing.T) {
} }
func TestNew_RequiresToken(t *testing.T) { func TestNew_RequiresToken(t *testing.T) {
_, err := New(model.ChannelConfig{}, nil) _, err := New(do.ChannelConfig{}, nil)
if err == nil { if err == nil {
t.Fatal("expected error") t.Fatal("expected error")
} }
@@ -1,12 +1,69 @@
// Copyright 2026 Arctel.net // Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
// Package errs defines error sentinels and user-facing error message constants // Package consts defines constants, sentinel errors, and user-facing error messages
// for the message_gateway plugin. // for the msg_gateway plugin.
package errs package consts
import "errors" import "errors"
// Channel type and scope constants.
const (
ChannelTypeTelegram = "telegram"
ChannelTypeQQ = "qq"
MessageChannelTypeTelegram = "telegram"
MessageChannelTypeQQ = "qq"
MessageOwnerScopeSystem = "system"
TypeCustom = "custom"
TypeEmail = "email"
TypeTelegram = "telegram"
ChannelCustom = "custom"
ChannelEmail = "email"
ChannelLark = "lark"
ChannelDingTalk = "dingtalk"
ChannelTelegram = "telegram"
ChannelBark = "bark"
ChannelDiscord = "discord"
ChannelSlack = "slack"
ChannelPushover = "pushover"
DefaultLevelInfo = "INFO"
KeyTitle = "title"
KeyContent = "content"
KeyLevel = "level"
// KeyURL represents the URL field key.
KeyURL = "url"
// KeyToken represents the Token field key.
KeyToken = "token"
// KeyOther represents the Other field key.
KeyOther = "other"
// TypeText represents standard text input type.
TypeText = "text"
// TypePassword represents password input type.
TypePassword = "password"
// TypeTextarea represents textarea input type.
TypeTextarea = "textarea"
)
// Task and Schedule identifier constants.
const (
TaskPushNotification = "msg_gateway:push_notification"
TaskCleanupPairingCodes = "msg_gateway:cleanup_pairing_codes"
TaskDispatchBotMsg = "msg_gateway:dispatch_bot_msg"
TaskTypeDispatchBotMsg = "dispatch_bot_msg"
SendNotificationTask = "push:send"
TaskTypeSendNotification = "send_notification"
)
// Pairing code constants.
const (
CodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
CodeLength = 8
)
// Sentinel errors. // Sentinel errors.
var ( var (
ErrCodeInvalid = errors.New("invalid or expired pairing code") ErrCodeInvalid = errors.New("invalid or expired pairing code")
@@ -17,11 +74,11 @@ var (
ErrChannelIDRequired = errors.New("channel_id is required") ErrChannelIDRequired = errors.New("channel_id is required")
ErrChannelDisabled = errors.New("channel is not enabled") ErrChannelDisabled = errors.New("channel is not enabled")
// ErrRecordNotFound maps GORM's missing-row sentinel at the repository boundary so // ErrRecordNotFound maps GORM's missing-row sentinel at the DAO boundary so
// upper layers never import gorm. Its text matches gorm.ErrRecordNotFound verbatim. // upper layers never import gorm. Its text matches gorm.ErrRecordNotFound verbatim.
ErrRecordNotFound = errors.New("record not found") ErrRecordNotFound = errors.New("record not found")
// ErrUnsupportedUserLookupField rejects a column name that the repository is not // ErrUnsupportedUserLookupField rejects a column name that the DAO is not
// allowed to interpolate into a WHERE clause. // allowed to interpolate into a WHERE clause.
ErrUnsupportedUserLookupField = errors.New("unsupported user lookup field") ErrUnsupportedUserLookupField = errors.New("unsupported user lookup field")
) )
@@ -1,12 +1,12 @@
// Copyright 2026 Arctel.net // Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package handler package controller
import ( import (
"Wavelet/pkg/response" "Wavelet/pkg/response"
"Wavelet/plugins/domain/message_gateway/errs" "Wavelet/plugins/domain/msg_gateway/consts"
"Wavelet/plugins/domain/message_gateway/service" "Wavelet/plugins/domain/msg_gateway/service"
"net/http" "net/http"
"strconv" "strconv"
@@ -19,7 +19,7 @@ import (
// @Tags admin-message-gateway // @Tags admin-message-gateway
// @Produce json // @Produce json
// @Security SessionCookie // @Security SessionCookie
// @Success 200 {object} response.Any{data=[]model.Definition} // @Success 200 {object} response.Any{data=[]do.Definition}
// @Router /api/v1/admin/message-gateway/channels/definitions [get] // @Router /api/v1/admin/message-gateway/channels/definitions [get]
func ListAdminChannelDefinitions(c *gin.Context) { func ListAdminChannelDefinitions(c *gin.Context) {
c.JSON(http.StatusOK, response.OK(service.ListDefinitions())) c.JSON(http.StatusOK, response.OK(service.ListDefinitions()))
@@ -31,7 +31,7 @@ func ListAdminChannelDefinitions(c *gin.Context) {
// @Tags admin-message-gateway // @Tags admin-message-gateway
// @Produce json // @Produce json
// @Security SessionCookie // @Security SessionCookie
// @Success 200 {object} response.Any{data=[]model.ChannelDTO} // @Success 200 {object} response.Any{data=[]do.ChannelDTO}
// @Router /api/v1/admin/message-gateway/channels [get] // @Router /api/v1/admin/message-gateway/channels [get]
func ListAdminChannels(c *gin.Context) { func ListAdminChannels(c *gin.Context) {
rows, err := service.ListChannels(c.Request.Context()) rows, err := service.ListChannels(c.Request.Context())
@@ -45,14 +45,14 @@ func ListAdminChannels(c *gin.Context) {
func parseAdminChannelID(c *gin.Context) (uint64, bool) { func parseAdminChannelID(c *gin.Context) (uint64, bool) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
response.AbortBadRequest(c, errs.ErrInvalidChannelID) response.AbortBadRequest(c, consts.ErrInvalidChannelID)
return 0, false return 0, false
} }
return id, true return id, true
} }
func handleAdminChannelError(c *gin.Context, err error, fallback func(c *gin.Context, msg string)) { func handleAdminChannelError(c *gin.Context, err error, fallback func(c *gin.Context, msg string)) {
if err.Error() == errs.ErrChannelNotFound { if err.Error() == consts.ErrChannelNotFound {
response.AbortNotFound(c, err.Error()) response.AbortNotFound(c, err.Error())
return return
} }
@@ -66,8 +66,8 @@ func handleAdminChannelError(c *gin.Context, err error, fallback func(c *gin.Con
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Security SessionCookie // @Security SessionCookie
// @Param request body model.CreateChannelRequest true "create body" // @Param request body do.CreateChannelRequest true "create body"
// @Success 200 {object} response.Any{data=model.ChannelDTO} // @Success 200 {object} response.Any{data=do.ChannelDTO}
// @Failure 400 {object} response.Any // @Failure 400 {object} response.Any
// @Router /api/v1/admin/message-gateway/channels [post] // @Router /api/v1/admin/message-gateway/channels [post]
func CreateAdminChannel(c *gin.Context) { func CreateAdminChannel(c *gin.Context) {
@@ -82,8 +82,8 @@ func CreateAdminChannel(c *gin.Context) {
// @Produce json // @Produce json
// @Security SessionCookie // @Security SessionCookie
// @Param id path int true "channel id" // @Param id path int true "channel id"
// @Param request body model.UpdateChannelRequest true "update body" // @Param request body do.UpdateChannelRequest true "update body"
// @Success 200 {object} response.Any{data=model.ChannelDTO} // @Success 200 {object} response.Any{data=do.ChannelDTO}
// @Failure 400 {object} response.Any // @Failure 400 {object} response.Any
// @Failure 404 {object} response.Any // @Failure 404 {object} response.Any
// @Router /api/v1/admin/message-gateway/channels/{id} [patch] // @Router /api/v1/admin/message-gateway/channels/{id} [patch]
@@ -1,13 +1,13 @@
// Copyright 2026 Arctel.net // Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package handler package controller
import ( import (
"Wavelet/pkg/response" "Wavelet/pkg/response"
"Wavelet/plugins/domain/message_gateway/errs" "Wavelet/plugins/domain/msg_gateway/consts"
"Wavelet/plugins/domain/message_gateway/model" "Wavelet/plugins/domain/msg_gateway/model/do"
"Wavelet/plugins/domain/message_gateway/service" "Wavelet/plugins/domain/msg_gateway/service"
"errors" "errors"
"net/http" "net/http"
"strconv" "strconv"
@@ -24,7 +24,7 @@ import (
// @Success 200 {object} response.Any "通道配置定义列表" // @Success 200 {object} response.Any "通道配置定义列表"
// @Router /api/v1/admin/push/channels/definitions [get] // @Router /api/v1/admin/push/channels/definitions [get]
func ListPushChannelDefinitions(c *gin.Context) { func ListPushChannelDefinitions(c *gin.Context) {
c.JSON(http.StatusOK, response.OK(model.ListPushDefinitions())) c.JSON(http.StatusOK, response.OK(do.ListPushDefinitions()))
} }
// ListPushChannels 获取消息通道列表 // ListPushChannels 获取消息通道列表
@@ -33,7 +33,7 @@ func ListPushChannelDefinitions(c *gin.Context) {
// @Tags admin-push // @Tags admin-push
// @Produce json // @Produce json
// @Security SessionCookie // @Security SessionCookie
// @Success 200 {object} response.Any{data=[]model.PushChannel} "消息通道列表" // @Success 200 {object} response.Any{data=[]entity.PushChannel} "消息通道列表"
// @Router /api/v1/admin/push/channels [get] // @Router /api/v1/admin/push/channels [get]
func ListPushChannels(c *gin.Context) { func ListPushChannels(c *gin.Context) {
channels, err := service.ListPushChannels(c.Request.Context()) channels, err := service.ListPushChannels(c.Request.Context())
@@ -48,7 +48,7 @@ func ListPushChannels(c *gin.Context) {
func parsePushChannelID(c *gin.Context) (uint64, bool) { func parsePushChannelID(c *gin.Context) (uint64, bool) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
response.AbortBadRequest(c, errs.ErrInvalidChannelID) response.AbortBadRequest(c, consts.ErrInvalidChannelID)
return 0, false return 0, false
} }
return id, true return id, true
@@ -56,8 +56,8 @@ func parsePushChannelID(c *gin.Context) (uint64, bool) {
// handlePushChannelNotFoundError maps a missing channel row to 404, others to fallback. // handlePushChannelNotFoundError maps a missing channel row to 404, others to fallback.
func handlePushChannelNotFoundError(c *gin.Context, err error, fallback func(c *gin.Context, msg string)) { func handlePushChannelNotFoundError(c *gin.Context, err error, fallback func(c *gin.Context, msg string)) {
if errors.Is(err, errs.ErrRecordNotFound) { if errors.Is(err, consts.ErrRecordNotFound) {
response.AbortNotFound(c, errs.ErrChannelNotFound) response.AbortNotFound(c, consts.ErrChannelNotFound)
return return
} }
fallback(c, err.Error()) fallback(c, err.Error())
@@ -70,8 +70,8 @@ func handlePushChannelNotFoundError(c *gin.Context, err error, fallback func(c *
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Security SessionCookie // @Security SessionCookie
// @Param request body model.CreatePushChannelRequest true "创建参数" // @Param request body do.CreatePushChannelRequest true "创建参数"
// @Success 200 {object} response.Any{data=model.PushChannel} "创建成功" // @Success 200 {object} response.Any{data=entity.PushChannel} "创建成功"
// @Router /api/v1/admin/push/channels [post] // @Router /api/v1/admin/push/channels [post]
func CreatePushChannel(c *gin.Context) { func CreatePushChannel(c *gin.Context) {
handleJSONRequest(c, service.CreatePushChannel) handleJSONRequest(c, service.CreatePushChannel)
@@ -85,8 +85,8 @@ func CreatePushChannel(c *gin.Context) {
// @Produce json // @Produce json
// @Security SessionCookie // @Security SessionCookie
// @Param id path uint64 true "通道ID" // @Param id path uint64 true "通道ID"
// @Param request body model.UpdatePushChannelRequest true "更新参数" // @Param request body do.UpdatePushChannelRequest true "更新参数"
// @Success 200 {object} response.Any{data=model.PushChannel} "更新成功" // @Success 200 {object} response.Any{data=entity.PushChannel} "更新成功"
// @Router /api/v1/admin/push/channels/{id} [put] // @Router /api/v1/admin/push/channels/{id} [put]
func UpdatePushChannel(c *gin.Context) { func UpdatePushChannel(c *gin.Context) {
handleEntityUpdate(c, parsePushChannelID, service.UpdatePushChannel, func(c *gin.Context, err error) { handleEntityUpdate(c, parsePushChannelID, service.UpdatePushChannel, func(c *gin.Context, err error) {
@@ -123,11 +123,11 @@ func DeletePushChannel(c *gin.Context) {
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Security SessionCookie // @Security SessionCookie
// @Param request body model.TestPushChannelRequest true "测试参数" // @Param request body do.TestPushChannelRequest true "测试参数"
// @Success 200 {object} response.Any "测试触发成功" // @Success 200 {object} response.Any "测试触发成功"
// @Router /api/v1/admin/push/channels/test [post] // @Router /api/v1/admin/push/channels/test [post]
func TestPushChannel(c *gin.Context) { func TestPushChannel(c *gin.Context) {
var req model.TestPushChannelRequest var req do.TestPushChannelRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
response.AbortBadRequest(c, err.Error()) response.AbortBadRequest(c, err.Error())
return return
@@ -1,13 +1,13 @@
// Copyright 2026 Arctel.net // Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package handler package controller
import ( import (
"Wavelet/pkg/response" "Wavelet/pkg/response"
"Wavelet/plugins/domain/message_gateway/errs" "Wavelet/plugins/domain/msg_gateway/consts"
"Wavelet/plugins/domain/message_gateway/model" "Wavelet/plugins/domain/msg_gateway/model/do"
"Wavelet/plugins/domain/message_gateway/service" "Wavelet/plugins/domain/msg_gateway/service"
"errors" "errors"
"net/http" "net/http"
"strconv" "strconv"
@@ -21,7 +21,7 @@ import (
// @Tags admin-push // @Tags admin-push
// @Produce json // @Produce json
// @Security SessionCookie // @Security SessionCookie
// @Success 200 {object} response.Any{data=[]model.PushEvent} "通知事件列表" // @Success 200 {object} response.Any{data=[]entity.PushEvent} "通知事件列表"
// @Router /api/v1/admin/push/events [get] // @Router /api/v1/admin/push/events [get]
func ListPushEvents(c *gin.Context) { func ListPushEvents(c *gin.Context) {
ctx := c.Request.Context() ctx := c.Request.Context()
@@ -49,7 +49,7 @@ func ListBuiltInPushEvents(c *gin.Context) {
func parsePushEventID(c *gin.Context) (uint64, bool) { func parsePushEventID(c *gin.Context) (uint64, bool) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
response.AbortBadRequest(c, errs.ErrInvalidEventID) response.AbortBadRequest(c, consts.ErrInvalidEventID)
return 0, false return 0, false
} }
return id, true return id, true
@@ -57,8 +57,8 @@ func parsePushEventID(c *gin.Context) (uint64, bool) {
// handlePushEventNotFoundError maps a missing event row to 404, others to fallback. // handlePushEventNotFoundError maps a missing event row to 404, others to fallback.
func handlePushEventNotFoundError(c *gin.Context, err error, fallback func(c *gin.Context, msg string)) { func handlePushEventNotFoundError(c *gin.Context, err error, fallback func(c *gin.Context, msg string)) {
if errors.Is(err, errs.ErrRecordNotFound) { if errors.Is(err, consts.ErrRecordNotFound) {
response.AbortNotFound(c, errs.ErrEventNotFound) response.AbortNotFound(c, consts.ErrEventNotFound)
return return
} }
fallback(c, err.Error()) fallback(c, err.Error())
@@ -71,8 +71,8 @@ func handlePushEventNotFoundError(c *gin.Context, err error, fallback func(c *gi
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Security SessionCookie // @Security SessionCookie
// @Param request body model.CreatePushEventRequest true "创建参数" // @Param request body do.CreatePushEventRequest true "创建参数"
// @Success 200 {object} response.Any{data=model.PushEvent} "创建成功" // @Success 200 {object} response.Any{data=entity.PushEvent} "创建成功"
// @Router /api/v1/admin/push/events [post] // @Router /api/v1/admin/push/events [post]
func CreatePushEvent(c *gin.Context) { func CreatePushEvent(c *gin.Context) {
handleJSONRequest(c, service.CreatePushEvent) handleJSONRequest(c, service.CreatePushEvent)
@@ -108,7 +108,7 @@ func DeletePushEvent(c *gin.Context) {
// @Produce json // @Produce json
// @Security SessionCookie // @Security SessionCookie
// @Param id path int true "事件 ID" // @Param id path int true "事件 ID"
// @Param request body model.UpdatePushEventRequest true "更新参数" // @Param request body do.UpdatePushEventRequest true "更新参数"
// @Success 200 {object} response.Any{data=string} "修改成功" // @Success 200 {object} response.Any{data=string} "修改成功"
// @Router /api/v1/admin/push/events/{id} [put] // @Router /api/v1/admin/push/events/{id} [put]
func UpdatePushEvent(c *gin.Context) { func UpdatePushEvent(c *gin.Context) {
@@ -117,7 +117,7 @@ func UpdatePushEvent(c *gin.Context) {
return return
} }
var req model.UpdatePushEventRequest var req do.UpdatePushEventRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
response.AbortBadRequest(c, err.Error()) response.AbortBadRequest(c, err.Error())
return return
@@ -175,8 +175,9 @@ func ListPushHistories(c *gin.Context) {
pageSize = 20 pageSize = 20
} }
total, results, err := service.ListPushHistories(c.Request.Context(), model.PushHistoryListFilter{ total, results, err := service.ListPushHistories(c.Request.Context(), do.PushHistoryListFilter{
EventKey: c.Query("event_key"), EventKey: c.Query("event_key"),
Channel: c.Query("channel"),
Status: c.Query("status"), Status: c.Query("status"),
Page: page, Page: page,
PageSize: pageSize, PageSize: pageSize,
@@ -199,11 +200,11 @@ func ListPushHistories(c *gin.Context) {
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Security SessionCookie // @Security SessionCookie
// @Param request body model.TestPushRequest true "测试请求体" // @Param request body do.TestPushRequest true "测试请求体"
// @Success 200 {object} response.Any{data=string} "测试成功" // @Success 200 {object} response.Any{data=string} "测试成功"
// @Router /api/v1/admin/push/test [post] // @Router /api/v1/admin/push/test [post]
func TestPush(c *gin.Context) { func TestPush(c *gin.Context) {
var req model.TestPushRequest var req do.TestPushRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
response.AbortBadRequest(c, err.Error()) response.AbortBadRequest(c, err.Error())
return return
@@ -1,7 +1,8 @@
// Copyright 2026 Arctel.net // Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package handler // Package controller provides HTTP endpoints for msg_gateway.
package controller
import ( import (
"Wavelet/core/extpoints" "Wavelet/core/extpoints"
@@ -1,16 +1,15 @@
// Copyright 2026 Arctel.net // Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
// Package handler provides HTTP endpoints for message_gateway. package controller
package handler
import ( import (
"Wavelet/core/contracts" "Wavelet/core/contracts"
"Wavelet/pkg/ginutil" "Wavelet/pkg/ginutil"
"Wavelet/pkg/response" "Wavelet/pkg/response"
"Wavelet/plugins/domain/message_gateway/errs" "Wavelet/plugins/domain/msg_gateway/consts"
"Wavelet/plugins/domain/message_gateway/model" "Wavelet/plugins/domain/msg_gateway/model/do"
"Wavelet/plugins/domain/message_gateway/service" "Wavelet/plugins/domain/msg_gateway/service"
"context" "context"
"errors" "errors"
"net/http" "net/http"
@@ -70,12 +69,12 @@ func handleEntityUpdate[Req any, Res any](
// @Tags message-gateway // @Tags message-gateway
// @Produce json // @Produce json
// @Security SessionCookie // @Security SessionCookie
// @Success 200 {object} response.Any{data=[]model.PublicChannelDTO} // @Success 200 {object} response.Any{data=[]do.PublicChannelDTO}
// @Failure 401 {object} response.Any // @Failure 401 {object} response.Any
// @Router /api/v1/message-gateway/channels [get] // @Router /api/v1/message-gateway/channels [get]
func ListChannels(c *gin.Context) { func ListChannels(c *gin.Context) {
if user, ok := currentUser(c); !ok || user == nil { if user, ok := currentUser(c); !ok || user == nil {
response.AbortUnauthorized(c, errs.ErrLoginRequired) response.AbortUnauthorized(c, consts.ErrLoginRequired)
return return
} }
rows, err := service.ListEnabledPublicChannels(c.Request.Context()) rows, err := service.ListEnabledPublicChannels(c.Request.Context())
@@ -92,13 +91,13 @@ func ListChannels(c *gin.Context) {
// @Tags message-gateway // @Tags message-gateway
// @Produce json // @Produce json
// @Security SessionCookie // @Security SessionCookie
// @Success 200 {object} response.Any{data=[]model.BindingDTO} // @Success 200 {object} response.Any{data=[]do.BindingDTO}
// @Failure 401 {object} response.Any // @Failure 401 {object} response.Any
// @Router /api/v1/message-gateway/bindings [get] // @Router /api/v1/message-gateway/bindings [get]
func ListBindings(c *gin.Context) { func ListBindings(c *gin.Context) {
user, ok := currentUser(c) user, ok := currentUser(c)
if !ok || user == nil { if !ok || user == nil {
response.AbortUnauthorized(c, errs.ErrLoginRequired) response.AbortUnauthorized(c, consts.ErrLoginRequired)
return return
} }
rows, err := service.ListUserBindings(c.Request.Context(), user.ID) rows, err := service.ListUserBindings(c.Request.Context(), user.ID)
@@ -116,25 +115,25 @@ func ListBindings(c *gin.Context) {
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Security SessionCookie // @Security SessionCookie
// @Param request body model.BindRequest true "bind body" // @Param request body do.BindRequest true "bind body"
// @Success 200 {object} response.Any{data=model.BindingDTO} // @Success 200 {object} response.Any{data=do.BindingDTO}
// @Failure 400 {object} response.Any // @Failure 400 {object} response.Any
// @Failure 409 {object} response.Any // @Failure 409 {object} response.Any
// @Router /api/v1/message-gateway/bindings [post] // @Router /api/v1/message-gateway/bindings [post]
func BindBinding(c *gin.Context) { func BindBinding(c *gin.Context) {
user, ok := currentUser(c) user, ok := currentUser(c)
if !ok || user == nil { if !ok || user == nil {
response.AbortUnauthorized(c, errs.ErrLoginRequired) response.AbortUnauthorized(c, consts.ErrLoginRequired)
return return
} }
var req model.BindRequest var req do.BindRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
response.AbortBadRequest(c, err.Error()) response.AbortBadRequest(c, err.Error())
return return
} }
dto, err := service.BindChannel(c.Request.Context(), user.ID, req) dto, err := service.BindChannel(c.Request.Context(), user.ID, req)
if err != nil { if err != nil {
if errors.Is(err, errs.ErrPlatformAlreadyBound) { if errors.Is(err, consts.ErrPlatformAlreadyBound) {
response.AbortConflict(c, err.Error()) response.AbortConflict(c, err.Error())
return return
} }
@@ -158,20 +157,20 @@ func BindBinding(c *gin.Context) {
func UnbindBinding(c *gin.Context) { func UnbindBinding(c *gin.Context) {
user, ok := currentUser(c) user, ok := currentUser(c)
if !ok || user == nil { if !ok || user == nil {
response.AbortUnauthorized(c, errs.ErrLoginRequired) response.AbortUnauthorized(c, consts.ErrLoginRequired)
return return
} }
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
response.AbortBadRequest(c, errs.ErrInvalidBindingID) response.AbortBadRequest(c, consts.ErrInvalidBindingID)
return return
} }
if err := service.UnbindChannel(c.Request.Context(), user.ID, id); err != nil { if err := service.UnbindChannel(c.Request.Context(), user.ID, id); err != nil {
if errors.Is(err, errs.ErrBindingNotFound) { if errors.Is(err, consts.ErrBindingNotFound) {
response.AbortNotFound(c, err.Error()) response.AbortNotFound(c, err.Error())
return return
} }
if errors.Is(err, errs.ErrBindingForbidden) { if errors.Is(err, consts.ErrBindingForbidden) {
response.AbortForbidden(c, err.Error()) response.AbortForbidden(c, err.Error())
return return
} }
@@ -1,15 +1,15 @@
// Copyright 2026 Arctel.net // Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
// Package repository provides data persistence for the message_gateway plugin. // Package dao provides database persistence and caching for the msg_gateway plugin.
package repository package dao
import ( import (
"Wavelet/core" "Wavelet/core"
"Wavelet/core/contracts" "Wavelet/core/contracts"
"Wavelet/pkg/idgen" "Wavelet/pkg/idgen"
"Wavelet/plugins/domain/message_gateway/errs" "Wavelet/plugins/domain/msg_gateway/consts"
"Wavelet/plugins/domain/message_gateway/model" "Wavelet/plugins/domain/msg_gateway/model/entity"
"context" "context"
"errors" "errors"
"sync" "sync"
@@ -53,16 +53,16 @@ func GetDB(ctx context.Context) *gorm.DB {
} }
// mapNotFound translates GORM's missing-row sentinel into the plugin-level // mapNotFound translates GORM's missing-row sentinel into the plugin-level
// errs.ErrRecordNotFound so the service and handler layers stay free of gorm imports. // consts.ErrRecordNotFound so the service and controller layers stay free of gorm imports.
func mapNotFound(err error) error { func mapNotFound(err error) error {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return errs.ErrRecordNotFound return consts.ErrRecordNotFound
} }
return err return err
} }
// CreateMessageChannel inserts a channel row. // CreateMessageChannel inserts a channel row.
func CreateMessageChannel(ctx context.Context, ch *model.MessageChannel) error { func CreateMessageChannel(ctx context.Context, ch *entity.MessageChannel) error {
if ch.ID == 0 { if ch.ID == 0 {
ch.ID = idgen.NextUint64ID() ch.ID = idgen.NextUint64ID()
} }
@@ -70,13 +70,13 @@ func CreateMessageChannel(ctx context.Context, ch *model.MessageChannel) error {
} }
// UpdateMessageChannel saves a channel row. // UpdateMessageChannel saves a channel row.
func UpdateMessageChannel(ctx context.Context, ch *model.MessageChannel) error { func UpdateMessageChannel(ctx context.Context, ch *entity.MessageChannel) error {
return GetDB(ctx).Save(ch).Error return GetDB(ctx).Save(ch).Error
} }
// GetMessageChannel loads a channel by id. // GetMessageChannel loads a channel by id.
func GetMessageChannel(ctx context.Context, id uint64) (*model.MessageChannel, error) { func GetMessageChannel(ctx context.Context, id uint64) (*entity.MessageChannel, error) {
var ch model.MessageChannel var ch entity.MessageChannel
if err := GetDB(ctx).Where("id = ?", id).First(&ch).Error; err != nil { if err := GetDB(ctx).Where("id = ?", id).First(&ch).Error; err != nil {
return nil, mapNotFound(err) return nil, mapNotFound(err)
} }
@@ -84,8 +84,8 @@ func GetMessageChannel(ctx context.Context, id uint64) (*model.MessageChannel, e
} }
// ListMessageChannels returns all channels newest first. // ListMessageChannels returns all channels newest first.
func ListMessageChannels(ctx context.Context) ([]model.MessageChannel, error) { func ListMessageChannels(ctx context.Context) ([]entity.MessageChannel, error) {
var rows []model.MessageChannel var rows []entity.MessageChannel
if err := GetDB(ctx).Order("id DESC").Find(&rows).Error; err != nil { if err := GetDB(ctx).Order("id DESC").Find(&rows).Error; err != nil {
return nil, err return nil, err
} }
@@ -95,18 +95,18 @@ func ListMessageChannels(ctx context.Context) ([]model.MessageChannel, error) {
// DeleteMessageChannel removes pairings, bindings, then the channel. // DeleteMessageChannel removes pairings, bindings, then the channel.
func DeleteMessageChannel(ctx context.Context, id uint64) error { func DeleteMessageChannel(ctx context.Context, id uint64) error {
return GetDB(ctx).Transaction(func(tx *gorm.DB) error { return GetDB(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("channel_id = ?", id).Delete(&model.MessagePairingCode{}).Error; err != nil { if err := tx.Where("channel_id = ?", id).Delete(&entity.MessagePairingCode{}).Error; err != nil {
return err return err
} }
if err := tx.Where("channel_id = ?", id).Delete(&model.MessageBinding{}).Error; err != nil { if err := tx.Where("channel_id = ?", id).Delete(&entity.MessageBinding{}).Error; err != nil {
return err return err
} }
return tx.Delete(&model.MessageChannel{}, id).Error return tx.Delete(&entity.MessageChannel{}, id).Error
}) })
} }
// CreateMessageBinding inserts a binding. // CreateMessageBinding inserts a binding.
func CreateMessageBinding(ctx context.Context, b *model.MessageBinding) error { func CreateMessageBinding(ctx context.Context, b *entity.MessageBinding) error {
if b.ID == 0 { if b.ID == 0 {
b.ID = idgen.NextUint64ID() b.ID = idgen.NextUint64ID()
} }
@@ -114,8 +114,8 @@ func CreateMessageBinding(ctx context.Context, b *model.MessageBinding) error {
} }
// GetBindingByChannelPlatform finds a binding for a platform user on a channel. // GetBindingByChannelPlatform finds a binding for a platform user on a channel.
func GetBindingByChannelPlatform(ctx context.Context, channelID uint64, platformUserID string) (*model.MessageBinding, error) { func GetBindingByChannelPlatform(ctx context.Context, channelID uint64, platformUserID string) (*entity.MessageBinding, error) {
var b model.MessageBinding var b entity.MessageBinding
err := GetDB(ctx).Where("channel_id = ? AND platform_user_id = ?", channelID, platformUserID).First(&b).Error err := GetDB(ctx).Where("channel_id = ? AND platform_user_id = ?", channelID, platformUserID).First(&b).Error
if err != nil { if err != nil {
return nil, mapNotFound(err) return nil, mapNotFound(err)
@@ -124,8 +124,8 @@ func GetBindingByChannelPlatform(ctx context.Context, channelID uint64, platform
} }
// ListBindingsByUser lists bindings for a Wavelet user. // ListBindingsByUser lists bindings for a Wavelet user.
func ListBindingsByUser(ctx context.Context, userID uint64) ([]model.MessageBinding, error) { func ListBindingsByUser(ctx context.Context, userID uint64) ([]entity.MessageBinding, error) {
var rows []model.MessageBinding var rows []entity.MessageBinding
if err := GetDB(ctx).Where("user_id = ?", userID).Order("id DESC").Find(&rows).Error; err != nil { if err := GetDB(ctx).Where("user_id = ?", userID).Order("id DESC").Find(&rows).Error; err != nil {
return nil, err return nil, err
} }
@@ -133,8 +133,8 @@ func ListBindingsByUser(ctx context.Context, userID uint64) ([]model.MessageBind
} }
// ListBindingsByChannel lists bindings on one messaging channel. // ListBindingsByChannel lists bindings on one messaging channel.
func ListBindingsByChannel(ctx context.Context, channelID uint64) ([]model.MessageBinding, error) { func ListBindingsByChannel(ctx context.Context, channelID uint64) ([]entity.MessageBinding, error) {
var rows []model.MessageBinding var rows []entity.MessageBinding
if err := GetDB(ctx).Where("channel_id = ?", channelID).Order("id DESC").Find(&rows).Error; err != nil { if err := GetDB(ctx).Where("channel_id = ?", channelID).Order("id DESC").Find(&rows).Error; err != nil {
return nil, err return nil, err
} }
@@ -142,8 +142,8 @@ func ListBindingsByChannel(ctx context.Context, channelID uint64) ([]model.Messa
} }
// GetMessageBinding loads a binding by id. // GetMessageBinding loads a binding by id.
func GetMessageBinding(ctx context.Context, id uint64) (*model.MessageBinding, error) { func GetMessageBinding(ctx context.Context, id uint64) (*entity.MessageBinding, error) {
var b model.MessageBinding var b entity.MessageBinding
if err := GetDB(ctx).Where("id = ?", id).First(&b).Error; err != nil { if err := GetDB(ctx).Where("id = ?", id).First(&b).Error; err != nil {
return nil, mapNotFound(err) return nil, mapNotFound(err)
} }
@@ -152,12 +152,12 @@ func GetMessageBinding(ctx context.Context, id uint64) (*model.MessageBinding, e
// DeleteMessageBinding deletes a binding by id. // DeleteMessageBinding deletes a binding by id.
func DeleteMessageBinding(ctx context.Context, id uint64) error { func DeleteMessageBinding(ctx context.Context, id uint64) error {
return GetDB(ctx).Delete(&model.MessageBinding{}, id).Error return GetDB(ctx).Delete(&entity.MessageBinding{}, id).Error
} }
// UpsertPairingCode reuses an unexpired code for the same channel+platform user. // UpsertPairingCode reuses an unexpired code for the same channel+platform user.
func UpsertPairingCode(ctx context.Context, channelID uint64, platformUserID, code string, expiresAt time.Time) (*model.MessagePairingCode, error) { func UpsertPairingCode(ctx context.Context, channelID uint64, platformUserID, code string, expiresAt time.Time) (*entity.MessagePairingCode, error) {
var existing model.MessagePairingCode var existing entity.MessagePairingCode
err := GetDB(ctx). err := GetDB(ctx).
Where("channel_id = ? AND platform_user_id = ? AND expires_at > ?", channelID, platformUserID, time.Now()). Where("channel_id = ? AND platform_user_id = ? AND expires_at > ?", channelID, platformUserID, time.Now()).
First(&existing).Error First(&existing).Error
@@ -167,7 +167,7 @@ func UpsertPairingCode(ctx context.Context, channelID uint64, platformUserID, co
if !errors.Is(err, gorm.ErrRecordNotFound) { if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err return nil, err
} }
row := &model.MessagePairingCode{ row := &entity.MessagePairingCode{
Code: code, Code: code,
ChannelID: channelID, ChannelID: channelID,
PlatformUserID: platformUserID, PlatformUserID: platformUserID,
@@ -180,8 +180,8 @@ func UpsertPairingCode(ctx context.Context, channelID uint64, platformUserID, co
} }
// GetPairingCode loads a pairing code by normalized code string. // GetPairingCode loads a pairing code by normalized code string.
func GetPairingCode(ctx context.Context, code string) (*model.MessagePairingCode, error) { func GetPairingCode(ctx context.Context, code string) (*entity.MessagePairingCode, error) {
var row model.MessagePairingCode var row entity.MessagePairingCode
if err := GetDB(ctx).Where("code = ?", code).First(&row).Error; err != nil { if err := GetDB(ctx).Where("code = ?", code).First(&row).Error; err != nil {
return nil, mapNotFound(err) return nil, mapNotFound(err)
} }
@@ -190,17 +190,17 @@ func GetPairingCode(ctx context.Context, code string) (*model.MessagePairingCode
// DeletePairingCode removes a pairing code. // DeletePairingCode removes a pairing code.
func DeletePairingCode(ctx context.Context, code string) error { func DeletePairingCode(ctx context.Context, code string) error {
return GetDB(ctx).Where("code = ?", code).Delete(&model.MessagePairingCode{}).Error return GetDB(ctx).Where("code = ?", code).Delete(&entity.MessagePairingCode{}).Error
} }
// DeleteExpiredPairingCodes removes expired pairing rows. // DeleteExpiredPairingCodes removes expired pairing rows.
func DeleteExpiredPairingCodes(ctx context.Context) error { func DeleteExpiredPairingCodes(ctx context.Context) error {
return GetDB(ctx).Where("expires_at <= ?", time.Now()).Delete(&model.MessagePairingCode{}).Error return GetDB(ctx).Where("expires_at <= ?", time.Now()).Delete(&entity.MessagePairingCode{}).Error
} }
// ListEnabledMessageChannels returns enabled channels. // ListEnabledMessageChannels returns enabled channels.
func ListEnabledMessageChannels(ctx context.Context) ([]model.MessageChannel, error) { func ListEnabledMessageChannels(ctx context.Context) ([]entity.MessageChannel, error) {
var rows []model.MessageChannel var rows []entity.MessageChannel
if err := GetDB(ctx).Where("enabled = ?", true).Find(&rows).Error; err != nil { if err := GetDB(ctx).Where("enabled = ?", true).Find(&rows).Error; err != nil {
return nil, err return nil, err
} }
@@ -1,13 +1,14 @@
// Copyright 2026 Arctel.net // Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package repository package dao
import ( import (
"Wavelet/core" "Wavelet/core"
"Wavelet/core/contracts" "Wavelet/core/contracts"
"Wavelet/plugins/domain/message_gateway/errs" "Wavelet/plugins/domain/msg_gateway/consts"
"Wavelet/plugins/domain/message_gateway/model" "Wavelet/plugins/domain/msg_gateway/model/do"
"Wavelet/plugins/domain/msg_gateway/model/entity"
"context" "context"
"errors" "errors"
"fmt" "fmt"
@@ -48,8 +49,8 @@ func GetCache(ctx context.Context) contracts.CacheService {
} }
// ListPushChannelsRecord returns all push channels ordered by creation time descending. // ListPushChannelsRecord returns all push channels ordered by creation time descending.
func ListPushChannelsRecord(ctx context.Context) ([]model.PushChannel, error) { func ListPushChannelsRecord(ctx context.Context) ([]entity.PushChannel, error) {
var channels []model.PushChannel var channels []entity.PushChannel
if err := GetDB(ctx).Order("created_at DESC").Find(&channels).Error; err != nil { if err := GetDB(ctx).Order("created_at DESC").Find(&channels).Error; err != nil {
return nil, err return nil, err
} }
@@ -57,17 +58,17 @@ func ListPushChannelsRecord(ctx context.Context) ([]model.PushChannel, error) {
} }
// GetPushChannelByIDRecord loads a push channel by primary key. // GetPushChannelByIDRecord loads a push channel by primary key.
func GetPushChannelByIDRecord(ctx context.Context, id uint64) (model.PushChannel, error) { func GetPushChannelByIDRecord(ctx context.Context, id uint64) (entity.PushChannel, error) {
var channel model.PushChannel var channel entity.PushChannel
if err := GetDB(ctx).Where("id = ?", id).First(&channel).Error; err != nil { if err := GetDB(ctx).Where("id = ?", id).First(&channel).Error; err != nil {
return model.PushChannel{}, mapNotFound(err) return entity.PushChannel{}, mapNotFound(err)
} }
return channel, nil return channel, nil
} }
// GetPushChannelByNameRecord loads a push channel by its unique name. // GetPushChannelByNameRecord loads a push channel by its unique name.
func GetPushChannelByNameRecord(ctx context.Context, name string) (*model.PushChannel, error) { func GetPushChannelByNameRecord(ctx context.Context, name string) (*entity.PushChannel, error) {
var channel model.PushChannel var channel entity.PushChannel
if err := GetDB(ctx).Where("name = ?", name).First(&channel).Error; err != nil { if err := GetDB(ctx).Where("name = ?", name).First(&channel).Error; err != nil {
return nil, mapNotFound(err) return nil, mapNotFound(err)
} }
@@ -77,14 +78,14 @@ func GetPushChannelByNameRecord(ctx context.Context, name string) (*model.PushCh
// CountPushChannelsByNameRecord returns how many channels share the given name. // CountPushChannelsByNameRecord returns how many channels share the given name.
func CountPushChannelsByNameRecord(ctx context.Context, name string) (int64, error) { func CountPushChannelsByNameRecord(ctx context.Context, name string) (int64, error) {
var count int64 var count int64
if err := GetDB(ctx).Model(&model.PushChannel{}).Where("name = ?", name).Count(&count).Error; err != nil { if err := GetDB(ctx).Model(&entity.PushChannel{}).Where("name = ?", name).Count(&count).Error; err != nil {
return 0, err return 0, err
} }
return count, nil return count, nil
} }
// CreatePushChannelRecord persists a new channel and invalidates cache. // CreatePushChannelRecord persists a new channel and invalidates cache.
func CreatePushChannelRecord(ctx context.Context, channel *model.PushChannel) error { func CreatePushChannelRecord(ctx context.Context, channel *entity.PushChannel) error {
if err := GetDB(ctx).Create(channel).Error; err != nil { if err := GetDB(ctx).Create(channel).Error; err != nil {
return err return err
} }
@@ -93,7 +94,7 @@ func CreatePushChannelRecord(ctx context.Context, channel *model.PushChannel) er
} }
// SavePushChannelRecord updates a channel and invalidates cache. // SavePushChannelRecord updates a channel and invalidates cache.
func SavePushChannelRecord(ctx context.Context, channel *model.PushChannel) error { func SavePushChannelRecord(ctx context.Context, channel *entity.PushChannel) error {
if err := GetDB(ctx).Save(channel).Error; err != nil { if err := GetDB(ctx).Save(channel).Error; err != nil {
return err return err
} }
@@ -102,7 +103,7 @@ func SavePushChannelRecord(ctx context.Context, channel *model.PushChannel) erro
} }
// DeletePushChannelRecord removes a channel and invalidates cache. // DeletePushChannelRecord removes a channel and invalidates cache.
func DeletePushChannelRecord(ctx context.Context, channel *model.PushChannel) error { func DeletePushChannelRecord(ctx context.Context, channel *entity.PushChannel) error {
if err := GetDB(ctx).Delete(channel).Error; err != nil { if err := GetDB(ctx).Delete(channel).Error; err != nil {
return err return err
} }
@@ -131,8 +132,8 @@ func getCachedOrQuery[T any](ctx context.Context, cacheKey string, ttl time.Dura
} }
// GetActivePushChannelByName loads an enabled push channel, preferring the cache layer. // GetActivePushChannelByName loads an enabled push channel, preferring the cache layer.
func GetActivePushChannelByName(ctx context.Context, name string) (*model.PushChannel, error) { func GetActivePushChannelByName(ctx context.Context, name string) (*entity.PushChannel, error) {
channel, err := getCachedOrQuery(ctx, "push:channel:active:"+name, activePushChannelCacheTTL, func(db *gorm.DB, dest *model.PushChannel) error { channel, err := getCachedOrQuery(ctx, "push:channel:active:"+name, activePushChannelCacheTTL, func(db *gorm.DB, dest *entity.PushChannel) error {
return db.Where("name = ? AND enabled = ?", name, true).First(dest).Error return db.Where("name = ? AND enabled = ?", name, true).First(dest).Error
}) })
if err != nil { if err != nil {
@@ -149,8 +150,8 @@ func DeleteActivePushChannelCache(ctx context.Context, name string) {
} }
// ListPushEventsRecord returns all push events ordered by creation time descending. // ListPushEventsRecord returns all push events ordered by creation time descending.
func ListPushEventsRecord(ctx context.Context) ([]model.PushEvent, error) { func ListPushEventsRecord(ctx context.Context) ([]entity.PushEvent, error) {
var events []model.PushEvent var events []entity.PushEvent
if err := GetDB(ctx).Order("created_at DESC").Find(&events).Error; err != nil { if err := GetDB(ctx).Order("created_at DESC").Find(&events).Error; err != nil {
return nil, err return nil, err
} }
@@ -158,19 +159,19 @@ func ListPushEventsRecord(ctx context.Context) ([]model.PushEvent, error) {
} }
// GetPushEventByIDRecord loads a push event by primary key. // GetPushEventByIDRecord loads a push event by primary key.
func GetPushEventByIDRecord(ctx context.Context, id uint64) (model.PushEvent, error) { func GetPushEventByIDRecord(ctx context.Context, id uint64) (entity.PushEvent, error) {
var event model.PushEvent var event entity.PushEvent
if err := GetDB(ctx).First(&event, id).Error; err != nil { if err := GetDB(ctx).First(&event, id).Error; err != nil {
return model.PushEvent{}, mapNotFound(err) return entity.PushEvent{}, mapNotFound(err)
} }
return event, nil return event, nil
} }
// GetPushEventByKeyRecord loads a push event by event key. // GetPushEventByKeyRecord loads a push event by event key.
func GetPushEventByKeyRecord(ctx context.Context, key string) (model.PushEvent, error) { func GetPushEventByKeyRecord(ctx context.Context, key string) (entity.PushEvent, error) {
var event model.PushEvent var event entity.PushEvent
if err := GetDB(ctx).Where("event_key = ?", key).First(&event).Error; err != nil { if err := GetDB(ctx).Where("event_key = ?", key).First(&event).Error; err != nil {
return model.PushEvent{}, mapNotFound(err) return entity.PushEvent{}, mapNotFound(err)
} }
return event, nil return event, nil
} }
@@ -178,14 +179,14 @@ func GetPushEventByKeyRecord(ctx context.Context, key string) (model.PushEvent,
// CountPushEventsByKeyRecord returns how many events use the given event key. // CountPushEventsByKeyRecord returns how many events use the given event key.
func CountPushEventsByKeyRecord(ctx context.Context, key string) (int64, error) { func CountPushEventsByKeyRecord(ctx context.Context, key string) (int64, error) {
var count int64 var count int64
if err := GetDB(ctx).Model(&model.PushEvent{}).Where("event_key = ?", key).Count(&count).Error; err != nil { if err := GetDB(ctx).Model(&entity.PushEvent{}).Where("event_key = ?", key).Count(&count).Error; err != nil {
return 0, err return 0, err
} }
return count, nil return count, nil
} }
// CreatePushEventRecord persists a new push event and invalidates cache. // CreatePushEventRecord persists a new push event and invalidates cache.
func CreatePushEventRecord(ctx context.Context, event *model.PushEvent) error { func CreatePushEventRecord(ctx context.Context, event *entity.PushEvent) error {
if err := GetDB(ctx).Create(event).Error; err != nil { if err := GetDB(ctx).Create(event).Error; err != nil {
return err return err
} }
@@ -194,7 +195,7 @@ func CreatePushEventRecord(ctx context.Context, event *model.PushEvent) error {
} }
// SavePushEventRecord updates a push event and invalidates cache. // SavePushEventRecord updates a push event and invalidates cache.
func SavePushEventRecord(ctx context.Context, event *model.PushEvent) error { func SavePushEventRecord(ctx context.Context, event *entity.PushEvent) error {
if err := GetDB(ctx).Save(event).Error; err != nil { if err := GetDB(ctx).Save(event).Error; err != nil {
return err return err
} }
@@ -203,7 +204,7 @@ func SavePushEventRecord(ctx context.Context, event *model.PushEvent) error {
} }
// UpdatePushEventEnabledRecord toggles the enabled flag for a push event. // UpdatePushEventEnabledRecord toggles the enabled flag for a push event.
func UpdatePushEventEnabledRecord(ctx context.Context, event *model.PushEvent, enabled bool) error { func UpdatePushEventEnabledRecord(ctx context.Context, event *entity.PushEvent, enabled bool) error {
event.Enabled = enabled event.Enabled = enabled
if err := GetDB(ctx).Model(event).Update("enabled", enabled).Error; err != nil { if err := GetDB(ctx).Model(event).Update("enabled", enabled).Error; err != nil {
return err return err
@@ -213,7 +214,7 @@ func UpdatePushEventEnabledRecord(ctx context.Context, event *model.PushEvent, e
} }
// DeletePushEventRecord removes a push event and invalidates cache. // DeletePushEventRecord removes a push event and invalidates cache.
func DeletePushEventRecord(ctx context.Context, event *model.PushEvent) error { func DeletePushEventRecord(ctx context.Context, event *entity.PushEvent) error {
if err := GetDB(ctx).Delete(event).Error; err != nil { if err := GetDB(ctx).Delete(event).Error; err != nil {
return err return err
} }
@@ -222,8 +223,8 @@ func DeletePushEventRecord(ctx context.Context, event *model.PushEvent) error {
} }
// ListActivePushEventsByTaskTypeRecord returns enabled events bound to a task type. // ListActivePushEventsByTaskTypeRecord returns enabled events bound to a task type.
func ListActivePushEventsByTaskTypeRecord(ctx context.Context, taskType string) ([]model.PushEvent, error) { func ListActivePushEventsByTaskTypeRecord(ctx context.Context, taskType string) ([]entity.PushEvent, error) {
var events []model.PushEvent var events []entity.PushEvent
if err := GetDB(ctx).Where("task_type = ? AND enabled = ?", taskType, true).Find(&events).Error; err != nil { if err := GetDB(ctx).Where("task_type = ? AND enabled = ?", taskType, true).Find(&events).Error; err != nil {
return nil, err return nil, err
} }
@@ -231,8 +232,8 @@ func ListActivePushEventsByTaskTypeRecord(ctx context.Context, taskType string)
} }
// GetActivePushEventByKey loads an enabled push event, preferring the cache layer. // GetActivePushEventByKey loads an enabled push event, preferring the cache layer.
func GetActivePushEventByKey(ctx context.Context, key string) (*model.PushEvent, error) { func GetActivePushEventByKey(ctx context.Context, key string) (*entity.PushEvent, error) {
event, err := getCachedOrQuery(ctx, "push:event:active:"+key, activePushEventCacheTTL, func(db *gorm.DB, dest *model.PushEvent) error { event, err := getCachedOrQuery(ctx, "push:event:active:"+key, activePushEventCacheTTL, func(db *gorm.DB, dest *entity.PushEvent) error {
return db.Where("event_key = ? AND enabled = ?", key, true).First(dest).Error return db.Where("event_key = ? AND enabled = ?", key, true).First(dest).Error
}) })
if err != nil { if err != nil {
@@ -249,8 +250,8 @@ func DeleteActivePushEventCache(ctx context.Context, key string) {
} }
// ListPushHistoriesRecord returns paginated push history records. // ListPushHistoriesRecord returns paginated push history records.
func ListPushHistoriesRecord(ctx context.Context, filter model.PushHistoryListFilter) (int64, []model.PushHistory, error) { func ListPushHistoriesRecord(ctx context.Context, filter do.PushHistoryListFilter) (int64, []entity.PushHistory, error) {
query := GetDB(ctx).Model(&model.PushHistory{}).Order("created_at DESC") query := GetDB(ctx).Model(&entity.PushHistory{}).Order("created_at DESC")
if filter.EventKey != "" { if filter.EventKey != "" {
query = query.Where("event_key = ?", filter.EventKey) query = query.Where("event_key = ?", filter.EventKey)
} }
@@ -263,7 +264,7 @@ func ListPushHistoriesRecord(ctx context.Context, filter model.PushHistoryListFi
return 0, nil, err return 0, nil, err
} }
var results []model.PushHistory var results []entity.PushHistory
offset := (filter.Page - 1) * filter.PageSize offset := (filter.Page - 1) * filter.PageSize
if err := query.Offset(offset).Limit(filter.PageSize).Find(&results).Error; err != nil { if err := query.Offset(offset).Limit(filter.PageSize).Find(&results).Error; err != nil {
return 0, nil, err return 0, nil, err
@@ -273,28 +274,23 @@ func ListPushHistoriesRecord(ctx context.Context, filter model.PushHistoryListFi
} }
// CreatePushHistoryRecord persists a push history audit record. // CreatePushHistoryRecord persists a push history audit record.
func CreatePushHistoryRecord(ctx context.Context, history *model.PushHistory) error { func CreatePushHistoryRecord(ctx context.Context, history *entity.PushHistory) error {
return GetDB(ctx).Create(history).Error return GetDB(ctx).Create(history).Error
} }
// PushHistoryQuery returns a scoped query builder for push histories. // PushHistoryQuery returns a scoped query builder for push histories.
func PushHistoryQuery(ctx context.Context) *gorm.DB { func PushHistoryQuery(ctx context.Context) *gorm.DB {
return GetDB(ctx).Model(&model.PushHistory{}) return GetDB(ctx).Model(&entity.PushHistory{})
} }
// smtpConfigKeys are the system-config rows backing the built-in email channel. // smtpConfigKeys are the system-config rows backing the built-in email channel.
var smtpConfigKeys = []string{"smtp_host", "smtp_port", "smtp_username", "smtp_password"} var smtpConfigKeys = []string{"smtp_host", "smtp_port", "smtp_username", "smtp_password"}
// LoadSMTPConfigRecord reads the SMTP settings in one query. // LoadSMTPConfigRecord reads the SMTP settings in one query.
// func LoadSMTPConfigRecord(ctx context.Context) (do.SMTPConfig, error) {
// A key that is simply absent leaves its field empty, which is how an unconfigured
// mailer is represented. A read that fails is returned as an error, so callers
// cannot mistake an unhealthy database for "no SMTP configured" and silently drop
// the notification.
func LoadSMTPConfigRecord(ctx context.Context) (model.SMTPConfig, error) {
db := GetDB(ctx) db := GetDB(ctx)
if db == nil { if db == nil {
return model.SMTPConfig{}, errors.New("database not available") return do.SMTPConfig{}, errors.New("database not available")
} }
var rows []struct { var rows []struct {
@@ -305,10 +301,10 @@ func LoadSMTPConfigRecord(ctx context.Context) (model.SMTPConfig, error) {
Select("key", "value"). Select("key", "value").
Where("key IN ?", smtpConfigKeys). Where("key IN ?", smtpConfigKeys).
Find(&rows).Error; err != nil { Find(&rows).Error; err != nil {
return model.SMTPConfig{}, fmt.Errorf("read smtp system configs: %w", err) return do.SMTPConfig{}, fmt.Errorf("read smtp system configs: %w", err)
} }
var cfg model.SMTPConfig var cfg do.SMTPConfig
for _, row := range rows { for _, row := range rows {
switch row.Key { switch row.Key {
case "smtp_host": case "smtp_host":
@@ -325,8 +321,6 @@ func LoadSMTPConfigRecord(ctx context.Context) (model.SMTPConfig, error) {
} }
// userLookupColumns allow-lists the columns FindUserByFieldRecord may filter on. // userLookupColumns allow-lists the columns FindUserByFieldRecord may filter on.
// The column name is concatenated into the WHERE clause, so anything not listed
// here must never reach the database.
var userLookupColumns = map[string]struct{}{ var userLookupColumns = map[string]struct{}{
"id": {}, "id": {},
"username": {}, "username": {},
@@ -336,11 +330,11 @@ var userLookupColumns = map[string]struct{}{
// contract is not wired yet. field must be one of userLookupColumns. // contract is not wired yet. field must be one of userLookupColumns.
func FindUserByFieldRecord(ctx context.Context, field string, value any) (*contracts.UserDTO, error) { func FindUserByFieldRecord(ctx context.Context, field string, value any) (*contracts.UserDTO, error) {
if _, ok := userLookupColumns[field]; !ok { if _, ok := userLookupColumns[field]; !ok {
return nil, errs.ErrUnsupportedUserLookupField return nil, consts.ErrUnsupportedUserLookupField
} }
db := GetDB(ctx) db := GetDB(ctx)
if db == nil { if db == nil {
return nil, errs.ErrRecordNotFound return nil, consts.ErrRecordNotFound
} }
var user contracts.UserDTO var user contracts.UserDTO
if err := db.Table("w_users").Where(field+" = ?", value).First(&user).Error; err != nil { if err := db.Table("w_users").Where(field+" = ?", value).First(&user).Error; err != nil {
@@ -354,7 +348,7 @@ func FindUserByFieldRecord(ctx context.Context, field string, value any) (*contr
func FindFirstAdminUserRecord(ctx context.Context) (*contracts.UserDTO, error) { func FindFirstAdminUserRecord(ctx context.Context) (*contracts.UserDTO, error) {
db := GetDB(ctx) db := GetDB(ctx)
if db == nil { if db == nil {
return nil, errs.ErrRecordNotFound return nil, consts.ErrRecordNotFound
} }
var adminUser contracts.UserDTO var adminUser contracts.UserDTO
if err := db.Table("w_users").Where("is_admin = ?", true).Order("id ASC").First(&adminUser).Error; err != nil { if err := db.Table("w_users").Where("is_admin = ?", true).Order("id ASC").First(&adminUser).Error; err != nil {
@@ -1,12 +1,12 @@
// Copyright 2026 Arctel.net // Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package repository_test package dao_test
import ( import (
"Wavelet/pkg/testhelper" "Wavelet/pkg/testhelper"
"Wavelet/plugins/domain/message_gateway/errs" "Wavelet/plugins/domain/msg_gateway/consts"
"Wavelet/plugins/domain/message_gateway/repository" "Wavelet/plugins/domain/msg_gateway/dao"
"context" "context"
"errors" "errors"
"testing" "testing"
@@ -35,19 +35,19 @@ func TestFindUserByFieldRecordRejectsUnlistedColumns(t *testing.T) {
t.Fatalf("seed user failed: %v", err) t.Fatalf("seed user failed: %v", err)
} }
repository.SetDBServiceForTest(stubDBService{db: db}) dao.SetDBServiceForTest(stubDBService{db: db})
t.Cleanup(func() { repository.SetDBServiceForTest(nil) }) t.Cleanup(func() { dao.SetDBServiceForTest(nil) })
ctx := context.Background() ctx := context.Background()
user, err := repository.FindUserByFieldRecord(ctx, "username", "seeded") user, err := dao.FindUserByFieldRecord(ctx, "username", "seeded")
if err != nil { if err != nil {
t.Fatalf("allowlisted lookup by username failed: %v", err) t.Fatalf("allowlisted lookup by username failed: %v", err)
} }
if user.ID != 77 { if user.ID != 77 {
t.Errorf("allowlisted lookup returned ID %d, want 77", user.ID) t.Errorf("allowlisted lookup returned ID %d, want 77", user.ID)
} }
if _, err := repository.FindUserByFieldRecord(ctx, "id", uint64(77)); err != nil { if _, err := dao.FindUserByFieldRecord(ctx, "id", uint64(77)); err != nil {
t.Errorf("allowlisted lookup by id failed: %v", err) t.Errorf("allowlisted lookup by id failed: %v", err)
} }
@@ -61,7 +61,7 @@ func TestFindUserByFieldRecordRejectsUnlistedColumns(t *testing.T) {
{"empty field", ""}, {"empty field", ""},
} }
for _, tc := range cases { for _, tc := range cases {
if _, err := repository.FindUserByFieldRecord(ctx, tc.field, "seeded"); !errors.Is(err, errs.ErrUnsupportedUserLookupField) { if _, err := dao.FindUserByFieldRecord(ctx, tc.field, "seeded"); !errors.Is(err, consts.ErrUnsupportedUserLookupField) {
t.Errorf("%s: got err %v, want ErrUnsupportedUserLookupField", tc.name, err) t.Errorf("%s: got err %v, want ErrUnsupportedUserLookupField", tc.name, err)
} }
} }
@@ -100,10 +100,10 @@ func TestLoadSMTPConfigRecordMapsEveryKey(t *testing.T) {
} }
} }
repository.SetDBServiceForTest(stubDBService{db: db}) dao.SetDBServiceForTest(stubDBService{db: db})
t.Cleanup(func() { repository.SetDBServiceForTest(nil) }) t.Cleanup(func() { dao.SetDBServiceForTest(nil) })
cfg, err := repository.LoadSMTPConfigRecord(context.Background()) cfg, err := dao.LoadSMTPConfigRecord(context.Background())
if err != nil { if err != nil {
t.Fatalf("LoadSMTPConfigRecord: %v", err) t.Fatalf("LoadSMTPConfigRecord: %v", err)
} }
@@ -122,10 +122,10 @@ func TestLoadSMTPConfigRecordSurfacesReadFailure(t *testing.T) {
t.Fatalf("open bare sqlite: %v", err) t.Fatalf("open bare sqlite: %v", err)
} }
repository.SetDBServiceForTest(stubDBService{db: bare}) dao.SetDBServiceForTest(stubDBService{db: bare})
t.Cleanup(func() { repository.SetDBServiceForTest(nil) }) t.Cleanup(func() { dao.SetDBServiceForTest(nil) })
if _, err := repository.LoadSMTPConfigRecord(context.Background()); err == nil { if _, err := dao.LoadSMTPConfigRecord(context.Background()); err == nil {
t.Fatal("LoadSMTPConfigRecord returned nil error although the config table cannot be read") t.Fatal("LoadSMTPConfigRecord returned nil error although the config table cannot be read")
} }
} }
@@ -0,0 +1,124 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package do defines domain objects, DTOs, and request/response payloads for msg_gateway.
package do
import "time"
// Capability describes what an adapter can send and receive.
type Capability struct {
Text bool
Image bool
File bool
Reply bool
Group bool
}
// ChannelConfig is the decrypted runtime config passed to a factory.
type ChannelConfig struct {
ID uint64
Type string
Name string
Credentials map[string]string
Extra map[string]string
}
// Recipient is the outbound destination on a platform.
type Recipient struct {
ChatID string
PlatformUserID string
}
// Attachment is a downloaded inbound file sitting on local disk.
type Attachment struct {
Path string
FileName string
MIME string
Error string
}
// InboundMessage is a normalized private-chat message.
type InboundMessage struct {
ChannelID uint64
ChannelType string
PlatformUserID string
ChatID string
MessageID string
Text string
Attachments []Attachment
BindingUserID *uint64
}
// OutboundMessage is a reply or probe send.
type OutboundMessage struct {
Text string
ReplyToID string
Attachments []Attachment
}
// 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"`
}
// 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"`
}
// Field is one admin form field.
type Field struct {
Key string `json:"key"`
Type string `json:"type"`
Required bool `json:"required"`
}
// Definition describes a channel type form.
type Definition struct {
Type string `json:"type"`
Fields []Field `json:"fields"`
}
// ChannelDTO represents a channel for admin consumption.
type ChannelDTO struct {
ID uint64 `json:"id,string"`
Name string `json:"name"`
Type string `json:"type"`
OwnerScope string `json:"owner_scope"`
OwnerID *uint64 `json:"owner_id,string,omitempty"`
Enabled bool `json:"enabled"`
Credentials map[string]string `json:"credentials"`
Extra map[string]string `json:"extra"`
}
// CreateChannelRequest is admin create payload.
type CreateChannelRequest struct {
Name string `json:"name"`
Type string `json:"type"`
Enabled *bool `json:"enabled"`
Credentials map[string]string `json:"credentials"`
Extra map[string]string `json:"extra"`
}
// UpdateChannelRequest is admin update payload.
type UpdateChannelRequest struct {
Name string `json:"name"`
Enabled *bool `json:"enabled"`
Credentials map[string]string `json:"credentials"`
Extra map[string]string `json:"extra"`
}
@@ -1,44 +1,13 @@
// Copyright 2026 Arctel.net // Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package model package do
import ( import (
"Wavelet/plugins/domain/msg_gateway/consts"
pkgpush "Wavelet/plugins/domain/msg_gateway/push"
"sync" "sync"
"time" "time"
pkgpush "Wavelet/plugins/domain/message_gateway/push"
)
// Push channel and payload constants.
const (
ChannelCustom = "custom"
ChannelEmail = "email"
ChannelLark = "lark"
ChannelDingTalk = "dingtalk"
ChannelTelegram = "telegram"
ChannelBark = "bark"
ChannelDiscord = "discord"
ChannelSlack = "slack"
ChannelPushover = "pushover"
DefaultLevelInfo = "INFO"
KeyTitle = "title"
KeyContent = "content"
KeyLevel = "level"
// KeyURL represents the URL field key.
KeyURL = "url"
// KeyToken represents the Token field key.
KeyToken = "token"
// KeyOther represents the Other field key.
KeyOther = "other"
// TypeText represents standard text input type.
TypeText = "text"
// TypePassword represents password input type.
TypePassword = "password"
// TypeTextarea represents textarea input type.
TypeTextarea = "textarea"
) )
// SMTPConfig mirrors the system SMTP settings consumed by the push service. // SMTPConfig mirrors the system SMTP settings consumed by the push service.
@@ -140,12 +109,12 @@ type NotificationMessage struct {
Ext map[string]any `json:"ext,omitempty"` Ext map[string]any `json:"ext,omitempty"`
} }
// Flatten converts the structured NotificationMessage back to a flat map (original json structure). // Flatten converts the structured NotificationMessage back to a flat map.
func (m NotificationMessage) Flatten() map[string]any { func (m NotificationMessage) Flatten() map[string]any {
res := map[string]any{ res := map[string]any{
KeyTitle: m.Title, consts.KeyTitle: m.Title,
KeyContent: m.Content, consts.KeyContent: m.Content,
KeyLevel: m.Level, consts.KeyLevel: m.Level,
} }
for k, v := range m.Ext { for k, v := range m.Ext {
res[k] = v res[k] = v
@@ -161,7 +130,7 @@ type EventMetadata struct {
Description string `json:"description"` Description string `json:"description"`
} }
// SendPayload is the async push dispatch载荷 consumed by the notification worker. // SendPayload is the async push dispatch payload consumed by the notification worker.
type SendPayload struct { type SendPayload struct {
EventKey string `json:"event_key"` EventKey string `json:"event_key"`
Config pkgpush.Config `json:"config"` Config pkgpush.Config `json:"config"`
@@ -181,6 +150,15 @@ type PushHistoryListFilter struct {
PageSize int PageSize int
} }
// PushNotificationEvent defines the payload for eventbus notification trigger.
type PushNotificationEvent struct {
UserID uint64 `json:"user_id"`
Channel string `json:"channel"`
Title string `json:"title"`
Content string `json:"content"`
Metadata map[string]any `json:"metadata,omitempty"`
}
var ( var (
pushDefMu sync.RWMutex pushDefMu sync.RWMutex
pushDefinitions = make(map[string]PushDefinition) pushDefinitions = make(map[string]PushDefinition)
@@ -199,15 +177,15 @@ func ListPushDefinitions() []PushDefinition {
defer pushDefMu.RUnlock() defer pushDefMu.RUnlock()
order := []string{ order := []string{
ChannelCustom, consts.ChannelCustom,
ChannelLark, consts.ChannelLark,
ChannelDingTalk, consts.ChannelDingTalk,
ChannelTelegram, consts.ChannelTelegram,
ChannelBark, consts.ChannelBark,
ChannelDiscord, consts.ChannelDiscord,
ChannelSlack, consts.ChannelSlack,
ChannelPushover, consts.ChannelPushover,
ChannelEmail, consts.ChannelEmail,
} }
res := make([]PushDefinition, 0, len(pushDefinitions)) res := make([]PushDefinition, 0, len(pushDefinitions))
for _, t := range order { for _, t := range order {
@@ -230,25 +208,25 @@ func ListPushDefinitions() []PushDefinition {
return res return res
} }
//nolint:funlen,goconst // Channel definitions registration table //nolint:funlen,goconst,dupl // Channel definitions registration table
func init() { func init() {
RegisterPushChannelDefinition(PushDefinition{ RegisterPushChannelDefinition(PushDefinition{
Type: ChannelCustom, Type: consts.ChannelCustom,
Name: "自定义消息通道", Name: "自定义消息通道",
Description: "使用自定义 HTTP POST 请求向外部 Webhook 发送数据。", Description: "使用自定义 HTTP POST 请求向外部 Webhook 发送数据。",
Fields: []PushField{ Fields: []PushField{
{ {
Key: KeyURL, Key: consts.KeyURL,
Label: "请求地址", Label: "请求地址",
Type: TypeText, Type: consts.TypeText,
Required: true, Required: true,
Placeholder: "在此填写完整的请求地址,必须使用 HTTPS 协议", Placeholder: "在此填写完整的请求地址,必须使用 HTTPS 协议",
Description: "接口请求的完整 HTTPS URL,例如 https://api.example.com/webhook", Description: "接口请求的完整 HTTPS URL,例如 https://api.example.com/webhook",
}, },
{ {
Key: KeyOther, Key: consts.KeyOther,
Label: "请求体 (JSON)", Label: "请求体 (JSON)",
Type: TypeTextarea, Type: consts.TypeTextarea,
Required: true, Required: true,
Placeholder: "在此输入请求体,支持模板变量,必须为合法的 JSON 格式", Placeholder: "在此输入请求体,支持模板变量,必须为合法的 JSON 格式",
Description: "可使用的变量:$title, $description, $content, $url, $to。例如 {\"text\": \"$content\"}", Description: "可使用的变量:$title, $description, $content, $url, $to。例如 {\"text\": \"$content\"}",
@@ -257,30 +235,30 @@ func init() {
}) })
RegisterPushChannelDefinition(PushDefinition{ RegisterPushChannelDefinition(PushDefinition{
Type: ChannelLark, Type: consts.ChannelLark,
Name: "飞书群机器人", Name: "飞书群机器人",
Description: "配置飞书群自定义机器人的 Webhook 接口投递。", Description: "配置飞书群自定义机器人的 Webhook 接口投递。",
Fields: []PushField{ Fields: []PushField{
{ {
Key: KeyURL, Key: consts.KeyURL,
Label: "Webhook 地址", Label: "Webhook 地址",
Type: TypeText, Type: consts.TypeText,
Required: true, Required: true,
Placeholder: "https://open.feishu.cn/open-apis/bot/v2/hook/YOUR_TOKEN", Placeholder: "https://open.feishu.cn/open-apis/bot/v2/hook/YOUR_TOKEN",
Description: "从飞书群机器人设置中复制的 Webhook URL", Description: "从飞书群机器人设置中复制的 Webhook URL",
}, },
{ {
Key: KeyToken, Key: consts.KeyToken,
Label: "签名校验密钥 (Secret) (可选)", Label: "签名校验密钥 (Secret) (可选)",
Type: TypeText, Type: consts.TypeText,
Required: false, Required: false,
Placeholder: "可选,若机器人启用了安全设置中的签名校验,请在此输入", Placeholder: "可选,若机器人启用了安全设置中的签名校验,请在此输入",
Description: "飞书群机器人安全设置中的签名校验 Key", Description: "飞书群机器人安全设置中的签名校验 Key",
}, },
{ {
Key: KeyOther, Key: consts.KeyOther,
Label: "自定义卡片 JSON 模版 (可选)", Label: "自定义卡片 JSON 模版 (可选)",
Type: TypeTextarea, Type: consts.TypeTextarea,
Required: false, Required: false,
Placeholder: "可选,留空则默认使用系统内置的精美互动卡片", Placeholder: "可选,留空则默认使用系统内置的精美互动卡片",
Description: "若填写,必须是合法的飞书卡片 JSON 格式", Description: "若填写,必须是合法的飞书卡片 JSON 格式",
@@ -289,22 +267,22 @@ func init() {
}) })
RegisterPushChannelDefinition(PushDefinition{ RegisterPushChannelDefinition(PushDefinition{
Type: ChannelDingTalk, Type: consts.ChannelDingTalk,
Name: "钉钉群机器人", Name: "钉钉群机器人",
Description: "配置钉钉群自定义机器人的 Webhook 接口投递。", Description: "配置钉钉群自定义机器人的 Webhook 接口投递。",
Fields: []PushField{ Fields: []PushField{
{ {
Key: KeyURL, Key: consts.KeyURL,
Label: "Webhook 地址", Label: "Webhook 地址",
Type: TypeText, Type: consts.TypeText,
Required: true, Required: true,
Placeholder: "https://oapi.dingtalk.com/robot/send?access_token=YOUR_TOKEN", Placeholder: "https://oapi.dingtalk.com/robot/send?access_token=YOUR_TOKEN",
Description: "从钉钉群机器人设置中获取的完整 Webhook URL", Description: "从钉钉群机器人设置中获取的完整 Webhook URL",
}, },
{ {
Key: KeyToken, Key: consts.KeyToken,
Label: "加签密钥 (Secret) (可选)", Label: "加签密钥 (Secret) (可选)",
Type: TypeText, Type: consts.TypeText,
Required: false, Required: false,
Placeholder: "可选,若机器人启用了安全设置中的加签校验,请在此输入 SEC 开头的密钥", Placeholder: "可选,若机器人启用了安全设置中的加签校验,请在此输入 SEC 开头的密钥",
Description: "钉钉群机器人安全设置中的加签 Secret", Description: "钉钉群机器人安全设置中的加签 Secret",
@@ -313,30 +291,30 @@ func init() {
}) })
RegisterPushChannelDefinition(PushDefinition{ RegisterPushChannelDefinition(PushDefinition{
Type: ChannelTelegram, Type: consts.ChannelTelegram,
Name: "Telegram 机器人", Name: "Telegram 机器人",
Description: "配置 Telegram 机器人推送消息。", Description: "配置 Telegram 机器人推送消息。",
Fields: []PushField{ Fields: []PushField{
{ {
Key: KeyURL, Key: consts.KeyURL,
Label: "API 基础地址 (可选)", Label: "API 基础地址 (可选)",
Type: TypeText, Type: consts.TypeText,
Required: false, Required: false,
Placeholder: "https://api.telegram.org", Placeholder: "https://api.telegram.org",
Description: "接口请求的 HTTPS 基础地址,留空默认为 https://api.telegram.org", Description: "接口请求的 HTTPS 基础地址,留空默认为 https://api.telegram.org",
}, },
{ {
Key: KeyToken, Key: consts.KeyToken,
Label: "机器人 Token (Bot Token)", Label: "机器人 Token (Bot Token)",
Type: TypePassword, Type: consts.TypePassword,
Required: true, Required: true,
Placeholder: "在此输入 Telegram 机器人的 Bot Token", Placeholder: "在此输入 Telegram 机器人的 Bot Token",
Description: "通过 BotFather 申请到的机器人 Access Token", Description: "通过 BotFather 申请到的机器人 Access Token",
}, },
{ {
Key: KeyOther, Key: consts.KeyOther,
Label: "默认会话 ID (Chat ID) (可选)", Label: "默认会话 ID (Chat ID) (可选)",
Type: TypeText, Type: consts.TypeText,
Required: false, Required: false,
Placeholder: "例如 -100123456789 或 @channel_name", Placeholder: "例如 -100123456789 或 @channel_name",
Description: "默认的消息接收 Chat ID。如果通知事件中未配置 targets,将推送到此 ID", Description: "默认的消息接收 Chat ID。如果通知事件中未配置 targets,将推送到此 ID",
@@ -345,30 +323,30 @@ func init() {
}) })
RegisterPushChannelDefinition(PushDefinition{ RegisterPushChannelDefinition(PushDefinition{
Type: ChannelBark, Type: consts.ChannelBark,
Name: "Bark (iOS 推送)", Name: "Bark (iOS 推送)",
Description: "配置 Bark 推送通知至 iPhone / iPad 客户端。", Description: "配置 Bark 推送通知至 iPhone / iPad 客户端。",
Fields: []PushField{ Fields: []PushField{
{ {
Key: KeyToken, Key: consts.KeyToken,
Label: "设备 Key (Device Key)", Label: "设备 Key (Device Key)",
Type: TypeText, Type: consts.TypeText,
Required: true, Required: true,
Placeholder: "Bark App 首页显示的 Device Key", Placeholder: "Bark App 首页显示的 Device Key",
Description: "从 Bark App 复制的设备专属 Key", Description: "从 Bark App 复制的设备专属 Key",
}, },
{ {
Key: KeyURL, Key: consts.KeyURL,
Label: "Bark 服务器地址 (可选)", Label: "Bark 服务器地址 (可选)",
Type: TypeText, Type: consts.TypeText,
Required: false, Required: false,
Placeholder: "https://api.day.app", Placeholder: "https://api.day.app",
Description: "Bark 服务器地址,留空默认使用官方公共服务器 https://api.day.app", Description: "Bark 服务器地址,留空默认使用官方公共服务器 https://api.day.app",
}, },
{ {
Key: KeyOther, Key: consts.KeyOther,
Label: "额外配置 JSON (可选)", Label: "额外配置 JSON (可选)",
Type: TypeTextarea, Type: consts.TypeTextarea,
Required: false, Required: false,
Placeholder: "{\"group\": \"Wavelet\", \"sound\": \"minuet\", \"icon\": \"https://...\"}", Placeholder: "{\"group\": \"Wavelet\", \"sound\": \"minuet\", \"icon\": \"https://...\"}",
Description: "可选的 JSON 配置,支持 group (分组)、sound (铃声)、icon (自定义图标)", Description: "可选的 JSON 配置,支持 group (分组)、sound (铃声)、icon (自定义图标)",
@@ -377,14 +355,14 @@ func init() {
}) })
RegisterPushChannelDefinition(PushDefinition{ RegisterPushChannelDefinition(PushDefinition{
Type: ChannelDiscord, Type: consts.ChannelDiscord,
Name: "Discord 频道", Name: "Discord 频道",
Description: "配置 Discord 频道的 Incoming Webhook 消息推送。", Description: "配置 Discord 频道的 Incoming Webhook 消息推送。",
Fields: []PushField{ Fields: []PushField{
{ {
Key: KeyURL, Key: consts.KeyURL,
Label: "Webhook 地址", Label: "Webhook 地址",
Type: TypeText, Type: consts.TypeText,
Required: true, Required: true,
Placeholder: "https://discord.com/api/webhooks/...", Placeholder: "https://discord.com/api/webhooks/...",
Description: "从 Discord 频道集成设置中复制的 Webhook URL", Description: "从 Discord 频道集成设置中复制的 Webhook URL",
@@ -393,14 +371,14 @@ func init() {
}) })
RegisterPushChannelDefinition(PushDefinition{ RegisterPushChannelDefinition(PushDefinition{
Type: ChannelSlack, Type: consts.ChannelSlack,
Name: "Slack 频道", Name: "Slack 频道",
Description: "配置 Slack 频道的 Incoming Webhook 消息推送。", Description: "配置 Slack 频道的 Incoming Webhook 消息推送。",
Fields: []PushField{ Fields: []PushField{
{ {
Key: KeyURL, Key: consts.KeyURL,
Label: "Webhook 地址", Label: "Webhook 地址",
Type: TypeText, Type: consts.TypeText,
Required: true, Required: true,
Placeholder: "https://hooks.slack.com/services/...", Placeholder: "https://hooks.slack.com/services/...",
Description: "从 Slack 应用配置中复制的 Incoming Webhook URL", Description: "从 Slack 应用配置中复制的 Incoming Webhook URL",
@@ -409,22 +387,22 @@ func init() {
}) })
RegisterPushChannelDefinition(PushDefinition{ RegisterPushChannelDefinition(PushDefinition{
Type: ChannelPushover, Type: consts.ChannelPushover,
Name: "Pushover 推送", Name: "Pushover 推送",
Description: "配置 Pushover 即时推送到手机/桌面客户端。", Description: "配置 Pushover 即时推送到手机/桌面客户端。",
Fields: []PushField{ Fields: []PushField{
{ {
Key: KeyToken, Key: consts.KeyToken,
Label: "应用 Token (App Token)", Label: "应用 Token (App Token)",
Type: TypePassword, Type: consts.TypePassword,
Required: true, Required: true,
Placeholder: "Pushover 创建应用生成的 API Token / Key", Placeholder: "Pushover 创建应用生成的 API Token / Key",
Description: "从 Pushover 控制台创建的 Application API Token", Description: "从 Pushover 控制台创建的 Application API Token",
}, },
{ {
Key: KeyURL, Key: consts.KeyURL,
Label: "用户 Key (User Key)", Label: "用户 Key (User Key)",
Type: TypeText, Type: consts.TypeText,
Required: true, Required: true,
Placeholder: "Pushover 账号主页的 User Key", Placeholder: "Pushover 账号主页的 User Key",
Description: "Pushover 个人账号的 User Key", Description: "Pushover 个人账号的 User Key",
@@ -433,7 +411,7 @@ func init() {
}) })
RegisterPushChannelDefinition(PushDefinition{ RegisterPushChannelDefinition(PushDefinition{
Type: ChannelEmail, Type: consts.ChannelEmail,
Name: "邮件推送通道", Name: "邮件推送通道",
Description: "邮件推送通道直接使用系统全局 SMTP 设置进行发送,无需在此填写服务器配置。", Description: "邮件推送通道直接使用系统全局 SMTP 设置进行发送,无需在此填写服务器配置。",
Fields: []PushField{}, Fields: []PushField{},
@@ -0,0 +1,56 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package entity defines GORM table mapping entities for msg_gateway.
package entity
import "time"
// MessageChannel is an admin-configured messaging adapter entity.
type MessageChannel struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
Type string `json:"type" gorm:"size:32;not null"`
Name string `json:"name" gorm:"size:64;not null"`
OwnerScope string `json:"owner_scope" gorm:"size:32;not null;default:'system'"`
OwnerID *uint64 `json:"owner_id,omitempty"`
Credentials string `json:"credentials" gorm:"type:text;not null"`
Extra string `json:"extra" gorm:"type:text"`
Enabled bool `json:"enabled" gorm:"default:false;not null"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
}
// TableName 表名
func (MessageChannel) TableName() string {
return "w_message_channels"
}
// MessageBinding maps a platform user to a Wavelet user on one channel.
type MessageBinding struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
ChannelID uint64 `json:"channel_id" gorm:"not null;index"`
PlatformUserID string `json:"platform_user_id" gorm:"size:128;not null;index"`
UserID uint64 `json:"user_id" gorm:"not null;index"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
}
// TableName 表名
func (MessageBinding) TableName() string {
return "w_message_bindings"
}
// MessagePairingCode is a one-time bind code.
type MessagePairingCode struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
Code string `json:"code" gorm:"size:32;uniqueIndex;not null"`
ChannelID uint64 `json:"channel_id" gorm:"not null;index"`
PlatformUserID string `json:"platform_user_id" gorm:"size:128;not null;index"`
UserID uint64 `json:"user_id" gorm:"not null;index"`
ExpiresAt time.Time `json:"expires_at" gorm:"not null;index"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
}
// TableName 表名
func (MessagePairingCode) TableName() string {
return "w_message_pairing_codes"
}
@@ -0,0 +1,95 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package entity
import (
"Wavelet/plugins/domain/msg_gateway/consts"
"errors"
"strings"
"time"
)
// PushChannel 消息通道实体
type PushChannel struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
Name string `json:"name" gorm:"size:100;not null"`
Description string `json:"description" gorm:"size:255"`
Type string `json:"type" gorm:"size:50;not null;index"`
URL string `json:"url" gorm:"type:text"`
Token string `json:"token" gorm:"type:text"`
Other string `json:"other" gorm:"type:text"`
Enabled bool `json:"enabled" gorm:"index;not null;default:true"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"`
}
// TableName 指定 GORM 表名
func (PushChannel) TableName() string {
return "w_push_channels"
}
// Validate 验证与标准化字段
func (c *PushChannel) Validate() error {
c.Name = strings.TrimSpace(c.Name)
if c.Name == "" {
return errors.New(consts.ErrChannelNameRequired)
}
c.Type = strings.TrimSpace(c.Type)
if c.Type == "" {
return errors.New(consts.ErrChannelTypeRequired)
}
return nil
}
// PushEvent 系统通知事件实体
type PushEvent struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
EventKey string `json:"event_key" gorm:"uniqueIndex;size:80;not null"`
Name string `json:"name" gorm:"size:100;not null"`
TaskType string `json:"task_type" gorm:"size:100;index;not null;default:''"`
Channels []string `json:"channels" gorm:"type:text;serializer:json;not null"`
Targets []string `json:"targets" gorm:"type:text;serializer:json;not null"`
Template string `json:"template" gorm:"type:text;not null"`
Enabled bool `json:"enabled" gorm:"index;not null;default:false"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"`
}
// TableName 指定 GORM 表名
func (PushEvent) TableName() string {
return "w_push_events"
}
// Validate 验证 PushEvent 实体字段
func (e *PushEvent) Validate() error {
e.EventKey = strings.TrimSpace(e.EventKey)
if e.EventKey == "" {
return errors.New(consts.ErrEventKeyRequired)
}
e.Name = strings.TrimSpace(e.Name)
if e.Name == "" {
return errors.New(consts.ErrNameRequired)
}
return nil
}
// PushHistory 推送日志/历史实体
type PushHistory struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
EventKey string `json:"event_key" gorm:"size:80;not null;index"`
Channel string `json:"channel" gorm:"size:50;not null;index"`
Target string `json:"target" gorm:"size:255;not null"`
Title string `json:"title" gorm:"size:255;not null"`
Content string `json:"content" gorm:"type:text;not null"`
Level string `json:"level" gorm:"size:20;not null;default:'INFO'"`
Status string `json:"status" gorm:"size:20;not null;index"`
ErrorMsg string `json:"error_msg" gorm:"type:text"`
Payload string `json:"payload" gorm:"type:text"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"`
}
// TableName 指定 GORM 表名
func (PushHistory) TableName() string {
return "w_push_histories"
}
@@ -1,11 +1,11 @@
// Copyright 2026 Arctel.net // Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package message_gateway_test package msg_gateway_test
import ( import (
"Wavelet/pkg/testhelper" "Wavelet/pkg/testhelper"
"Wavelet/plugins/domain/message_gateway" "Wavelet/plugins/domain/msg_gateway"
"context" "context"
"testing" "testing"
"time" "time"
@@ -31,17 +31,17 @@ func (m *mockDBService) Named(_ string) *gorm.DB {
func TestUpsertPairingCode_ReusesUnexpired(t *testing.T) { func TestUpsertPairingCode_ReusesUnexpired(t *testing.T) {
testDB, _, cleanup := testhelper.SetupTestEnvironment(t) testDB, _, cleanup := testhelper.SetupTestEnvironment(t)
message_gateway.SetDBServiceForTest(&mockDBService{db: testDB}) msg_gateway.SetDBServiceForTest(&mockDBService{db: testDB})
defer func() { defer func() {
message_gateway.SetDBServiceForTest(nil) msg_gateway.SetDBServiceForTest(nil)
cleanup() cleanup()
}() }()
ctx := context.Background() ctx := context.Background()
first, err := message_gateway.UpsertPairingCode(ctx, 1, "tg-1", "ABCD1234", time.Now().Add(15*time.Minute)) first, err := msg_gateway.UpsertPairingCode(ctx, 1, "tg-1", "ABCD1234", time.Now().Add(15*time.Minute))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
second, err := message_gateway.UpsertPairingCode(ctx, 1, "tg-1", "ZZZZ9999", time.Now().Add(15*time.Minute)) second, err := msg_gateway.UpsertPairingCode(ctx, 1, "tg-1", "ZZZZ9999", time.Now().Add(15*time.Minute))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -1,7 +1,7 @@
// Copyright 2026 Arctel.net // Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package message_gateway package msg_gateway
import ( import (
"strings" "strings"
@@ -1,8 +1,8 @@
// Copyright 2026 Arctel.net // Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
// Package message_gateway provides the Bot gateway, multi-channel notification dispatching, and asynchronous push worker domain plugin for Cordis. // Package msg_gateway provides the Bot gateway, multi-channel notification dispatching, and asynchronous push worker domain plugin for Cordis.
package message_gateway package msg_gateway
import ( import (
"Wavelet/core" "Wavelet/core"
@@ -10,12 +10,14 @@ import (
"Wavelet/core/extpoints" "Wavelet/core/extpoints"
"Wavelet/pkg/ginutil" "Wavelet/pkg/ginutil"
"Wavelet/pkg/util" "Wavelet/pkg/util"
"Wavelet/plugins/domain/message_gateway/channels/qq" "Wavelet/plugins/domain/msg_gateway/channels/qq"
"Wavelet/plugins/domain/message_gateway/channels/telegram" "Wavelet/plugins/domain/msg_gateway/channels/telegram"
"Wavelet/plugins/domain/message_gateway/handler" "Wavelet/plugins/domain/msg_gateway/consts"
"Wavelet/plugins/domain/message_gateway/model" "Wavelet/plugins/domain/msg_gateway/controller"
"Wavelet/plugins/domain/message_gateway/repository" "Wavelet/plugins/domain/msg_gateway/dao"
"Wavelet/plugins/domain/message_gateway/service" "Wavelet/plugins/domain/msg_gateway/model/do"
"Wavelet/plugins/domain/msg_gateway/model/entity"
"Wavelet/plugins/domain/msg_gateway/service"
"context" "context"
"embed" "embed"
"reflect" "reflect"
@@ -26,7 +28,7 @@ import (
//go:embed migrations/*/*.sql //go:embed migrations/*/*.sql
var mgMigrations embed.FS var mgMigrations embed.FS
// Option configures the message_gateway plugin. // Option configures the msg_gateway plugin.
type Option func(*Plugin) type Option func(*Plugin)
// WithAutoStartRunner enables automatic bot runner startup in the background. // WithAutoStartRunner enables automatic bot runner startup in the background.
@@ -42,7 +44,7 @@ type Plugin struct {
cancelRunner context.CancelFunc cancelRunner context.CancelFunc
} }
// New creates a new message_gateway domain plugin. // New creates a new msg_gateway domain plugin.
func New(opts ...Option) *Plugin { func New(opts ...Option) *Plugin {
p := &Plugin{} p := &Plugin{}
for _, opt := range opts { for _, opt := range opts {
@@ -53,12 +55,12 @@ func New(opts ...Option) *Plugin {
return p return p
} }
// Name returns the unique identifier for the message_gateway domain plugin. // Name returns the unique identifier for the msg_gateway domain plugin.
func (p *Plugin) Name() string { func (p *Plugin) Name() string {
return "message_gateway" return "msg_gateway"
} }
// Inject declares required dependencies for the message_gateway domain plugin. // Inject declares required dependencies for the msg_gateway domain plugin.
func (p *Plugin) Inject() []reflect.Type { func (p *Plugin) Inject() []reflect.Type {
return []reflect.Type{ return []reflect.Type{
reflect.TypeFor[contracts.DBService](), reflect.TypeFor[contracts.DBService](),
@@ -72,7 +74,7 @@ func (p *Plugin) Inject() []reflect.Type {
// Manifest returns the plugin metadata. // Manifest returns the plugin metadata.
func (p *Plugin) Manifest() core.Manifest { func (p *Plugin) Manifest() core.Manifest {
return core.Manifest{ return core.Manifest{
Name: "message_gateway", Name: "msg_gateway",
Version: "1.0.0", Version: "1.0.0",
Description: "Bot gateway, multi-channel notification push, and async worker dispatch plugin", Description: "Bot gateway, multi-channel notification push, and async worker dispatch plugin",
Author: "Wavelet Team", Author: "Wavelet Team",
@@ -83,29 +85,29 @@ type mgAppConfig struct {
SessionSecret string `config:"session_secret" env:"APP_SESSION_SECRET" secret:"true"` SessionSecret string `config:"session_secret" env:"APP_SESSION_SECRET" secret:"true"`
} }
// DeclareConfig declares configuration bindings for the message_gateway plugin. // DeclareConfig declares configuration bindings for the msg_gateway plugin.
func (p *Plugin) DeclareConfig() []core.ConfigBinding { func (p *Plugin) DeclareConfig() []core.ConfigBinding {
return []core.ConfigBinding{ return []core.ConfigBinding{
{Prefix: "app", Target: &mgAppConfig{}}, {Prefix: "app", Target: &mgAppConfig{}},
} }
} }
// Apply registers message_gateway migrations, routes, tasks, schedules, events, and settings into the Context. // Apply registers msg_gateway migrations, routes, tasks, schedules, events, and settings into the Context.
func (p *Plugin) Apply(ctx *core.Context) error { func (p *Plugin) Apply(ctx *core.Context) error {
var cfg mgAppConfig var cfg mgAppConfig
if err := ctx.Config().Bind("app", &cfg); err == nil && cfg.SessionSecret != "" { if err := ctx.Config().Bind("app", &cfg); err == nil && cfg.SessionSecret != "" {
service.SetCredentialSecret(cfg.SessionSecret) service.SetCredentialSecret(cfg.SessionSecret)
} }
core.Bind[contracts.DBService](ctx, repository.SetDBService) core.Bind[contracts.DBService](ctx, dao.SetDBService)
core.Bind[contracts.CacheService](ctx, func(cache contracts.CacheService) { core.Bind[contracts.CacheService](ctx, func(cache contracts.CacheService) {
repository.SetCacheService(cache) dao.SetCacheService(cache)
service.SetCacheService(cache) service.SetCacheService(cache)
}) })
core.Bind[contracts.TaskService](ctx, service.SetTaskService) core.Bind[contracts.TaskService](ctx, service.SetTaskService)
core.Bind[contracts.UserService](ctx, service.SetUserService) core.Bind[contracts.UserService](ctx, service.SetUserService)
ctx.OnDispose(func() error { ctx.OnDispose(func() error {
repository.SetDBService(nil) dao.SetDBService(nil)
repository.SetCacheService(nil) dao.SetCacheService(nil)
service.SetCacheService(nil) service.SetCacheService(nil)
service.SetTaskService(nil) service.SetTaskService(nil)
service.SetUserService(nil) service.SetUserService(nil)
@@ -126,25 +128,25 @@ func (p *Plugin) Apply(ctx *core.Context) error {
} }
// 1. Register migrations // 1. Register migrations
ctx.Migrations().Register("message_gateway", mgMigrations) ctx.Migrations().Register("msg_gateway", mgMigrations)
// 2. Register User HTTP Routes // 2. Register User HTTP Routes
handler.RegisterUserRoutes(ctx.Router().Group("/api/v1"), loginMW) controller.RegisterUserRoutes(ctx.Router().Group("/api/v1"), loginMW)
// 3. Register Admin Message Gateway HTTP Routes // 3. Register Admin Message Gateway HTTP Routes
handler.RegisterAdminRoutes(ctx.Router().Group("/api/v1/admin"), loginMW, adminMW) controller.RegisterAdminRoutes(ctx.Router().Group("/api/v1/admin"), loginMW, adminMW)
// 4. Register Admin Push HTTP Routes // 4. Register Admin Push HTTP Routes
handler.RegisterAdminPushRoutes(ctx.Router().Group("/api/v1/admin"), loginMW, adminMW) controller.RegisterAdminPushRoutes(ctx.Router().Group("/api/v1/admin"), loginMW, adminMW)
service.Register(model.MessageChannelTypeTelegram, telegram.New) service.Register(consts.MessageChannelTypeTelegram, telegram.New)
service.Register(model.MessageChannelTypeQQ, qq.New) service.Register(consts.MessageChannelTypeQQ, qq.New)
const defaultTaskRetry = 3 const defaultTaskRetry = 3
pushHandler := &service.PushHandler{} pushHandler := &service.PushHandler{}
// 5. Register background tasks // 5. Register background tasks
ctx.Task().Register("message_gateway:push_notification", func(c context.Context, payload []byte) error { ctx.Task().Register(consts.TaskPushNotification, func(c context.Context, payload []byte) error {
return pushHandler.Execute(c, payload) return pushHandler.Execute(c, payload)
}, },
extpoints.WithTaskType("push_notification"), extpoints.WithTaskType("push_notification"),
@@ -163,8 +165,8 @@ func (p *Plugin) Apply(ctx *core.Context) error {
ctx.Task().Register(service.TaskDispatchBotMsg, &service.BotDispatchHandler{}, ctx.Task().Register(service.TaskDispatchBotMsg, &service.BotDispatchHandler{},
extpoints.WithTaskMeta(service.BotDispatchMeta)) extpoints.WithTaskMeta(service.BotDispatchMeta))
ctx.Task().Register("message_gateway:cleanup_pairing_codes", func(c context.Context, _ []byte) error { ctx.Task().Register(consts.TaskCleanupPairingCodes, func(c context.Context, _ []byte) error {
return repository.DeleteExpiredPairingCodes(c) return dao.DeleteExpiredPairingCodes(c)
}, },
extpoints.WithTaskType("cleanup_pairing_codes"), extpoints.WithTaskType("cleanup_pairing_codes"),
extpoints.WithTaskName("清理过期配对码"), extpoints.WithTaskName("清理过期配对码"),
@@ -176,17 +178,17 @@ func (p *Plugin) Apply(ctx *core.Context) error {
) )
// 6. Register Cron Schedules // 6. Register Cron Schedules
ctx.Schedule().RegisterCron("*/10 * * * *", "message_gateway:cleanup_pairing_codes", map[string]any{"action": "cleanup"}) ctx.Schedule().RegisterCron("*/10 * * * *", consts.TaskCleanupPairingCodes, map[string]any{"action": "cleanup"})
// 7. Register EventBus listeners for decoupled push triggers // 7. Register EventBus listeners for decoupled push triggers
ctx.Events().On("notification:push", func(c context.Context, e model.PushNotificationEvent) error { ctx.Events().On("notification:push", func(c context.Context, e do.PushNotificationEvent) error {
meta := model.EventMetadata{ meta := do.EventMetadata{
Key: "eventbus:" + e.Channel, Key: "eventbus:" + e.Channel,
Name: e.Title, Name: e.Title,
DefaultTemplate: model.NotificationMessage{ DefaultTemplate: do.NotificationMessage{
Title: e.Title, Title: e.Title,
Content: e.Content, Content: e.Content,
Level: model.DefaultLevelInfo, Level: consts.DefaultLevelInfo,
Ext: e.Metadata, Ext: e.Metadata,
}, },
Description: "EventBus triggered notification", Description: "EventBus triggered notification",
@@ -211,14 +213,14 @@ func (p *Plugin) Apply(ctx *core.Context) error {
// 10. Register Settings Schemas // 10. Register Settings Schemas
ctx.Settings().Register(extpoints.SettingSchema{ ctx.Settings().Register(extpoints.SettingSchema{
Key: "message_gateway.pairing_code_expiry_minutes", Key: "msg_gateway.pairing_code_expiry_minutes",
Default: 15, Default: 15,
Description: "Expiry duration for bot pairing codes in minutes", Description: "Expiry duration for bot pairing codes in minutes",
Type: "integer", Type: "integer",
Category: "messaging", Category: "messaging",
}) })
ctx.Settings().Register(extpoints.SettingSchema{ ctx.Settings().Register(extpoints.SettingSchema{
Key: "message_gateway.max_bindings_per_user", Key: "msg_gateway.max_bindings_per_user",
Default: 5, Default: 5,
Description: "Maximum platform bot bindings per user", Description: "Maximum platform bot bindings per user",
Type: "integer", Type: "integer",
@@ -250,77 +252,77 @@ const (
CodeLength = service.CodeLength CodeLength = service.CodeLength
) )
// MessageChannel is an alias for model.MessageChannel. // MessageChannel is an alias for entity.MessageChannel.
type MessageChannel = model.MessageChannel type MessageChannel = entity.MessageChannel
// MessageBinding is an alias for model.MessageBinding. // MessageBinding is an alias for entity.MessageBinding.
type MessageBinding = model.MessageBinding type MessageBinding = entity.MessageBinding
// MessagePairingCode is an alias for model.MessagePairingCode. // MessagePairingCode is an alias for entity.MessagePairingCode.
type MessagePairingCode = model.MessagePairingCode type MessagePairingCode = entity.MessagePairingCode
// PushChannel is an alias for model.PushChannel. // PushChannel is an alias for entity.PushChannel.
type PushChannel = model.PushChannel type PushChannel = entity.PushChannel
// PushEvent is an alias for model.PushEvent. // PushEvent is an alias for entity.PushEvent.
type PushEvent = model.PushEvent type PushEvent = entity.PushEvent
// PushHistory is an alias for model.PushHistory. // PushHistory is an alias for entity.PushHistory.
type PushHistory = model.PushHistory type PushHistory = entity.PushHistory
// PushNotificationEvent is an alias for model.PushNotificationEvent. // PushNotificationEvent is an alias for do.PushNotificationEvent.
type PushNotificationEvent = model.PushNotificationEvent type PushNotificationEvent = do.PushNotificationEvent
// ChannelConfig is an alias for model.ChannelConfig. // ChannelConfig is an alias for do.ChannelConfig.
type ChannelConfig = model.ChannelConfig type ChannelConfig = do.ChannelConfig
// Capability is an alias for model.Capability. // Capability is an alias for do.Capability.
type Capability = model.Capability type Capability = do.Capability
// Recipient is an alias for model.Recipient. // Recipient is an alias for do.Recipient.
type Recipient = model.Recipient type Recipient = do.Recipient
// Attachment is an alias for model.Attachment. // Attachment is an alias for do.Attachment.
type Attachment = model.Attachment type Attachment = do.Attachment
// InboundMessage is an alias for model.InboundMessage. // InboundMessage is an alias for do.InboundMessage.
type InboundMessage = model.InboundMessage type InboundMessage = do.InboundMessage
// OutboundMessage is an alias for model.OutboundMessage. // OutboundMessage is an alias for do.OutboundMessage.
type OutboundMessage = model.OutboundMessage type OutboundMessage = do.OutboundMessage
// BindingDTO is an alias for model.BindingDTO. // BindingDTO is an alias for do.BindingDTO.
type BindingDTO = model.BindingDTO type BindingDTO = do.BindingDTO
// PublicChannelDTO is an alias for model.PublicChannelDTO. // PublicChannelDTO is an alias for do.PublicChannelDTO.
type PublicChannelDTO = model.PublicChannelDTO type PublicChannelDTO = do.PublicChannelDTO
// Definition is an alias for model.Definition. // Definition is an alias for do.Definition.
type Definition = model.Definition type Definition = do.Definition
// ChannelDTO is an alias for model.ChannelDTO. // ChannelDTO is an alias for do.ChannelDTO.
type ChannelDTO = model.ChannelDTO type ChannelDTO = do.ChannelDTO
// CreateChannelRequest is an alias for model.CreateChannelRequest. // CreateChannelRequest is an alias for do.CreateChannelRequest.
type CreateChannelRequest = model.CreateChannelRequest type CreateChannelRequest = do.CreateChannelRequest
// UpdateChannelRequest is an alias for model.UpdateChannelRequest. // UpdateChannelRequest is an alias for do.UpdateChannelRequest.
type UpdateChannelRequest = model.UpdateChannelRequest type UpdateChannelRequest = do.UpdateChannelRequest
// PushDefinition is an alias for model.PushDefinition. // PushDefinition is an alias for do.PushDefinition.
type PushDefinition = model.PushDefinition type PushDefinition = do.PushDefinition
// PushField is an alias for model.PushField. // PushField is an alias for do.PushField.
type PushField = model.PushField type PushField = do.PushField
// NotificationMessage is an alias for model.NotificationMessage. // NotificationMessage is an alias for do.NotificationMessage.
type NotificationMessage = model.NotificationMessage type NotificationMessage = do.NotificationMessage
// EventMetadata is an alias for model.EventMetadata. // EventMetadata is an alias for do.EventMetadata.
type EventMetadata = model.EventMetadata type EventMetadata = do.EventMetadata
// SendPayload is an alias for model.SendPayload. // SendPayload is an alias for do.SendPayload.
type SendPayload = model.SendPayload type SendPayload = do.SendPayload
// Handler is an alias for service.Handler. // Handler is an alias for service.Handler.
type Handler = service.Handler type Handler = service.Handler
@@ -342,8 +344,8 @@ type PushHandler = service.PushHandler
// Re-exported variables and functions. // Re-exported variables and functions.
var ( var (
SetDBServiceForTest = repository.SetDBServiceForTest SetDBServiceForTest = dao.SetDBServiceForTest
UpsertPairingCode = repository.UpsertPairingCode UpsertPairingCode = dao.UpsertPairingCode
Register = service.Register Register = service.Register
Lookup = service.Lookup Lookup = service.Lookup
GenerateCode = service.GenerateCode GenerateCode = service.GenerateCode
@@ -1,13 +1,13 @@
// Copyright 2026 Arctel.net // Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package message_gateway_test package msg_gateway_test
import ( import (
"Wavelet/core" "Wavelet/core"
"Wavelet/core/contracts" "Wavelet/core/contracts"
"Wavelet/plugins/domain/message_gateway" "Wavelet/plugins/domain/msg_gateway"
"Wavelet/plugins/domain/message_gateway/service" "Wavelet/plugins/domain/msg_gateway/service"
"context" "context"
"testing" "testing"
@@ -17,7 +17,7 @@ import (
func TestPushRegistry(t *testing.T) { func TestPushRegistry(t *testing.T) {
ctx := core.NewContext(context.Background()) ctx := core.NewContext(context.Background())
require.NoError(t, message_gateway.New().Apply(ctx)) require.NoError(t, msg_gateway.New().Apply(ctx))
registry, err := core.Inject[contracts.PushRegistry](ctx) registry, err := core.Inject[contracts.PushRegistry](ctx)
require.NoError(t, err) require.NoError(t, err)
@@ -1,11 +1,11 @@
// Copyright 2026 Arctel.net // Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package message_gateway_test package msg_gateway_test
import ( import (
"Wavelet/core" "Wavelet/core"
"Wavelet/plugins/domain/message_gateway" "Wavelet/plugins/domain/msg_gateway"
"context" "context"
"io/fs" "io/fs"
"testing" "testing"
@@ -14,32 +14,32 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestMessageGatewayPluginUnit(t *testing.T) { func TestMsgGatewayPluginUnit(t *testing.T) {
ctx := core.NewContext(context.Background()) ctx := core.NewContext(context.Background())
p := message_gateway.New() p := msg_gateway.New()
assert.Equal(t, "message_gateway", p.Name()) assert.Equal(t, "msg_gateway", p.Name())
assert.Equal(t, "1.0.0", p.Manifest().Version) assert.Equal(t, "1.0.0", p.Manifest().Version)
require.NoError(t, p.Apply(ctx)) require.NoError(t, p.Apply(ctx))
// Verify migrations // Verify migrations
entry, ok := ctx.Migrations().Get("message_gateway") entry, ok := ctx.Migrations().Get("msg_gateway")
require.True(t, ok) require.True(t, ok)
entries, err := fs.ReadDir(entry.FS, entry.Dir) entries, err := fs.ReadDir(entry.FS, entry.Dir)
require.NoError(t, err) require.NoError(t, err)
assert.NotEmpty(t, entries) assert.NotEmpty(t, entries)
// Verify tasks // Verify tasks
task, ok := ctx.Tasks().Get("message_gateway:push_notification") task, ok := ctx.Tasks().Get("msg_gateway:push_notification")
require.True(t, ok) require.True(t, ok)
assert.Equal(t, 3, task.Retry) assert.Equal(t, 3, task.Retry)
// Verify schedules // Verify schedules
sched, ok := ctx.Schedules().Get("message_gateway:cleanup_pairing_codes") sched, ok := ctx.Schedules().Get("msg_gateway:cleanup_pairing_codes")
require.True(t, ok) require.True(t, ok)
assert.Equal(t, "*/10 * * * *", sched.Spec) assert.Equal(t, "*/10 * * * *", sched.Spec)
// Verify settings // Verify settings
setting, ok := ctx.Settings().Get("message_gateway.max_bindings_per_user") setting, ok := ctx.Settings().Get("msg_gateway.max_bindings_per_user")
require.True(t, ok) require.True(t, ok)
assert.Equal(t, 5, setting.Default) assert.Equal(t, 5, setting.Default)
} }
@@ -48,7 +48,7 @@ func TestMessageGatewayPluginUnit(t *testing.T) {
// Register,则每次触发都投递到无人处理的任务类型,清理逻辑静默失效。 // Register,则每次触发都投递到无人处理的任务类型,清理逻辑静默失效。
func TestEveryScheduleHasTaskHandler(t *testing.T) { func TestEveryScheduleHasTaskHandler(t *testing.T) {
ctx := core.NewContext(context.Background()) ctx := core.NewContext(context.Background())
require.NoError(t, message_gateway.New().Apply(ctx)) require.NoError(t, msg_gateway.New().Apply(ctx))
schedules := ctx.Schedules().Schedules() schedules := ctx.Schedules().Schedules()
require.NotEmpty(t, schedules) require.NotEmpty(t, schedules)
@@ -1,7 +1,7 @@
// Copyright 2026 Arctel.net // Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
package message_gateway package msg_gateway
import ( import (
"context" "context"
@@ -4,9 +4,10 @@
package service package service
import ( import (
"Wavelet/plugins/domain/message_gateway/errs" "Wavelet/plugins/domain/msg_gateway/consts"
"Wavelet/plugins/domain/message_gateway/model" "Wavelet/plugins/domain/msg_gateway/dao"
"Wavelet/plugins/domain/message_gateway/repository" "Wavelet/plugins/domain/msg_gateway/model/do"
"Wavelet/plugins/domain/msg_gateway/model/entity"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -22,19 +23,19 @@ import (
const defaultTelegramAPI = "https://api.telegram.org" const defaultTelegramAPI = "https://api.telegram.org"
// ListDefinitions returns the admin form schema of every supported channel type. // ListDefinitions returns the admin form schema of every supported channel type.
func ListDefinitions() []model.Definition { func ListDefinitions() []do.Definition {
return []model.Definition{ return []do.Definition{
{ {
Type: model.MessageChannelTypeTelegram, Type: consts.MessageChannelTypeTelegram,
Fields: []model.Field{ Fields: []do.Field{
{Key: "token", Type: model.TypePassword, Required: true}, {Key: "token", Type: consts.TypePassword, Required: true},
{Key: "api_base", Type: model.TypeText, Required: false}, {Key: "api_base", Type: consts.TypeText, Required: false},
}, },
}, },
{ {
Type: model.MessageChannelTypeQQ, Type: consts.MessageChannelTypeQQ,
Fields: []model.Field{ Fields: []do.Field{
{Key: "app_id", Type: model.TypeText, Required: true}, {Key: "app_id", Type: consts.TypeText, Required: true},
{Key: "client_secret", Type: "password", Required: true}, {Key: "client_secret", Type: "password", Required: true},
}, },
}, },
@@ -42,25 +43,25 @@ func ListDefinitions() []model.Definition {
} }
// CreateChannel validates the admin payload and persists an encrypted channel. // CreateChannel validates the admin payload and persists an encrypted channel.
func CreateChannel(ctx context.Context, req model.CreateChannelRequest) (model.ChannelDTO, error) { func CreateChannel(ctx context.Context, req do.CreateChannelRequest) (do.ChannelDTO, error) {
name := strings.TrimSpace(req.Name) name := strings.TrimSpace(req.Name)
if name == "" { if name == "" {
return model.ChannelDTO{}, errors.New(errs.ErrNameRequired) return do.ChannelDTO{}, errors.New(consts.ErrNameRequired)
} }
channelType := strings.TrimSpace(req.Type) channelType := strings.TrimSpace(req.Type)
if channelType != model.MessageChannelTypeTelegram && channelType != model.MessageChannelTypeQQ { if channelType != consts.MessageChannelTypeTelegram && channelType != consts.MessageChannelTypeQQ {
return model.ChannelDTO{}, errors.New(errs.ErrTypeInvalid) return do.ChannelDTO{}, errors.New(consts.ErrTypeInvalid)
} }
creds := req.Credentials creds := req.Credentials
if creds == nil { if creds == nil {
creds = map[string]string{} creds = map[string]string{}
} }
if err := ValidateCredentials(channelType, creds, false); err != nil { if err := ValidateCredentials(channelType, creds, false); err != nil {
return model.ChannelDTO{}, err return do.ChannelDTO{}, err
} }
cipher, err := EncryptCredentials(creds) cipher, err := EncryptCredentials(creds)
if err != nil { if err != nil {
return model.ChannelDTO{}, err return do.ChannelDTO{}, err
} }
extra := req.Extra extra := req.Extra
if extra == nil { if extra == nil {
@@ -70,32 +71,32 @@ func CreateChannel(ctx context.Context, req model.CreateChannelRequest) (model.C
if req.Enabled != nil { if req.Enabled != nil {
enabled = *req.Enabled enabled = *req.Enabled
} }
row := &model.MessageChannel{ row := &entity.MessageChannel{
Name: name, Name: name,
Type: channelType, Type: channelType,
OwnerScope: model.MessageOwnerScopeSystem, OwnerScope: consts.MessageOwnerScopeSystem,
Enabled: enabled, Enabled: enabled,
Credentials: cipher, Credentials: cipher,
Extra: EncodeExtra(extra), Extra: EncodeExtra(extra),
} }
if err := repository.CreateMessageChannel(ctx, row); err != nil { if err := dao.CreateMessageChannel(ctx, row); err != nil {
return model.ChannelDTO{}, err return do.ChannelDTO{}, err
} }
return ToDTO(row, creds, extra), nil return ToDTO(row, creds, extra), nil
} }
// UpdateChannel patches a channel; empty secrets keep the stored ciphertext. // UpdateChannel patches a channel; empty secrets keep the stored ciphertext.
func UpdateChannel(ctx context.Context, id uint64, req model.UpdateChannelRequest) (model.ChannelDTO, error) { func UpdateChannel(ctx context.Context, id uint64, req do.UpdateChannelRequest) (do.ChannelDTO, error) {
row, err := repository.GetMessageChannel(ctx, id) row, err := dao.GetMessageChannel(ctx, id)
if err != nil { if err != nil {
if errors.Is(err, errs.ErrRecordNotFound) { if errors.Is(err, consts.ErrRecordNotFound) {
return model.ChannelDTO{}, errors.New(errs.ErrChannelNotFound) return do.ChannelDTO{}, errors.New(consts.ErrChannelNotFound)
} }
return model.ChannelDTO{}, err return do.ChannelDTO{}, err
} }
creds, err := DecryptCredentials(row.Credentials) creds, err := DecryptCredentials(row.Credentials)
if err != nil { if err != nil {
return model.ChannelDTO{}, err return do.ChannelDTO{}, err
} }
extra := ParseExtra(row.Extra) extra := ParseExtra(row.Extra)
@@ -120,30 +121,30 @@ func UpdateChannel(ctx context.Context, id uint64, req model.UpdateChannelReques
merged[k] = v merged[k] = v
} }
if err := ValidateCredentials(row.Type, merged, true); err != nil { if err := ValidateCredentials(row.Type, merged, true); err != nil {
return model.ChannelDTO{}, err return do.ChannelDTO{}, err
} }
creds = merged creds = merged
} }
cipher, err := EncryptCredentials(creds) cipher, err := EncryptCredentials(creds)
if err != nil { if err != nil {
return model.ChannelDTO{}, err return do.ChannelDTO{}, err
} }
row.Credentials = cipher row.Credentials = cipher
row.Extra = EncodeExtra(extra) row.Extra = EncodeExtra(extra)
if err := repository.UpdateMessageChannel(ctx, row); err != nil { if err := dao.UpdateMessageChannel(ctx, row); err != nil {
return model.ChannelDTO{}, err return do.ChannelDTO{}, err
} }
return ToDTO(row, creds, extra), nil return ToDTO(row, creds, extra), nil
} }
// ListChannels returns every channel with secrets masked. // ListChannels returns every channel with secrets masked.
func ListChannels(ctx context.Context) ([]model.ChannelDTO, error) { func ListChannels(ctx context.Context) ([]do.ChannelDTO, error) {
rows, err := repository.ListMessageChannels(ctx) rows, err := dao.ListMessageChannels(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
out := make([]model.ChannelDTO, 0, len(rows)) out := make([]do.ChannelDTO, 0, len(rows))
for i := range rows { for i := range rows {
creds, _ := DecryptCredentials(rows[i].Credentials) creds, _ := DecryptCredentials(rows[i].Credentials)
extra := ParseExtra(rows[i].Extra) extra := ParseExtra(rows[i].Extra)
@@ -154,21 +155,21 @@ func ListChannels(ctx context.Context) ([]model.ChannelDTO, error) {
// DeleteChannel removes a channel together with its bindings and pairing codes. // DeleteChannel removes a channel together with its bindings and pairing codes.
func DeleteChannel(ctx context.Context, id uint64) error { func DeleteChannel(ctx context.Context, id uint64) error {
if _, err := repository.GetMessageChannel(ctx, id); err != nil { if _, err := dao.GetMessageChannel(ctx, id); err != nil {
if errors.Is(err, errs.ErrRecordNotFound) { if errors.Is(err, consts.ErrRecordNotFound) {
return errors.New(errs.ErrChannelNotFound) return errors.New(consts.ErrChannelNotFound)
} }
return err return err
} }
return repository.DeleteMessageChannel(ctx, id) return dao.DeleteMessageChannel(ctx, id)
} }
// ProbeChannel verifies the stored credentials against the upstream platform. // ProbeChannel verifies the stored credentials against the upstream platform.
func ProbeChannel(ctx context.Context, id uint64) error { func ProbeChannel(ctx context.Context, id uint64) error {
row, err := repository.GetMessageChannel(ctx, id) row, err := dao.GetMessageChannel(ctx, id)
if err != nil { if err != nil {
if errors.Is(err, errs.ErrRecordNotFound) { if errors.Is(err, consts.ErrRecordNotFound) {
return errors.New(errs.ErrChannelNotFound) return errors.New(consts.ErrChannelNotFound)
} }
return err return err
} }
@@ -177,12 +178,12 @@ func ProbeChannel(ctx context.Context, id uint64) error {
return err return err
} }
switch row.Type { switch row.Type {
case model.MessageChannelTypeTelegram: case consts.MessageChannelTypeTelegram:
return ProbeTelegram(ctx, creds) return ProbeTelegram(ctx, creds)
case model.MessageChannelTypeQQ: case consts.MessageChannelTypeQQ:
return ProbeQQ(ctx, creds) return ProbeQQ(ctx, creds)
default: default:
return errors.New(errs.ErrTypeInvalid) return errors.New(consts.ErrTypeInvalid)
} }
} }
@@ -190,7 +191,7 @@ func ProbeChannel(ctx context.Context, id uint64) error {
func ProbeTelegram(ctx context.Context, creds map[string]string) error { func ProbeTelegram(ctx context.Context, creds map[string]string) error {
tok := creds["token"] tok := creds["token"]
if strings.TrimSpace(tok) == "" { if strings.TrimSpace(tok) == "" {
return errors.New(errs.ErrMissingTelegramToken) return errors.New(consts.ErrMissingTelegramToken)
} }
base := creds["api_base"] base := creds["api_base"]
base = strings.TrimRight(strings.TrimSpace(base), "/") base = strings.TrimRight(strings.TrimSpace(base), "/")
@@ -210,7 +211,7 @@ func ProbeTelegram(ctx context.Context, creds map[string]string) error {
defer func() { _ = resp.Body.Close() }() defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body) body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return fmt.Errorf("%s (%d): %s", errs.ErrTelegramGetMeFailed, resp.StatusCode, string(body)) return fmt.Errorf("%s (%d): %s", consts.ErrTelegramGetMeFailed, resp.StatusCode, string(body))
} }
var res struct { var res struct {
OK bool `json:"ok"` OK bool `json:"ok"`
@@ -219,7 +220,7 @@ func ProbeTelegram(ctx context.Context, creds map[string]string) error {
return err return err
} }
if !res.OK { if !res.OK {
return fmt.Errorf("%s: %s", errs.ErrTelegramNotOK, string(body)) return fmt.Errorf("%s: %s", consts.ErrTelegramNotOK, string(body))
} }
return nil return nil
} }
@@ -229,7 +230,7 @@ func ProbeQQ(_ context.Context, creds map[string]string) error {
appID := strings.TrimSpace(creds["app_id"]) appID := strings.TrimSpace(creds["app_id"])
secret := strings.TrimSpace(creds["client_secret"]) secret := strings.TrimSpace(creds["client_secret"])
if appID == "" || secret == "" { if appID == "" || secret == "" {
return errors.New(errs.ErrMissingQQCredentials) return errors.New(consts.ErrMissingQQCredentials)
} }
credentials := &token.QQBotCredentials{ credentials := &token.QQBotCredentials{
AppID: appID, AppID: appID,
@@ -238,10 +239,10 @@ func ProbeQQ(_ context.Context, creds map[string]string) error {
tokSrc := token.NewQQBotTokenSource(credentials) tokSrc := token.NewQQBotTokenSource(credentials)
tok, err := tokSrc.Token() tok, err := tokSrc.Token()
if err != nil { if err != nil {
return fmt.Errorf("%s: %w", errs.ErrQQTokenFetchFailed, err) return fmt.Errorf("%s: %w", consts.ErrQQTokenFetchFailed, err)
} }
if tok == nil || tok.AccessToken == "" { if tok == nil || tok.AccessToken == "" {
return errors.New(errs.ErrQQEmptyToken) return errors.New(consts.ErrQQEmptyToken)
} }
return nil return nil
} }
@@ -249,31 +250,31 @@ func ProbeQQ(_ context.Context, creds map[string]string) error {
// ValidateCredentials checks the admin submitted credentials for a channel type. // ValidateCredentials checks the admin submitted credentials for a channel type.
func ValidateCredentials(t string, creds map[string]string, isUpdate bool) error { func ValidateCredentials(t string, creds map[string]string, isUpdate bool) error {
switch t { switch t {
case model.MessageChannelTypeTelegram: case consts.MessageChannelTypeTelegram:
tok := creds["token"] tok := creds["token"]
if strings.TrimSpace(tok) == "" && !isUpdate { if strings.TrimSpace(tok) == "" && !isUpdate {
return errors.New(errs.ErrTelegramTokenRequired) return errors.New(consts.ErrTelegramTokenRequired)
} }
if base, ok := creds["api_base"]; ok && strings.TrimSpace(base) != "" { if base, ok := creds["api_base"]; ok && strings.TrimSpace(base) != "" {
if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") { if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
return errors.New(errs.ErrAPIBaseInvalid) return errors.New(consts.ErrAPIBaseInvalid)
} }
} }
case model.MessageChannelTypeQQ: case consts.MessageChannelTypeQQ:
appID := creds["app_id"] appID := creds["app_id"]
secret := creds["client_secret"] secret := creds["client_secret"]
if (strings.TrimSpace(appID) == "" || strings.TrimSpace(secret) == "") && !isUpdate { if (strings.TrimSpace(appID) == "" || strings.TrimSpace(secret) == "") && !isUpdate {
return errors.New(errs.ErrQQCredentialsRequired) return errors.New(consts.ErrQQCredentialsRequired)
} }
default: default:
return errors.New(errs.ErrTypeInvalid) return errors.New(consts.ErrTypeInvalid)
} }
return nil return nil
} }
// ToDTO projects a channel row onto the admin DTO with credentials masked. // ToDTO projects a channel row onto the admin DTO with credentials masked.
func ToDTO(row *model.MessageChannel, creds, extra map[string]string) model.ChannelDTO { func ToDTO(row *entity.MessageChannel, creds, extra map[string]string) do.ChannelDTO {
return model.ChannelDTO{ return do.ChannelDTO{
ID: row.ID, ID: row.ID,
Name: row.Name, Name: row.Name,
Type: row.Type, Type: row.Type,
@@ -6,9 +6,10 @@ package service
import ( import (
"Wavelet/core/contracts" "Wavelet/core/contracts"
"Wavelet/pkg/logger" "Wavelet/pkg/logger"
"Wavelet/plugins/domain/message_gateway/errs" "Wavelet/plugins/domain/msg_gateway/consts"
"Wavelet/plugins/domain/message_gateway/model" "Wavelet/plugins/domain/msg_gateway/dao"
"Wavelet/plugins/domain/message_gateway/repository" "Wavelet/plugins/domain/msg_gateway/model/do"
"Wavelet/plugins/domain/msg_gateway/model/entity"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -18,9 +19,9 @@ import (
const ( const (
// TaskDispatchBotMsg is the queue pattern for bot downlink dispatch. // TaskDispatchBotMsg is the queue pattern for bot downlink dispatch.
TaskDispatchBotMsg = "message_gateway:dispatch_bot_msg" TaskDispatchBotMsg = consts.TaskDispatchBotMsg
// TaskTypeDispatchBotMsg is the admin type identifier for bot downlink dispatch. // TaskTypeDispatchBotMsg is the admin type identifier for bot downlink dispatch.
TaskTypeDispatchBotMsg = "dispatch_bot_msg" TaskTypeDispatchBotMsg = consts.TaskTypeDispatchBotMsg
taskQueueDefault = "default" taskQueueDefault = "default"
taskParamTypeString = "string" taskParamTypeString = "string"
@@ -38,7 +39,7 @@ var BotDispatchMeta = contracts.TaskMetaDTO{
Queue: taskQueueDefault, Queue: taskQueueDefault,
Retryable: true, Retryable: true,
Params: []contracts.TaskParamDTO{ Params: []contracts.TaskParamDTO{
{Name: paramNameText, Label: "消息内容", Type: model.TypeText, Required: true, Placeholder: "要发送的文本", Description: "下发给绑定用户的文本"}, {Name: paramNameText, Label: "消息内容", Type: consts.TypeText, Required: true, Placeholder: "要发送的文本", Description: "下发给绑定用户的文本"},
{Name: "channel_id", Label: "频道 ID", Type: taskParamTypeString, Required: false, Placeholder: "留空表示全部启用频道", Description: "仅向指定频道的绑定发送"}, {Name: "channel_id", Label: "频道 ID", Type: taskParamTypeString, Required: false, Placeholder: "留空表示全部启用频道", Description: "仅向指定频道的绑定发送"},
{Name: "user_id", Label: "用户 ID", Type: taskParamTypeString, Required: false, Placeholder: "留空表示频道下全部绑定", Description: "仅向指定 Wavelet 用户的绑定发送"}, {Name: "user_id", Label: "用户 ID", Type: taskParamTypeString, Required: false, Placeholder: "留空表示频道下全部绑定", Description: "仅向指定 Wavelet 用户的绑定发送"},
}, },
@@ -69,7 +70,7 @@ func (h *BotDispatchHandler) Execute(ctx context.Context, payload []byte) (*cont
return nil, err return nil, err
} }
channels, err := repository.ListEnabledMessageChannels(ctx) channels, err := dao.ListEnabledMessageChannels(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -82,7 +83,7 @@ func (h *BotDispatchHandler) Execute(ctx context.Context, payload []byte) (*cont
} }
channels = filtered channels = filtered
if len(channels) == 0 { if len(channels) == 0 {
return nil, errors.New(errs.ErrChannelNotFound) return nil, errors.New(consts.ErrChannelNotFound)
} }
} }
@@ -107,20 +108,20 @@ func parseBotDispatchPayload(payload []byte) (botDispatchPayload, error) {
var p botDispatchPayload var p botDispatchPayload
if len(payload) > 0 { if len(payload) > 0 {
if err := json.Unmarshal(payload, &p); err != nil { if err := json.Unmarshal(payload, &p); err != nil {
return p, fmt.Errorf("%s: %w", errs.ErrInvalidJSONFormat, err) return p, fmt.Errorf("%s: %w", consts.ErrInvalidJSONFormat, err)
} }
} }
p.Text = strings.TrimSpace(p.Text) p.Text = strings.TrimSpace(p.Text)
if p.Text == "" { if p.Text == "" {
return p, errors.New(errs.ErrBotDispatchTextRequired) return p, errors.New(consts.ErrBotDispatchTextRequired)
} }
return p, nil return p, nil
} }
func dispatchOnChannel(ctx context.Context, row *model.MessageChannel, userID uint64, text string) (sent, failed int) { func dispatchOnChannel(ctx context.Context, row *entity.MessageChannel, userID uint64, text string) (sent, failed int) {
factory, ok := Lookup(row.Type) factory, ok := Lookup(row.Type)
if !ok { if !ok {
logger.ErrorF(ctx, "bot dispatch: %s type=%s", errs.ErrBotChannelNotRegistered, row.Type) logger.ErrorF(ctx, "bot dispatch: %s type=%s", consts.ErrBotChannelNotRegistered, row.Type)
return 0, 1 return 0, 1
} }
cfg, err := channelConfigFromRow(row) cfg, err := channelConfigFromRow(row)
@@ -139,7 +140,7 @@ func dispatchOnChannel(ctx context.Context, row *model.MessageChannel, userID ui
} }
defer func() { _ = ch.Disconnect(ctx) }() defer func() { _ = ch.Disconnect(ctx) }()
bindings, err := repository.ListBindingsByChannel(ctx, row.ID) bindings, err := dao.ListBindingsByChannel(ctx, row.ID)
if err != nil { if err != nil {
logger.ErrorF(ctx, "bot dispatch: list bindings %d: %v", row.ID, err) logger.ErrorF(ctx, "bot dispatch: list bindings %d: %v", row.ID, err)
return 0, 1 return 0, 1
@@ -148,11 +149,11 @@ func dispatchOnChannel(ctx context.Context, row *model.MessageChannel, userID ui
if userID != 0 && bindings[i].UserID != userID { if userID != 0 && bindings[i].UserID != userID {
continue continue
} }
to := model.Recipient{ to := do.Recipient{
ChatID: bindings[i].PlatformUserID, ChatID: bindings[i].PlatformUserID,
PlatformUserID: bindings[i].PlatformUserID, PlatformUserID: bindings[i].PlatformUserID,
} }
if err := ch.Send(ctx, to, model.OutboundMessage{Text: text}); err != nil { if err := ch.Send(ctx, to, do.OutboundMessage{Text: text}); err != nil {
logger.ErrorF(ctx, "bot dispatch: send channel=%d user=%d: %v", row.ID, bindings[i].UserID, err) logger.ErrorF(ctx, "bot dispatch: send channel=%d user=%d: %v", row.ID, bindings[i].UserID, err)
failed++ failed++
continue continue
@@ -162,10 +163,10 @@ func dispatchOnChannel(ctx context.Context, row *model.MessageChannel, userID ui
return sent, failed return sent, failed
} }
func channelConfigFromRow(row *model.MessageChannel) (model.ChannelConfig, error) { func channelConfigFromRow(row *entity.MessageChannel) (do.ChannelConfig, error) {
creds, err := DecryptCredentials(row.Credentials) creds, err := DecryptCredentials(row.Credentials)
if err != nil { if err != nil {
return model.ChannelConfig{}, err return do.ChannelConfig{}, err
} }
if creds == nil { if creds == nil {
creds = map[string]string{} creds = map[string]string{}
@@ -180,7 +181,7 @@ func channelConfigFromRow(row *model.MessageChannel) (model.ChannelConfig, error
if extra["base_url"] == "" && creds["api_base"] != "" { if extra["base_url"] == "" && creds["api_base"] != "" {
extra["base_url"] = creds["api_base"] extra["base_url"] = creds["api_base"]
} }
return model.ChannelConfig{ return do.ChannelConfig{
ID: row.ID, ID: row.ID,
Type: row.Type, Type: row.Type,
Name: row.Name, Name: row.Name,
@@ -4,7 +4,9 @@
package service_test package service_test
import ( import (
"Wavelet/plugins/domain/message_gateway/service" "Wavelet/plugins/domain/msg_gateway/dao"
"Wavelet/plugins/domain/msg_gateway/model/entity"
"Wavelet/plugins/domain/msg_gateway/service"
"context" "context"
"path/filepath" "path/filepath"
"testing" "testing"
@@ -13,9 +15,6 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"gorm.io/gorm" "gorm.io/gorm"
"Wavelet/plugins/domain/message_gateway/model"
"Wavelet/plugins/domain/message_gateway/repository"
) )
type dispatchTestDB struct{ db *gorm.DB } type dispatchTestDB struct{ db *gorm.DB }
@@ -35,9 +34,9 @@ func TestBotDispatchValidatePayload(t *testing.T) {
func TestBotDispatchNoChannels(t *testing.T) { func TestBotDispatchNoChannels(t *testing.T) {
testDB, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "dispatch.db")), &gorm.Config{}) testDB, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "dispatch.db")), &gorm.Config{})
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, testDB.AutoMigrate(&model.MessageChannel{}, &model.MessageBinding{})) require.NoError(t, testDB.AutoMigrate(&entity.MessageChannel{}, &entity.MessageBinding{}))
repository.SetDBServiceForTest(&dispatchTestDB{db: testDB}) dao.SetDBServiceForTest(&dispatchTestDB{db: testDB})
t.Cleanup(func() { repository.SetDBServiceForTest(nil) }) t.Cleanup(func() { dao.SetDBServiceForTest(nil) })
h := &service.BotDispatchHandler{} h := &service.BotDispatchHandler{}
res, err := h.Execute(context.Background(), []byte(`{"text":"hello"}`)) res, err := h.Execute(context.Background(), []byte(`{"text":"hello"}`))
@@ -7,10 +7,11 @@ import (
"Wavelet/core/contracts" "Wavelet/core/contracts"
"Wavelet/pkg/logger" "Wavelet/pkg/logger"
"Wavelet/pkg/util" "Wavelet/pkg/util"
"Wavelet/plugins/domain/message_gateway/errs" "Wavelet/plugins/domain/msg_gateway/consts"
"Wavelet/plugins/domain/message_gateway/model" "Wavelet/plugins/domain/msg_gateway/dao"
pkgpush "Wavelet/plugins/domain/message_gateway/push" "Wavelet/plugins/domain/msg_gateway/model/do"
"Wavelet/plugins/domain/message_gateway/repository" "Wavelet/plugins/domain/msg_gateway/model/entity"
pkgpush "Wavelet/plugins/domain/msg_gateway/push"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -24,11 +25,11 @@ import (
var ( var (
builtInEventsMu sync.RWMutex builtInEventsMu sync.RWMutex
// BuiltInEvents lists all built-in events defined in custom_events. // BuiltInEvents lists all built-in events defined in custom_events.
BuiltInEvents []model.EventMetadata BuiltInEvents []do.EventMetadata
) )
// RegisterBuiltInEvent registers a built-in event definition. // RegisterBuiltInEvent registers a built-in event definition.
func RegisterBuiltInEvent(meta model.EventMetadata) { func RegisterBuiltInEvent(meta do.EventMetadata) {
builtInEventsMu.Lock() builtInEventsMu.Lock()
defer builtInEventsMu.Unlock() defer builtInEventsMu.Unlock()
for i, e := range BuiltInEvents { for i, e := range BuiltInEvents {
@@ -41,10 +42,10 @@ func RegisterBuiltInEvent(meta model.EventMetadata) {
} }
// GetBuiltInEvents returns a copy of registered built-in events. // GetBuiltInEvents returns a copy of registered built-in events.
func GetBuiltInEvents() []model.EventMetadata { func GetBuiltInEvents() []do.EventMetadata {
builtInEventsMu.RLock() builtInEventsMu.RLock()
defer builtInEventsMu.RUnlock() defer builtInEventsMu.RUnlock()
out := make([]model.EventMetadata, len(BuiltInEvents)) out := make([]do.EventMetadata, len(BuiltInEvents))
copy(out, BuiltInEvents) copy(out, BuiltInEvents)
return out return out
} }
@@ -62,12 +63,12 @@ func (PushRegistryAdapter) SyncEvents(ctx context.Context) error {
return SyncEvents(ctx) return SyncEvents(ctx)
} }
func eventMetadataFromContract(meta contracts.PushEventMeta) model.EventMetadata { func eventMetadataFromContract(meta contracts.PushEventMeta) do.EventMetadata {
return model.EventMetadata{ return do.EventMetadata{
Key: meta.Key, Key: meta.Key,
Name: meta.Name, Name: meta.Name,
Description: meta.Description, Description: meta.Description,
DefaultTemplate: model.NotificationMessage{ DefaultTemplate: do.NotificationMessage{
Title: meta.DefaultTemplate.Title, Title: meta.DefaultTemplate.Title,
Content: meta.DefaultTemplate.Content, Content: meta.DefaultTemplate.Content,
Level: meta.DefaultTemplate.Level, Level: meta.DefaultTemplate.Level,
@@ -79,13 +80,13 @@ func eventMetadataFromContract(meta contracts.PushEventMeta) model.EventMetadata
// SyncBuiltInEvents seeds a database row for every registered built-in event. // SyncBuiltInEvents seeds a database row for every registered built-in event.
func SyncBuiltInEvents(ctx context.Context) error { func SyncBuiltInEvents(ctx context.Context) error {
for _, meta := range GetBuiltInEvents() { for _, meta := range GetBuiltInEvents() {
_, err := repository.GetPushEventByKeyRecord(ctx, meta.Key) _, err := dao.GetPushEventByKeyRecord(ctx, meta.Key)
if errors.Is(err, errs.ErrRecordNotFound) { if errors.Is(err, consts.ErrRecordNotFound) {
var defaultTemplateStr string var defaultTemplateStr string
if defaultTemplateBytes, err := json.Marshal(meta.DefaultTemplate); err == nil { if defaultTemplateBytes, err := json.Marshal(meta.DefaultTemplate); err == nil {
defaultTemplateStr = string(defaultTemplateBytes) defaultTemplateStr = string(defaultTemplateBytes)
} }
event := model.PushEvent{ event := entity.PushEvent{
EventKey: meta.Key, EventKey: meta.Key,
Name: meta.Name, Name: meta.Name,
Channels: []string{}, Channels: []string{},
@@ -93,7 +94,7 @@ func SyncBuiltInEvents(ctx context.Context) error {
Template: defaultTemplateStr, Template: defaultTemplateStr,
Enabled: false, Enabled: false,
} }
if err := repository.CreatePushEventRecord(ctx, &event); err != nil { if err := dao.CreatePushEventRecord(ctx, &event); err != nil {
return err return err
} }
} else if err != nil { } else if err != nil {
@@ -104,23 +105,23 @@ func SyncBuiltInEvents(ctx context.Context) error {
} }
// ListPushEvents lists all configured push events. // ListPushEvents lists all configured push events.
func ListPushEvents(ctx context.Context) ([]model.PushEvent, error) { func ListPushEvents(ctx context.Context) ([]entity.PushEvent, error) {
return repository.ListPushEventsRecord(ctx) return dao.ListPushEventsRecord(ctx)
} }
// CreatePushEvent stores a push event configuration for a built-in event or task type. // CreatePushEvent stores a push event configuration for a built-in event or task type.
func CreatePushEvent(ctx context.Context, req model.CreatePushEventRequest) (model.PushEvent, error) { func CreatePushEvent(ctx context.Context, req do.CreatePushEventRequest) (entity.PushEvent, error) {
eventKey, eventName, defaultTemplateBytes, err := GetEventInfo(ctx, req) eventKey, eventName, defaultTemplateBytes, err := GetEventInfo(ctx, req)
if err != nil { if err != nil {
return model.PushEvent{}, err return entity.PushEvent{}, err
} }
count, err := repository.CountPushEventsByKeyRecord(ctx, eventKey) count, err := dao.CountPushEventsByKeyRecord(ctx, eventKey)
if err != nil { if err != nil {
return model.PushEvent{}, err return entity.PushEvent{}, err
} }
if count > 0 { if count > 0 {
return model.PushEvent{}, errors.New(errs.ErrEventAlreadyConfigured) return entity.PushEvent{}, errors.New(consts.ErrEventAlreadyConfigured)
} }
templateStr := strings.TrimSpace(req.Template) templateStr := strings.TrimSpace(req.Template)
@@ -129,7 +130,7 @@ func CreatePushEvent(ctx context.Context, req model.CreatePushEventRequest) (mod
} else { } else {
var tempMap map[string]any var tempMap map[string]any
if err := json.Unmarshal([]byte(templateStr), &tempMap); err != nil { if err := json.Unmarshal([]byte(templateStr), &tempMap); err != nil {
return model.PushEvent{}, errors.New(errs.ErrTemplateInvalidJSON) return entity.PushEvent{}, errors.New(consts.ErrTemplateInvalidJSON)
} }
} }
@@ -142,7 +143,7 @@ func CreatePushEvent(ctx context.Context, req model.CreatePushEventRequest) (mod
targets = []string{} targets = []string{}
} }
event := model.PushEvent{ event := entity.PushEvent{
EventKey: eventKey, EventKey: eventKey,
Name: eventName, Name: eventName,
TaskType: req.TaskType, TaskType: req.TaskType,
@@ -152,26 +153,26 @@ func CreatePushEvent(ctx context.Context, req model.CreatePushEventRequest) (mod
Enabled: req.Enabled, Enabled: req.Enabled,
} }
if err := event.Validate(); err != nil { if err := event.Validate(); err != nil {
return model.PushEvent{}, err return entity.PushEvent{}, err
} }
if err := repository.CreatePushEventRecord(ctx, &event); err != nil { if err := dao.CreatePushEventRecord(ctx, &event); err != nil {
return model.PushEvent{}, err return entity.PushEvent{}, err
} }
return event, nil return event, nil
} }
// DeletePushEvent deletes a push event configuration by id. // DeletePushEvent deletes a push event configuration by id.
func DeletePushEvent(ctx context.Context, id uint64) error { func DeletePushEvent(ctx context.Context, id uint64) error {
event, err := repository.GetPushEventByIDRecord(ctx, id) event, err := dao.GetPushEventByIDRecord(ctx, id)
if err != nil { if err != nil {
return err return err
} }
return repository.DeletePushEventRecord(ctx, &event) return dao.DeletePushEventRecord(ctx, &event)
} }
// UpdatePushEvent replaces mutable push event fields. // UpdatePushEvent replaces mutable push event fields.
func UpdatePushEvent(ctx context.Context, id uint64, req model.UpdatePushEventRequest) error { func UpdatePushEvent(ctx context.Context, id uint64, req do.UpdatePushEventRequest) error {
event, err := repository.GetPushEventByIDRecord(ctx, id) event, err := dao.GetPushEventByIDRecord(ctx, id)
if err != nil { if err != nil {
return err return err
} }
@@ -183,37 +184,37 @@ func UpdatePushEvent(ctx context.Context, id uint64, req model.UpdatePushEventRe
if err := event.Validate(); err != nil { if err := event.Validate(); err != nil {
return err return err
} }
return repository.SavePushEventRecord(ctx, &event) return dao.SavePushEventRecord(ctx, &event)
} }
// TogglePushEvent flips the enabled flag of a push event. // TogglePushEvent flips the enabled flag of a push event.
func TogglePushEvent(ctx context.Context, id uint64) (bool, error) { func TogglePushEvent(ctx context.Context, id uint64) (bool, error) {
event, err := repository.GetPushEventByIDRecord(ctx, id) event, err := dao.GetPushEventByIDRecord(ctx, id)
if err != nil { if err != nil {
return false, err return false, err
} }
enabled := !event.Enabled enabled := !event.Enabled
if enabled && len(event.Channels) == 0 { if enabled && len(event.Channels) == 0 {
return false, errors.New(errs.ErrEnableWithoutChannels) return false, errors.New(consts.ErrEnableWithoutChannels)
} }
if err := repository.UpdatePushEventEnabledRecord(ctx, &event, enabled); err != nil { if err := dao.UpdatePushEventEnabledRecord(ctx, &event, enabled); err != nil {
return false, err return false, err
} }
return enabled, nil return enabled, nil
} }
// ListPushHistories returns a paginated push delivery audit page. // ListPushHistories returns a paginated push delivery audit page.
func ListPushHistories(ctx context.Context, filter model.PushHistoryListFilter) (int64, []model.PushHistory, error) { func ListPushHistories(ctx context.Context, filter do.PushHistoryListFilter) (int64, []entity.PushHistory, error) {
return repository.ListPushHistoriesRecord(ctx, filter) return dao.ListPushHistoriesRecord(ctx, filter)
} }
// ApplySMTPFallbackToPushConfig fills an email config from the system SMTP settings. // ApplySMTPFallbackToPushConfig fills an email config from the system SMTP settings.
func ApplySMTPFallbackToPushConfig(ctx context.Context, cfg *pkgpush.Config) { func ApplySMTPFallbackToPushConfig(ctx context.Context, cfg *pkgpush.Config) {
if cfg.Channel != model.ChannelEmail || (cfg.URL != "" && cfg.Key != "") { if cfg.Channel != consts.ChannelEmail || (cfg.URL != "" && cfg.Key != "") {
return return
} }
smtp, err := repository.LoadSMTPConfigRecord(ctx) smtp, err := dao.LoadSMTPConfigRecord(ctx)
if err != nil { if err != nil {
logger.ErrorF(ctx, "[Push] 读取 SMTP 系统配置失败: %v", err) logger.ErrorF(ctx, "[Push] 读取 SMTP 系统配置失败: %v", err)
return return
@@ -237,15 +238,15 @@ func RunPushTest(ctx context.Context, cfg pkgpush.Config, target string) error {
return err return err
} }
if err := pusher.ValidateConfig(cfg); err != nil { if err := pusher.ValidateConfig(cfg); err != nil {
return fmt.Errorf("%s: %w", errs.ErrValidationFailed, err) return fmt.Errorf("%s: %w", consts.ErrValidationFailed, err)
} }
ApplySMTPFallbackToPushConfig(ctx, &cfg) ApplySMTPFallbackToPushConfig(ctx, &cfg)
testBody := map[string]any{ testBody := map[string]any{
model.KeyTitle: "测试通道推送", consts.KeyTitle: "测试通道推送",
model.KeyContent: "当您收到这条消息,说明当前渠道连通性测试通过。", consts.KeyContent: "当您收到这条消息,说明当前渠道连通性测试通过。",
model.KeyLevel: model.DefaultLevelInfo, consts.KeyLevel: consts.DefaultLevelInfo,
} }
if _, err := pusher.Send(ctx, cfg, target, testBody, "", nil); err != nil { if _, err := pusher.Send(ctx, cfg, target, testBody, "", nil); err != nil {
return err return err
@@ -254,21 +255,21 @@ func RunPushTest(ctx context.Context, cfg pkgpush.Config, target string) error {
} }
// ListPushChannels returns every configured push channel. // ListPushChannels returns every configured push channel.
func ListPushChannels(ctx context.Context) ([]model.PushChannel, error) { func ListPushChannels(ctx context.Context) ([]entity.PushChannel, error) {
return repository.ListPushChannelsRecord(ctx) return dao.ListPushChannelsRecord(ctx)
} }
// CreatePushChannel validates uniqueness and persists a new push channel. // CreatePushChannel validates uniqueness and persists a new push channel.
func CreatePushChannel(ctx context.Context, req model.CreatePushChannelRequest) (model.PushChannel, error) { func CreatePushChannel(ctx context.Context, req do.CreatePushChannelRequest) (entity.PushChannel, error) {
count, err := repository.CountPushChannelsByNameRecord(ctx, req.Name) count, err := dao.CountPushChannelsByNameRecord(ctx, req.Name)
if err != nil { if err != nil {
return model.PushChannel{}, err return entity.PushChannel{}, err
} }
if count > 0 { if count > 0 {
return model.PushChannel{}, errors.New(errs.ErrChannelNameExists) return entity.PushChannel{}, errors.New(consts.ErrChannelNameExists)
} }
channel := model.PushChannel{ channel := entity.PushChannel{
Name: req.Name, Name: req.Name,
Description: req.Description, Description: req.Description,
Type: req.Type, Type: req.Type,
@@ -278,19 +279,19 @@ func CreatePushChannel(ctx context.Context, req model.CreatePushChannelRequest)
Enabled: req.Enabled, Enabled: req.Enabled,
} }
if err := channel.Validate(); err != nil { if err := channel.Validate(); err != nil {
return model.PushChannel{}, err return entity.PushChannel{}, err
} }
if err := repository.CreatePushChannelRecord(ctx, &channel); err != nil { if err := dao.CreatePushChannelRecord(ctx, &channel); err != nil {
return model.PushChannel{}, err return entity.PushChannel{}, err
} }
return channel, nil return channel, nil
} }
// UpdatePushChannel replaces the mutable fields of an existing push channel. // UpdatePushChannel replaces the mutable fields of an existing push channel.
func UpdatePushChannel(ctx context.Context, id uint64, req model.UpdatePushChannelRequest) (model.PushChannel, error) { func UpdatePushChannel(ctx context.Context, id uint64, req do.UpdatePushChannelRequest) (entity.PushChannel, error) {
channel, err := repository.GetPushChannelByIDRecord(ctx, id) channel, err := dao.GetPushChannelByIDRecord(ctx, id)
if err != nil { if err != nil {
return model.PushChannel{}, err return entity.PushChannel{}, err
} }
channel.Description = req.Description channel.Description = req.Description
@@ -300,30 +301,30 @@ func UpdatePushChannel(ctx context.Context, id uint64, req model.UpdatePushChann
channel.Other = req.Other channel.Other = req.Other
channel.Enabled = req.Enabled channel.Enabled = req.Enabled
if err := channel.Validate(); err != nil { if err := channel.Validate(); err != nil {
return model.PushChannel{}, err return entity.PushChannel{}, err
} }
if err := repository.SavePushChannelRecord(ctx, &channel); err != nil { if err := dao.SavePushChannelRecord(ctx, &channel); err != nil {
return model.PushChannel{}, err return entity.PushChannel{}, err
} }
return channel, nil return channel, nil
} }
// DeletePushChannel removes a push channel by id. // DeletePushChannel removes a push channel by id.
func DeletePushChannel(ctx context.Context, id uint64) error { func DeletePushChannel(ctx context.Context, id uint64) error {
channel, err := repository.GetPushChannelByIDRecord(ctx, id) channel, err := dao.GetPushChannelByIDRecord(ctx, id)
if err != nil { if err != nil {
return err return err
} }
return repository.DeletePushChannelRecord(ctx, &channel) return dao.DeletePushChannelRecord(ctx, &channel)
} }
// LoadChannelForTest resolves the credentials under test, either from a stored // LoadChannelForTest resolves the credentials under test, either from a stored
// channel name or from the ad-hoc values sent by the caller. // channel name or from the ad-hoc values sent by the caller.
func LoadChannelForTest(ctx context.Context, req model.TestPushChannelRequest) (string, string, string, string, error) { func LoadChannelForTest(ctx context.Context, req do.TestPushChannelRequest) (string, string, string, string, error) {
if req.Name != "" { if req.Name != "" {
channel, err := repository.GetPushChannelByNameRecord(ctx, req.Name) channel, err := dao.GetPushChannelByNameRecord(ctx, req.Name)
if err != nil { if err != nil {
return "", "", "", "", errors.New(errs.ErrChannelNotFound) return "", "", "", "", errors.New(consts.ErrChannelNotFound)
} }
return channel.URL, channel.Token, channel.Other, channel.Type, nil return channel.URL, channel.Token, channel.Other, channel.Type, nil
} }
@@ -331,17 +332,17 @@ func LoadChannelForTest(ctx context.Context, req model.TestPushChannelRequest) (
} }
// PreparePushChannelTest builds the connectivity probe payload for a channel. // PreparePushChannelTest builds the connectivity probe payload for a channel.
func PreparePushChannelTest(ctx context.Context, req model.TestPushChannelRequest) (model.SendPayload, error) { func PreparePushChannelTest(ctx context.Context, req do.TestPushChannelRequest) (do.SendPayload, error) {
url, token, other, channelType, err := LoadChannelForTest(ctx, req) url, token, other, channelType, err := LoadChannelForTest(ctx, req)
if err != nil { if err != nil {
return model.SendPayload{}, err return do.SendPayload{}, err
} }
if channelType == model.ChannelEmail { if channelType == consts.ChannelEmail {
url, token, other = ResolveSMTPConfig(ctx, url, token, other) url, token, other = ResolveSMTPConfig(ctx, url, token, other)
} }
tempChannel := model.PushChannel{ tempChannel := entity.PushChannel{
Name: "test_temp", Name: "test_temp",
URL: url, URL: url,
Token: token, Token: token,
@@ -350,23 +351,23 @@ func PreparePushChannelTest(ctx context.Context, req model.TestPushChannelReques
Enabled: true, Enabled: true,
} }
if err := tempChannel.Validate(); err != nil { if err := tempChannel.Validate(); err != nil {
return model.SendPayload{}, err return do.SendPayload{}, err
} }
url = tempChannel.URL url = tempChannel.URL
var config pkgpush.Config var config pkgpush.Config
var renderedJSON string var renderedJSON string
switch channelType { switch channelType {
case model.ChannelLark: case consts.ChannelLark:
config = pkgpush.Config{Channel: model.ChannelLark, URL: url, Secret: token} config = pkgpush.Config{Channel: consts.ChannelLark, URL: url, Secret: token}
renderedJSON = other renderedJSON = other
case model.ChannelEmail: case consts.ChannelEmail:
config = pkgpush.Config{Channel: model.ChannelEmail, URL: url, Key: token, Secret: other} config = pkgpush.Config{Channel: consts.ChannelEmail, URL: url, Key: token, Secret: other}
case model.ChannelTelegram: case consts.ChannelTelegram:
config = pkgpush.Config{Channel: model.ChannelTelegram, URL: url, Secret: token, Key: other} config = pkgpush.Config{Channel: consts.ChannelTelegram, URL: url, Secret: token, Key: other}
default: default:
config = pkgpush.Config{Channel: model.ChannelCustom, URL: url} config = pkgpush.Config{Channel: consts.ChannelCustom, URL: url}
customPushReq := model.CustomPushRequest{ customPushReq := do.CustomPushRequest{
Title: "通道测试通知", Title: "通道测试通知",
Content: "这是一条来自系统的消息通道连通性测试消息。", Content: "这是一条来自系统的消息通道连通性测试消息。",
Description: "系统通道测试", Description: "系统通道测试",
@@ -376,14 +377,14 @@ func PreparePushChannelTest(ctx context.Context, req model.TestPushChannelReques
renderedJSON = RenderCustomPayload(other, customPushReq) renderedJSON = RenderCustomPayload(other, customPushReq)
} }
return model.SendPayload{ return do.SendPayload{
EventKey: "test_channel", EventKey: "test_channel",
Config: config, Config: config,
Target: req.Target, Target: req.Target,
Body: model.NotificationMessage{ Body: do.NotificationMessage{
Title: "通道测试通知", Title: "通道测试通知",
Content: "这是一条来自系统的消息通道连通性测试消息。", Content: "这是一条来自系统的消息通道连通性测试消息。",
Level: model.DefaultLevelInfo, Level: consts.DefaultLevelInfo,
}, },
Template: renderedJSON, Template: renderedJSON,
}, nil }, nil
@@ -391,7 +392,7 @@ func PreparePushChannelTest(ctx context.Context, req model.TestPushChannelReques
// RenderCustomPayload substitutes the supported template variables of a custom // RenderCustomPayload substitutes the supported template variables of a custom
// webhook body, JSON-escaping every injected value. // webhook body, JSON-escaping every injected value.
func RenderCustomPayload(template string, req model.CustomPushRequest) string { func RenderCustomPayload(template string, req do.CustomPushRequest) string {
result := template result := template
result = strings.ReplaceAll(result, "$title", EscapeJSONString(req.Title)) result = strings.ReplaceAll(result, "$title", EscapeJSONString(req.Title))
result = strings.ReplaceAll(result, "$description", EscapeJSONString(req.Description)) result = strings.ReplaceAll(result, "$description", EscapeJSONString(req.Description))
@@ -412,20 +413,20 @@ func EscapeJSONString(s string) string {
} }
// ListActivePushEventsByTaskType returns enabled push events for a given task type. // ListActivePushEventsByTaskType returns enabled push events for a given task type.
func ListActivePushEventsByTaskType(ctx context.Context, taskType string) ([]model.PushEvent, error) { func ListActivePushEventsByTaskType(ctx context.Context, taskType string) ([]entity.PushEvent, error) {
return repository.ListActivePushEventsByTaskTypeRecord(ctx, taskType) return dao.ListActivePushEventsByTaskTypeRecord(ctx, taskType)
} }
// QueryUser resolves a user through the UserService contract, falling back to the // QueryUser resolves a user through the UserService contract, falling back to the
// repository read path while the contract is not wired yet. // DAO read path while the contract is not wired yet.
func QueryUser(ctx context.Context, fromService func(contracts.UserService) (*contracts.UserDTO, error), dbField string, dbVal any) (*contracts.UserDTO, error) { func QueryUser(ctx context.Context, fromService func(contracts.UserService) (*contracts.UserDTO, error), dbField string, dbVal any) (*contracts.UserDTO, error) {
if userSvc := GetUserService(ctx); userSvc != nil { if userSvc := GetUserService(ctx); userSvc != nil {
return fromService(userSvc) return fromService(userSvc)
} }
if user, err := repository.FindUserByFieldRecord(ctx, dbField, dbVal); err == nil && user != nil { if user, err := dao.FindUserByFieldRecord(ctx, dbField, dbVal); err == nil && user != nil {
return user, nil return user, nil
} }
return nil, errors.New(errs.ErrUserNotFound) return nil, errors.New(consts.ErrUserNotFound)
} }
// FindUserByID resolves a user by primary key. // FindUserByID resolves a user by primary key.
@@ -463,7 +464,7 @@ func LoadUserFromPayload(ctx context.Context, data map[string]any) any {
} }
// RecordPushHistory creates a push history audit record. // RecordPushHistory creates a push history audit record.
func RecordPushHistory(ctx context.Context, req model.SendPayload, status, errMsg string) error { func RecordPushHistory(ctx context.Context, req do.SendPayload, status, errMsg string) error {
title := req.Body.Title title := req.Body.Title
content := req.Body.Content content := req.Body.Content
level := req.Body.Level level := req.Body.Level
@@ -471,7 +472,7 @@ func RecordPushHistory(ctx context.Context, req model.SendPayload, status, errMs
title = "系统通知" title = "系统通知"
} }
if level == "" { if level == "" {
level = model.DefaultLevelInfo level = consts.DefaultLevelInfo
} }
target := req.Target target := req.Target
@@ -488,7 +489,7 @@ func RecordPushHistory(ctx context.Context, req model.SendPayload, status, errMs
} }
} }
history := model.PushHistory{ history := entity.PushHistory{
EventKey: req.EventKey, EventKey: req.EventKey,
Channel: req.Config.Channel, Channel: req.Config.Channel,
Target: target, Target: target,
@@ -498,7 +499,7 @@ func RecordPushHistory(ctx context.Context, req model.SendPayload, status, errMs
Status: status, Status: status,
ErrorMsg: errMsg, ErrorMsg: errMsg,
} }
return repository.CreatePushHistoryRecord(ctx, &history) return dao.CreatePushHistoryRecord(ctx, &history)
} }
// ResolveTarget parses dynamic placeholders into concrete receiver targets. // ResolveTarget parses dynamic placeholders into concrete receiver targets.
@@ -520,10 +521,10 @@ func ResolveTarget(ctx context.Context, target string, flatBody map[string]any,
if !found { if !found {
return resolved return resolved
} }
if channel == model.ChannelEmail && user.Email != "" { if channel == consts.ChannelEmail && user.Email != "" {
return user.Email return user.Email
} }
if channel != model.ChannelEmail && user.Username != "" { if channel != consts.ChannelEmail && user.Username != "" {
return user.Username return user.Username
} }
return resolved return resolved
@@ -546,7 +547,7 @@ func ResolveDynamicKeyword(target string, flatBody map[string]any) string {
if val, ok := flatBody["username"]; ok { if val, ok := flatBody["username"]; ok {
return fmt.Sprintf("%v", val) return fmt.Sprintf("%v", val)
} }
case "user.email", model.ChannelEmail: case "user.email", consts.ChannelEmail:
if val, ok := flatBody["user.email"]; ok { if val, ok := flatBody["user.email"]; ok {
return fmt.Sprintf("%v", val) return fmt.Sprintf("%v", val)
} }
@@ -571,15 +572,15 @@ func ResolveTargetUser(ctx context.Context, resolved, _ string) (contracts.UserD
} }
// GetFirstAdminUser resolves the first administrator through the UserService // GetFirstAdminUser resolves the first administrator through the UserService
// contract, falling back to the repository read path when it is unavailable. // contract, falling back to the DAO read path when it is unavailable.
func GetFirstAdminUser(ctx context.Context) (*contracts.UserDTO, error) { func GetFirstAdminUser(ctx context.Context) (*contracts.UserDTO, error) {
if userSvc := GetUserService(ctx); userSvc != nil { if userSvc := GetUserService(ctx); userSvc != nil {
return userSvc.GetFirstAdminUser(ctx) return userSvc.GetFirstAdminUser(ctx)
} }
if adminUser, err := repository.FindFirstAdminUserRecord(ctx); err == nil && adminUser != nil { if adminUser, err := dao.FindFirstAdminUserRecord(ctx); err == nil && adminUser != nil {
return adminUser, nil return adminUser, nil
} }
return nil, errors.New(errs.ErrNoAdminUser) return nil, errors.New(consts.ErrNoAdminUser)
} }
// ResolveSystemTarget maps system receiver aliases to administrator contact info. // ResolveSystemTarget maps system receiver aliases to administrator contact info.
@@ -591,10 +592,10 @@ func ResolveSystemTarget(ctx context.Context, resolved, channel string) (string,
if err != nil || adminUser == nil { if err != nil || adminUser == nil {
return resolved, true return resolved, true
} }
if channel == model.ChannelEmail && adminUser.Email != "" { if channel == consts.ChannelEmail && adminUser.Email != "" {
return adminUser.Email, true return adminUser.Email, true
} }
if channel != model.ChannelEmail && adminUser.Username != "" { if channel != consts.ChannelEmail && adminUser.Username != "" {
return adminUser.Username, true return adminUser.Username, true
} }
return resolved, true return resolved, true
@@ -605,7 +606,7 @@ func ResolveSMTPConfig(ctx context.Context, url, token, other string) (string, s
if url != "" && token != "" { if url != "" && token != "" {
return url, token, other return url, token, other
} }
smtp, err := repository.LoadSMTPConfigRecord(ctx) smtp, err := dao.LoadSMTPConfigRecord(ctx)
if err != nil { if err != nil {
logger.ErrorF(ctx, "[Push] 读取 SMTP 系统配置失败: %v", err) logger.ErrorF(ctx, "[Push] 读取 SMTP 系统配置失败: %v", err)
return url, token, other return url, token, other
@@ -641,18 +642,18 @@ func GetSystemUser(ctx context.Context) *contracts.UserDTO {
} }
// FindBuiltInEvent finds a registered built-in event by key. // FindBuiltInEvent finds a registered built-in event by key.
func FindBuiltInEvent(key string) (model.EventMetadata, bool) { func FindBuiltInEvent(key string) (do.EventMetadata, bool) {
for _, meta := range GetBuiltInEvents() { for _, meta := range GetBuiltInEvents() {
if meta.Key == key { if meta.Key == key {
return meta, true return meta, true
} }
} }
return model.EventMetadata{}, false return do.EventMetadata{}, false
} }
// GetEventInfo derives the event key, display name and default template for a // GetEventInfo derives the event key, display name and default template for a
// task-completion based event or a registered built-in event key. // task-completion based event or a registered built-in event key.
func GetEventInfo(ctx context.Context, req model.CreatePushEventRequest) (string, string, []byte, error) { func GetEventInfo(ctx context.Context, req do.CreatePushEventRequest) (string, string, []byte, error) {
if req.TaskType != "" { if req.TaskType != "" {
taskName := req.TaskType taskName := req.TaskType
if taskSvc := GetTaskService(ctx); taskSvc != nil { if taskSvc := GetTaskService(ctx); taskSvc != nil {
@@ -662,10 +663,10 @@ func GetEventInfo(ctx context.Context, req model.CreatePushEventRequest) (string
} }
eventKey := "task_completed:" + req.TaskType eventKey := "task_completed:" + req.TaskType
eventName := "任务完成: " + taskName eventName := "任务完成: " + taskName
defaultTemplate := model.NotificationMessage{ defaultTemplate := do.NotificationMessage{
Title: "任务完成: " + taskName, Title: "任务完成: " + taskName,
Content: "异步任务 {{task_name}} (ID: {{task_id}}) 已完成。状态: {{task_status}},耗时: {{task_duration}} ms。", Content: "异步任务 {{task_name}} (ID: {{task_id}}) 已完成。状态: {{task_status}},耗时: {{task_duration}} ms。",
Level: model.DefaultLevelInfo, Level: consts.DefaultLevelInfo,
} }
defaultTemplateBytes, err := json.Marshal(defaultTemplate) defaultTemplateBytes, err := json.Marshal(defaultTemplate)
if err != nil { if err != nil {
@@ -675,12 +676,12 @@ func GetEventInfo(ctx context.Context, req model.CreatePushEventRequest) (string
} }
if req.EventKey == "" { if req.EventKey == "" {
return "", "", nil, errors.New(errs.ErrEventKeyOrTaskType) return "", "", nil, errors.New(consts.ErrEventKeyOrTaskType)
} }
meta, found := FindBuiltInEvent(req.EventKey) meta, found := FindBuiltInEvent(req.EventKey)
if !found { if !found {
return "", "", nil, errors.New(errs.ErrUnsupportedEventKey) return "", "", nil, errors.New(consts.ErrUnsupportedEventKey)
} }
defaultTemplateBytes, err := json.Marshal(meta.DefaultTemplate) defaultTemplateBytes, err := json.Marshal(meta.DefaultTemplate)
@@ -691,7 +692,7 @@ func GetEventInfo(ctx context.Context, req model.CreatePushEventRequest) (string
} }
// EnqueuePushTask dispatches a notification payload to the async push worker. // EnqueuePushTask dispatches a notification payload to the async push worker.
func EnqueuePushTask(ctx context.Context, payload model.SendPayload) error { func EnqueuePushTask(ctx context.Context, payload do.SendPayload) error {
payloadBytes, err := json.Marshal(payload) payloadBytes, err := json.Marshal(payload)
if err != nil { if err != nil {
return err return err
@@ -700,7 +701,7 @@ func EnqueuePushTask(ctx context.Context, payload model.SendPayload) error {
_, err = taskSvc.Dispatch(ctx, "send_notification", payloadBytes, contracts.TaskTriggerSystem) _, err = taskSvc.Dispatch(ctx, "send_notification", payloadBytes, contracts.TaskTriggerSystem)
return err return err
} }
return errors.New(errs.ErrTaskServiceUnavailable) return errors.New(consts.ErrTaskServiceUnavailable)
} }
// GetFlatBody flattens nested body map. // GetFlatBody flattens nested body map.
@@ -736,9 +737,9 @@ func FlattenMap(prefix string, m, result map[string]any) {
const ( const (
// SendNotificationTask is the asynq task name for push notification. // SendNotificationTask is the asynq task name for push notification.
SendNotificationTask = "push:send" SendNotificationTask = consts.SendNotificationTask
// TaskTypeSendNotification is the admin task manager type identifier. // TaskTypeSendNotification is the admin task manager type identifier.
TaskTypeSendNotification = "send_notification" TaskTypeSendNotification = consts.TaskTypeSendNotification
) )
// SendNotificationMeta represents the task metadata. // SendNotificationMeta represents the task metadata.
@@ -778,16 +779,16 @@ type PushHandler struct{}
// ValidatePayload validates and normalizes push parameters. // ValidatePayload validates and normalizes push parameters.
func (h *PushHandler) ValidatePayload(payload []byte) ([]byte, error) { func (h *PushHandler) ValidatePayload(payload []byte) ([]byte, error) {
if len(payload) == 0 { if len(payload) == 0 {
return nil, errors.New(errs.ErrPayloadRequired) return nil, errors.New(consts.ErrPayloadRequired)
} }
var req model.SendPayload var req do.SendPayload
if err := json.Unmarshal(payload, &req); err != nil { if err := json.Unmarshal(payload, &req); err != nil {
return nil, fmt.Errorf("%s: %w", errs.ErrInvalidJSONFormat, err) return nil, fmt.Errorf("%s: %w", consts.ErrInvalidJSONFormat, err)
} }
if req.Config.Channel == "" { if req.Config.Channel == "" {
return nil, errors.New(errs.ErrChannelTypeRequired) return nil, errors.New(consts.ErrChannelTypeRequired)
} }
return json.Marshal(req) return json.Marshal(req)
@@ -795,17 +796,17 @@ func (h *PushHandler) ValidatePayload(payload []byte) ([]byte, error) {
// Execute performs the push send and logs delivery history audit. // Execute performs the push send and logs delivery history audit.
func (h *PushHandler) Execute(ctx context.Context, payload []byte) error { func (h *PushHandler) Execute(ctx context.Context, payload []byte) error {
var req model.SendPayload var req do.SendPayload
if err := json.Unmarshal(payload, &req); err != nil { if err := json.Unmarshal(payload, &req); err != nil {
logger.ErrorF(ctx, "[Push] 解析推送参数失败: %v", err) logger.ErrorF(ctx, "[Push] 解析推送参数失败: %v", err)
return fmt.Errorf("%s: %w", errs.ErrParsePayloadFailed, err) return fmt.Errorf("%s: %w", consts.ErrParsePayloadFailed, err)
} }
logger.InfoF(ctx, "[Push] 开始推送通知: 事件 = %s, 渠道 = %s, 接收目标 = %s", req.EventKey, req.Config.Channel, req.Target) logger.InfoF(ctx, "[Push] 开始推送通知: 事件 = %s, 渠道 = %s, 接收目标 = %s", req.EventKey, req.Config.Channel, req.Target)
pusher, err := pkgpush.GetPusher(req.Config.Channel) pusher, err := pkgpush.GetPusher(req.Config.Channel)
if err != nil { if err != nil {
errWrap := fmt.Errorf("%s: %w", errs.ErrGetPusherFailed, err) errWrap := fmt.Errorf("%s: %w", consts.ErrGetPusherFailed, err)
logger.ErrorF(ctx, "[Push] 推送失败: %v", errWrap) logger.ErrorF(ctx, "[Push] 推送失败: %v", errWrap)
h.recordHistory(ctx, req, "failed", errWrap.Error()) h.recordHistory(ctx, req, "failed", errWrap.Error())
return errWrap return errWrap
@@ -829,7 +830,7 @@ func (h *PushHandler) Execute(ctx context.Context, payload []byte) error {
return nil return nil
} }
func (h *PushHandler) recordHistory(ctx context.Context, req model.SendPayload, status, errMsg string) { func (h *PushHandler) recordHistory(ctx context.Context, req do.SendPayload, status, errMsg string) {
if dbErr := RecordPushHistory(ctx, req, status, errMsg); dbErr != nil { if dbErr := RecordPushHistory(ctx, req, status, errMsg); dbErr != nil {
logger.ErrorF(ctx, "[Push] 写入推送历史审计记录失败: %v", dbErr) logger.ErrorF(ctx, "[Push] 写入推送历史审计记录失败: %v", dbErr)
} }
@@ -873,7 +874,7 @@ func HandleTaskCompleted(ctx context.Context, e contracts.TaskCompletedEvent) {
} }
for _, event := range events { for _, event := range events {
meta := model.EventMetadata{ meta := do.EventMetadata{
Key: event.EventKey, Key: event.EventKey,
Name: event.Name, Name: event.Name,
Description: "异步任务执行完毕触发的自动通知", Description: "异步任务执行完毕触发的自动通知",
@@ -942,7 +943,7 @@ type EventTrigger struct{}
var DefaultTrigger = &EventTrigger{} var DefaultTrigger = &EventTrigger{}
// Trigger receives event metadata and processes the event notification dispatch asynchronously. // Trigger receives event metadata and processes the event notification dispatch asynchronously.
func (t *EventTrigger) Trigger(ctx context.Context, meta model.EventMetadata, body map[string]any) { func (t *EventTrigger) Trigger(ctx context.Context, meta do.EventMetadata, body map[string]any) {
asyncCtx := context.WithoutCancel(ctx) asyncCtx := context.WithoutCancel(ctx)
util.Go(func() { util.Go(func() {
if body == nil { if body == nil {
@@ -952,9 +953,9 @@ func (t *EventTrigger) Trigger(ctx context.Context, meta model.EventMetadata, bo
body["user"] = GetSystemUser(asyncCtx) body["user"] = GetSystemUser(asyncCtx)
} }
eventPtr, err := repository.GetActivePushEventByKey(asyncCtx, meta.Key) eventPtr, err := dao.GetActivePushEventByKey(asyncCtx, meta.Key)
if err != nil { if err != nil {
if errors.Is(err, errs.ErrRecordNotFound) { if errors.Is(err, consts.ErrRecordNotFound) {
return return
} }
logger.ErrorF(asyncCtx, "push_event_trigger: failed to get active event %s: %v", meta.Key, err) logger.ErrorF(asyncCtx, "push_event_trigger: failed to get active event %s: %v", meta.Key, err)
@@ -971,8 +972,8 @@ func (t *EventTrigger) Trigger(ctx context.Context, meta model.EventMetadata, bo
}) })
} }
func (t *EventTrigger) buildMessage(event *model.PushEvent, meta model.EventMetadata, flatBody, body map[string]any) (model.NotificationMessage, string) { func (t *EventTrigger) buildMessage(event *entity.PushEvent, meta do.EventMetadata, flatBody, body map[string]any) (do.NotificationMessage, string) {
var msg model.NotificationMessage var msg do.NotificationMessage
renderedTemplate := "" renderedTemplate := ""
templateSource := event.Template templateSource := event.Template
@@ -982,7 +983,7 @@ func (t *EventTrigger) buildMessage(event *model.PushEvent, meta model.EventMeta
if err != nil { if err != nil {
msg.Title = event.Name msg.Title = event.Name
msg.Content = renderedTemplate msg.Content = renderedTemplate
msg.Level = model.DefaultLevelInfo msg.Level = consts.DefaultLevelInfo
} }
} else { } else {
msg = t.parseDefaultTemplate(meta, flatBody) msg = t.parseDefaultTemplate(meta, flatBody)
@@ -992,7 +993,7 @@ func (t *EventTrigger) buildMessage(event *model.PushEvent, meta model.EventMeta
msg.Ext = make(map[string]any) msg.Ext = make(map[string]any)
} }
for k, v := range body { for k, v := range body {
if k == model.KeyTitle || k == model.KeyContent || k == model.KeyLevel { if k == consts.KeyTitle || k == consts.KeyContent || k == consts.KeyLevel {
continue continue
} }
if _, exists := msg.Ext[k]; !exists { if _, exists := msg.Ext[k]; !exists {
@@ -1003,8 +1004,8 @@ func (t *EventTrigger) buildMessage(event *model.PushEvent, meta model.EventMeta
return msg, renderedTemplate return msg, renderedTemplate
} }
func (t *EventTrigger) parseCustomTemplate(event *model.PushEvent, templateSource string, flatBody map[string]any) (model.NotificationMessage, string, error) { func (t *EventTrigger) parseCustomTemplate(event *entity.PushEvent, templateSource string, flatBody map[string]any) (do.NotificationMessage, string, error) {
var msg model.NotificationMessage var msg do.NotificationMessage
renderedTemplate := pkgpush.ParseTemplate(templateSource, flatBody) renderedTemplate := pkgpush.ParseTemplate(templateSource, flatBody)
var tMap map[string]any var tMap map[string]any
@@ -1012,33 +1013,33 @@ func (t *EventTrigger) parseCustomTemplate(event *model.PushEvent, templateSourc
return msg, renderedTemplate, err return msg, renderedTemplate, err
} }
if title, ok := tMap[model.KeyTitle].(string); ok && title != "" { if title, ok := tMap[consts.KeyTitle].(string); ok && title != "" {
msg.Title = title msg.Title = title
} else { } else {
msg.Title = event.Name msg.Title = event.Name
} }
delete(tMap, model.KeyTitle) delete(tMap, consts.KeyTitle)
if content, ok := tMap[model.KeyContent].(string); ok && content != "" { if content, ok := tMap[consts.KeyContent].(string); ok && content != "" {
msg.Content = content msg.Content = content
} else { } else {
msg.Content = renderedTemplate msg.Content = renderedTemplate
} }
delete(tMap, model.KeyContent) delete(tMap, consts.KeyContent)
if level, ok := tMap[model.KeyLevel].(string); ok && level != "" { if level, ok := tMap[consts.KeyLevel].(string); ok && level != "" {
msg.Level = level msg.Level = level
} else { } else {
msg.Level = model.DefaultLevelInfo msg.Level = consts.DefaultLevelInfo
} }
delete(tMap, model.KeyLevel) delete(tMap, consts.KeyLevel)
msg.Ext = tMap msg.Ext = tMap
return msg, renderedTemplate, nil return msg, renderedTemplate, nil
} }
func (t *EventTrigger) parseDefaultTemplate(meta model.EventMetadata, flatBody map[string]any) model.NotificationMessage { func (t *EventTrigger) parseDefaultTemplate(meta do.EventMetadata, flatBody map[string]any) do.NotificationMessage {
var msg model.NotificationMessage var msg do.NotificationMessage
msg.Title = pkgpush.ParseTemplate(meta.DefaultTemplate.Title, flatBody) msg.Title = pkgpush.ParseTemplate(meta.DefaultTemplate.Title, flatBody)
msg.Content = pkgpush.ParseTemplate(meta.DefaultTemplate.Content, flatBody) msg.Content = pkgpush.ParseTemplate(meta.DefaultTemplate.Content, flatBody)
msg.Level = pkgpush.ParseTemplate(meta.DefaultTemplate.Level, flatBody) msg.Level = pkgpush.ParseTemplate(meta.DefaultTemplate.Level, flatBody)
@@ -1056,9 +1057,9 @@ func (t *EventTrigger) parseDefaultTemplate(meta model.EventMetadata, flatBody m
return msg return msg
} }
func (t *EventTrigger) enqueuePushTasks(ctx context.Context, meta model.EventMetadata, event *model.PushEvent, msg model.NotificationMessage, flatBody map[string]any) { func (t *EventTrigger) enqueuePushTasks(ctx context.Context, meta do.EventMetadata, event *entity.PushEvent, msg do.NotificationMessage, flatBody map[string]any) {
for _, channelName := range event.Channels { for _, channelName := range event.Channels {
customChannel, err := repository.GetActivePushChannelByName(ctx, channelName) customChannel, err := dao.GetActivePushChannelByName(ctx, channelName)
if err == nil { if err == nil {
t.enqueueCustomPushChannelTasks(ctx, meta, event, customChannel, msg, flatBody) t.enqueueCustomPushChannelTasks(ctx, meta, event, customChannel, msg, flatBody)
continue continue
@@ -1067,7 +1068,7 @@ func (t *EventTrigger) enqueuePushTasks(ctx context.Context, meta model.EventMet
} }
} }
func (t *EventTrigger) enqueueCustomPushChannelTasks(ctx context.Context, meta model.EventMetadata, event *model.PushEvent, channel *model.PushChannel, msg model.NotificationMessage, flatBody map[string]any) { func (t *EventTrigger) enqueueCustomPushChannelTasks(ctx context.Context, meta do.EventMetadata, event *entity.PushEvent, channel *entity.PushChannel, msg do.NotificationMessage, flatBody map[string]any) {
if len(event.Targets) == 0 { if len(event.Targets) == 0 {
t.enqueueSingleCustomPushChannelTask(ctx, meta, channel, "", msg) t.enqueueSingleCustomPushChannelTask(ctx, meta, channel, "", msg)
return return
@@ -1079,22 +1080,22 @@ func (t *EventTrigger) enqueueCustomPushChannelTasks(ctx context.Context, meta m
} }
} }
func (t *EventTrigger) enqueueSingleCustomPushChannelTask(ctx context.Context, meta model.EventMetadata, channel *model.PushChannel, target string, msg model.NotificationMessage) { func (t *EventTrigger) enqueueSingleCustomPushChannelTask(ctx context.Context, meta do.EventMetadata, channel *entity.PushChannel, target string, msg do.NotificationMessage) {
var config pkgpush.Config var config pkgpush.Config
var renderedTemplate string var renderedTemplate string
switch channel.Type { switch channel.Type {
case model.ChannelLark: case consts.ChannelLark:
config = pkgpush.Config{Channel: model.ChannelLark, URL: channel.URL, Secret: channel.Token} config = pkgpush.Config{Channel: consts.ChannelLark, URL: channel.URL, Secret: channel.Token}
renderedTemplate = channel.Other renderedTemplate = channel.Other
case model.ChannelEmail: case consts.ChannelEmail:
url, token, other := ResolveSMTPConfig(ctx, channel.URL, channel.Token, channel.Other) url, token, other := ResolveSMTPConfig(ctx, channel.URL, channel.Token, channel.Other)
config = pkgpush.Config{Channel: model.ChannelEmail, URL: url, Key: token, Secret: other} config = pkgpush.Config{Channel: consts.ChannelEmail, URL: url, Key: token, Secret: other}
case model.ChannelTelegram: case consts.ChannelTelegram:
config = pkgpush.Config{Channel: model.ChannelTelegram, URL: channel.URL, Secret: channel.Token, Key: channel.Other} config = pkgpush.Config{Channel: consts.ChannelTelegram, URL: channel.URL, Secret: channel.Token, Key: channel.Other}
default: default:
config = pkgpush.Config{Channel: model.ChannelCustom, URL: channel.URL} config = pkgpush.Config{Channel: consts.ChannelCustom, URL: channel.URL}
customPushReq := model.CustomPushRequest{ customPushReq := do.CustomPushRequest{
Title: msg.Title, Title: msg.Title,
Content: msg.Content, Content: msg.Content,
Description: meta.Description, Description: meta.Description,
@@ -1106,7 +1107,7 @@ func (t *EventTrigger) enqueueSingleCustomPushChannelTask(ctx context.Context, m
renderedTemplate = RenderCustomPayload(channel.Other, customPushReq) renderedTemplate = RenderCustomPayload(channel.Other, customPushReq)
} }
payload := model.SendPayload{ payload := do.SendPayload{
EventKey: meta.Key, EventKey: meta.Key,
Config: config, Config: config,
Target: target, Target: target,
@@ -1124,10 +1125,10 @@ func SyncEvents(ctx context.Context) error {
} }
// AdminLogin is the metadata definition for the admin login event. // AdminLogin is the metadata definition for the admin login event.
var AdminLogin = model.EventMetadata{ var AdminLogin = do.EventMetadata{
Key: "admin_login", Key: "admin_login",
Name: "管理员登录", Name: "管理员登录",
DefaultTemplate: model.NotificationMessage{ DefaultTemplate: do.NotificationMessage{
Title: "管理员登录提醒", Title: "管理员登录提醒",
Content: "管理员 {{user.username}} 于 {{time}} 从 IP {{ip}} 登录系统。", Content: "管理员 {{user.username}} 于 {{time}} 从 IP {{ip}} 登录系统。",
Level: "INFO", Level: "INFO",
@@ -1,7 +1,7 @@
// Copyright 2026 Arctel.net // Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
// Package service implements domain business logic and channel runners for message_gateway. // Package service implements domain business logic and channel runners for msg_gateway.
package service package service
import ( import (
@@ -9,9 +9,10 @@ import (
"Wavelet/core/contracts" "Wavelet/core/contracts"
"Wavelet/pkg/logger" "Wavelet/pkg/logger"
"Wavelet/pkg/util" "Wavelet/pkg/util"
"Wavelet/plugins/domain/message_gateway/errs" "Wavelet/plugins/domain/msg_gateway/consts"
"Wavelet/plugins/domain/message_gateway/model" "Wavelet/plugins/domain/msg_gateway/dao"
"Wavelet/plugins/domain/message_gateway/repository" "Wavelet/plugins/domain/msg_gateway/model/do"
"Wavelet/plugins/domain/msg_gateway/model/entity"
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/sha256" "crypto/sha256"
@@ -26,18 +27,18 @@ import (
) )
// Handler processes one inbound message. // Handler processes one inbound message.
type Handler func(ctx context.Context, msg model.InboundMessage) error type Handler func(ctx context.Context, msg do.InboundMessage) error
// Factory constructs a Channel from decrypted config. // Factory constructs a Channel from decrypted config.
type Factory func(cfg model.ChannelConfig, onInbound Handler) (Channel, error) type Factory func(cfg do.ChannelConfig, onInbound Handler) (Channel, error)
// Channel is one connected messaging adapter. // Channel is one connected messaging adapter.
type Channel interface { type Channel interface {
Type() string Type() string
Connect(ctx context.Context) error Connect(ctx context.Context) error
Disconnect(ctx context.Context) error Disconnect(ctx context.Context) error
Send(ctx context.Context, to model.Recipient, msg model.OutboundMessage) error Send(ctx context.Context, to do.Recipient, msg do.OutboundMessage) error
Capabilities() model.Capability Capabilities() do.Capability
} }
var ( var (
@@ -60,11 +61,11 @@ func Lookup(typ string) (Factory, bool) {
return fn, ok return fn, ok
} }
// CodeAlphabet excludes easily confused runes 0/O/1/I. // Re-exported constants.
const CodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" const (
CodeAlphabet = consts.CodeAlphabet
// CodeLength is the raw pairing code size. CodeLength = consts.CodeLength
const CodeLength = 8 )
// GenerateCode returns an 8-character pairing code. // GenerateCode returns an 8-character pairing code.
func GenerateCode() (string, error) { func GenerateCode() (string, error) {
@@ -282,87 +283,87 @@ func GetUserService(ctx context.Context) contracts.UserService {
} }
// BindChannel consumes a pairing code and binds the platform identity to the user. // BindChannel consumes a pairing code and binds the platform identity to the user.
func BindChannel(ctx context.Context, userID uint64, req model.BindRequest) (model.BindingDTO, error) { func BindChannel(ctx context.Context, userID uint64, req do.BindRequest) (do.BindingDTO, error) {
channelID, err := strconv.ParseUint(strings.TrimSpace(req.ChannelID), 10, 64) channelID, err := strconv.ParseUint(strings.TrimSpace(req.ChannelID), 10, 64)
if err != nil || channelID == 0 { if err != nil || channelID == 0 {
return model.BindingDTO{}, errs.ErrChannelIDRequired return do.BindingDTO{}, consts.ErrChannelIDRequired
} }
code := NormalizeCode(req.Code) code := NormalizeCode(req.Code)
if code == "" { if code == "" {
return model.BindingDTO{}, errs.ErrCodeInvalid return do.BindingDTO{}, consts.ErrCodeInvalid
} }
pairing, err := repository.GetPairingCode(ctx, code) pairing, err := dao.GetPairingCode(ctx, code)
if err != nil { if err != nil {
if errors.Is(err, errs.ErrRecordNotFound) { if errors.Is(err, consts.ErrRecordNotFound) {
return model.BindingDTO{}, errs.ErrCodeInvalid return do.BindingDTO{}, consts.ErrCodeInvalid
} }
return model.BindingDTO{}, err return do.BindingDTO{}, err
} }
if !pairing.ExpiresAt.After(time.Now()) { if !pairing.ExpiresAt.After(time.Now()) {
return model.BindingDTO{}, errs.ErrCodeInvalid return do.BindingDTO{}, consts.ErrCodeInvalid
} }
if pairing.ChannelID != channelID { if pairing.ChannelID != channelID {
return model.BindingDTO{}, errs.ErrChannelMismatch return do.BindingDTO{}, consts.ErrChannelMismatch
} }
ch, err := repository.GetMessageChannel(ctx, channelID) ch, err := dao.GetMessageChannel(ctx, channelID)
if err != nil { if err != nil {
if errors.Is(err, errs.ErrRecordNotFound) { if errors.Is(err, consts.ErrRecordNotFound) {
return model.BindingDTO{}, errs.ErrCodeInvalid return do.BindingDTO{}, consts.ErrCodeInvalid
} }
return model.BindingDTO{}, err return do.BindingDTO{}, err
} }
if !ch.Enabled { if !ch.Enabled {
return model.BindingDTO{}, errs.ErrChannelDisabled return do.BindingDTO{}, consts.ErrChannelDisabled
} }
existing, err := repository.GetBindingByChannelPlatform(ctx, channelID, pairing.PlatformUserID) existing, err := dao.GetBindingByChannelPlatform(ctx, channelID, pairing.PlatformUserID)
if err != nil && !errors.Is(err, errs.ErrRecordNotFound) { if err != nil && !errors.Is(err, consts.ErrRecordNotFound) {
return model.BindingDTO{}, err return do.BindingDTO{}, err
} }
if err == nil && existing != nil { if err == nil && existing != nil {
if existing.UserID != userID { if existing.UserID != userID {
return model.BindingDTO{}, errs.ErrPlatformAlreadyBound return do.BindingDTO{}, consts.ErrPlatformAlreadyBound
} }
_ = repository.DeletePairingCode(ctx, pairing.Code) _ = dao.DeletePairingCode(ctx, pairing.Code)
return ToBindingDTO(existing, ch), nil return ToBindingDTO(existing, ch), nil
} }
row := &model.MessageBinding{ row := &entity.MessageBinding{
UserID: userID, UserID: userID,
ChannelID: channelID, ChannelID: channelID,
PlatformUserID: pairing.PlatformUserID, PlatformUserID: pairing.PlatformUserID,
} }
if err := repository.CreateMessageBinding(ctx, row); err != nil { if err := dao.CreateMessageBinding(ctx, row); err != nil {
return model.BindingDTO{}, err return do.BindingDTO{}, err
} }
if err := repository.DeletePairingCode(ctx, pairing.Code); err != nil { if err := dao.DeletePairingCode(ctx, pairing.Code); err != nil {
return model.BindingDTO{}, err return do.BindingDTO{}, err
} }
return ToBindingDTO(row, ch), nil return ToBindingDTO(row, ch), nil
} }
// ListEnabledPublicChannels returns the channels a user may bind to. // ListEnabledPublicChannels returns the channels a user may bind to.
func ListEnabledPublicChannels(ctx context.Context) ([]model.PublicChannelDTO, error) { func ListEnabledPublicChannels(ctx context.Context) ([]do.PublicChannelDTO, error) {
rows, err := repository.ListEnabledMessageChannels(ctx) rows, err := dao.ListEnabledMessageChannels(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
out := make([]model.PublicChannelDTO, 0, len(rows)) out := make([]do.PublicChannelDTO, 0, len(rows))
for _, row := range rows { for _, row := range rows {
out = append(out, model.PublicChannelDTO{ID: row.ID, Name: row.Name, Type: row.Type}) out = append(out, do.PublicChannelDTO{ID: row.ID, Name: row.Name, Type: row.Type})
} }
return out, nil return out, nil
} }
// ListUserBindings returns the binding rows of one user enriched with channel info. // ListUserBindings returns the binding rows of one user enriched with channel info.
func ListUserBindings(ctx context.Context, userID uint64) ([]model.BindingDTO, error) { func ListUserBindings(ctx context.Context, userID uint64) ([]do.BindingDTO, error) {
rows, err := repository.ListBindingsByUser(ctx, userID) rows, err := dao.ListBindingsByUser(ctx, userID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
out := make([]model.BindingDTO, 0, len(rows)) out := make([]do.BindingDTO, 0, len(rows))
for i := range rows { for i := range rows {
ch, err := repository.GetMessageChannel(ctx, rows[i].ChannelID) ch, err := dao.GetMessageChannel(ctx, rows[i].ChannelID)
if err != nil { if err != nil {
out = append(out, ToBindingDTO(&rows[i], nil)) out = append(out, ToBindingDTO(&rows[i], nil))
continue continue
@@ -374,22 +375,22 @@ func ListUserBindings(ctx context.Context, userID uint64) ([]model.BindingDTO, e
// UnbindChannel removes a binding owned by the given user. // UnbindChannel removes a binding owned by the given user.
func UnbindChannel(ctx context.Context, userID, bindingID uint64) error { func UnbindChannel(ctx context.Context, userID, bindingID uint64) error {
row, err := repository.GetMessageBinding(ctx, bindingID) row, err := dao.GetMessageBinding(ctx, bindingID)
if err != nil { if err != nil {
if errors.Is(err, errs.ErrRecordNotFound) { if errors.Is(err, consts.ErrRecordNotFound) {
return errs.ErrBindingNotFound return consts.ErrBindingNotFound
} }
return err return err
} }
if row.UserID != userID { if row.UserID != userID {
return errs.ErrBindingForbidden return consts.ErrBindingForbidden
} }
return repository.DeleteMessageBinding(ctx, bindingID) return dao.DeleteMessageBinding(ctx, bindingID)
} }
// ToBindingDTO projects a binding row and its optional channel onto the user DTO. // ToBindingDTO projects a binding row and its optional channel onto the user DTO.
func ToBindingDTO(row *model.MessageBinding, ch *model.MessageChannel) model.BindingDTO { func ToBindingDTO(row *entity.MessageBinding, ch *entity.MessageChannel) do.BindingDTO {
dto := model.BindingDTO{ dto := do.BindingDTO{
ID: row.ID, ID: row.ID,
UserID: row.UserID, UserID: row.UserID,
ChannelID: row.ChannelID, ChannelID: row.ChannelID,
-5
View File
@@ -66,11 +66,6 @@ func (p *Plugin) Apply(ctx *core.Context) error {
}) })
ctx.Router().RegisterWhitelist("/api/v1/config/public") ctx.Router().RegisterWhitelist("/api/v1/config/public")
// 3. Custom injection
ctx.Router().GET("/custom", func(c *gin.Context) {
c.JSON(http.StatusOK, response.OK(gin.H{"custom": true}))
})
return nil return nil
} }