mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-13 10:26:38 +08:00
fix(persistence): migrate all pkg/persistence imports to plugins/infra/database and plugins/infra/cache
- Replace db.DB(ctx) with database.DB(ctx) from plugins/infra/database
- Replace db.Redis/db.PrefixedKey/db.GetJSON/db.SetJSON with cachepkg.* from plugins/infra/cache
- Replace pkg/persistence/idgen with pkg/idgen (already exists)
- Replace pkg/persistence/batchwriter with pkg/batchwriter (already exists)
- Replace pkg/persistence/migrator with pkg/migrator (already exists)
- Replace pkg/persistence/logstore with plugins/domain/risk_control/logstore
- Delete defunct pkg/{persistence,cap,message_gateway,push,shared,task}
- Fix vet issues: db alias in domain_test.go, driver_asynq_worker.TaskHandler reference
- Update Makefile architecture guard
- Update docs and skill references
- Update go.mod: gorilla/sessions promotion to direct dependency
This commit is contained in:
@@ -15,7 +15,7 @@ Wavelet 的消息推送机制采用了**元数据驱动 + 统一触发器 + 异
|
|||||||
|
|
||||||
| 目录/包名 | 职责定位 | 包含内容与设计细节 |
|
| 目录/包名 | 职责定位 | 包含内容与设计细节 |
|
||||||
| :--- | :--- | :--- |
|
| :--- | :--- | :--- |
|
||||||
| **`pkg/push/`** | 推送基础设施层 | 静态定义、不依赖系统数据库和任何框架。定义了统一接口 `Pusher`、单例 `PusherPool` 和多实现(Lark, Webhook, Email 等),提供配置验证及发送功能。 |
|
| **`plugins/domain/message_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。 |
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ build-embedded:
|
|||||||
code-check:
|
code-check:
|
||||||
@echo "==> Architecture guards..."
|
@echo "==> Architecture guards..."
|
||||||
@command -v rg >/dev/null 2>&1 || { echo 'error: rg (ripgrep) is required for architecture guards' >&2; exit 1; }
|
@command -v rg >/dev/null 2>&1 || { echo 'error: rg (ripgrep) is required for architecture guards' >&2; exit 1; }
|
||||||
@if [ -d pkg/model ] && rg -n 'db\.DB\(|db\.Redis' pkg/model --glob '*.go' -g '!*_test.go' ; then \
|
@if [ -d pkg/model ] && rg -n 'database\.DB\(|cachepkg\.Redis' pkg/model --glob '*.go' -g '!*_test.go' ; then \
|
||||||
echo 'error: pkg/model must not access db.DB or db.Redis (non-test code)' >&2; \
|
echo 'error: pkg/model must not access database.DB or cachepkg.Redis (non-test code)' >&2; \
|
||||||
exit 1; \
|
exit 1; \
|
||||||
fi
|
fi
|
||||||
golangci-lint run
|
golangci-lint run
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ import (
|
|||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/pkg/buildinfo"
|
"github.com/Rain-kl/Wavelet/pkg/buildinfo"
|
||||||
"github.com/Rain-kl/Wavelet/pkg/config"
|
"github.com/Rain-kl/Wavelet/pkg/config"
|
||||||
"github.com/Rain-kl/Wavelet/pkg/persistence/migrator"
|
"github.com/Rain-kl/Wavelet/pkg/migrator"
|
||||||
)
|
)
|
||||||
|
|
||||||
//nolint:unused // startup banner formatting utilities
|
//nolint:unused // startup banner formatting utilities
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/pkg/buildinfo"
|
"github.com/Rain-kl/Wavelet/pkg/buildinfo"
|
||||||
"github.com/Rain-kl/Wavelet/pkg/config"
|
"github.com/Rain-kl/Wavelet/pkg/config"
|
||||||
"github.com/Rain-kl/Wavelet/pkg/persistence/migrator"
|
"github.com/Rain-kl/Wavelet/pkg/migrator"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFormatStartupBanner(t *testing.T) {
|
func TestFormatStartupBanner(t *testing.T) {
|
||||||
|
|||||||
+3
-3
@@ -13,9 +13,9 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/pkg/persistence"
|
"github.com/Rain-kl/Wavelet/pkg/migrator"
|
||||||
"github.com/Rain-kl/Wavelet/pkg/persistence/migrator"
|
|
||||||
userdomain "github.com/Rain-kl/Wavelet/plugins/domain/user"
|
userdomain "github.com/Rain-kl/Wavelet/plugins/domain/user"
|
||||||
|
"github.com/Rain-kl/Wavelet/plugins/infra/database"
|
||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/plugins/domain/auth"
|
"github.com/Rain-kl/Wavelet/plugins/domain/auth"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
@@ -90,7 +90,7 @@ var resetPasswdCmd = &cobra.Command{
|
|||||||
log.Fatalf("加密密码失败: %v\n", err)
|
log.Fatalf("加密密码失败: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
err = database.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
if err := tx.Model(&user).Update("password", user.Password).Error; err != nil {
|
if err := tx.Model(&user).Update("password", user.Password).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ import (
|
|||||||
"github.com/Rain-kl/Wavelet/pkg/buildinfo"
|
"github.com/Rain-kl/Wavelet/pkg/buildinfo"
|
||||||
"github.com/Rain-kl/Wavelet/pkg/config"
|
"github.com/Rain-kl/Wavelet/pkg/config"
|
||||||
"github.com/Rain-kl/Wavelet/pkg/logger"
|
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||||
"github.com/Rain-kl/Wavelet/pkg/persistence/migrator"
|
"github.com/Rain-kl/Wavelet/pkg/migrator"
|
||||||
"github.com/Rain-kl/Wavelet/pkg/trace"
|
"github.com/Rain-kl/Wavelet/pkg/trace"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|||||||
-7437
File diff suppressed because it is too large
Load Diff
@@ -141,8 +141,7 @@ Wavelet 是面向未来 5 年生产级云原生与高并发业务中台的 **微
|
|||||||
| `w_uploads`<br>`w_upload_stats` | `plugins/domain/upload` | `models/models.go`<br>`repository/repository.go` | `core/contracts.StorageService`<br>`upload.Ingest` 流水线 |
|
| `w_uploads`<br>`w_upload_stats` | `plugins/domain/upload` | `models/models.go`<br>`repository/repository.go` | `core/contracts.StorageService`<br>`upload.Ingest` 流水线 |
|
||||||
| `w_system_configs`<br>`w_templates` | `plugins/domain/admin` | `models.go`<br>`repository.go` | `ctx.Settings()` / `contracts.ConfigService`<br>Redis Pub/Sub 广播 |
|
| `w_system_configs`<br>`w_templates` | `plugins/domain/admin` | `models.go`<br>`repository.go` | `ctx.Settings()` / `contracts.ConfigService`<br>Redis Pub/Sub 广播 |
|
||||||
| `w_message_channels`<br>`w_message_bindings`<br>`w_message_pairing_codes`<br>`w_push_events`<br>`w_push_channels`<br>`w_push_histories` | `plugins/domain/message_gateway` | `models.go`<br>`repository.go` | `EventBus` 强类型事件广播订阅 |
|
| `w_message_channels`<br>`w_message_bindings`<br>`w_message_pairing_codes`<br>`w_push_events`<br>`w_push_channels`<br>`w_push_histories` | `plugins/domain/message_gateway` | `models.go`<br>`repository.go` | `EventBus` 强类型事件广播订阅 |
|
||||||
| `w_user_access_logs` (分析库/日志库) | `plugins/domain/risk_control` | 委托 `pkg/persistence/logstore` | `logstore.UserAccessLog` 抽象门面 |
|
| `w_task_executions`<br>`w_schedules` | `plugins/drivers/driver_asynq_*` | `types.go`<br>`schedule.go` | `ctx.Task()` 与 `ctx.Schedule()` 扩展点 |
|
||||||
| `w_task_executions`<br>`w_schedules` | `pkg/task` & `plugins/drivers` | `pkg/task/types.go` | `ctx.Task()` 与 `ctx.Schedule()` 扩展点 |
|
|
||||||
|
|
||||||
### 5.3 架构防线与单向依赖保障
|
### 5.3 架构防线与单向依赖保障
|
||||||
1. **测试脚手架绝对解耦**:底层通用的 `pkg/testhelper` 严禁反向引用任何上层业务插件。`testhelper` 维护轻量自包含的测试表脚手架,彻底杜绝包导入循环(Import Cycle)。
|
1. **测试脚手架绝对解耦**:底层通用的 `pkg/testhelper` 严禁反向引用任何上层业务插件。`testhelper` 维护轻量自包含的测试表脚手架,彻底杜绝包导入循环(Import Cycle)。
|
||||||
|
|||||||
@@ -1,650 +0,0 @@
|
|||||||
# Message Gateway Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Ship Wavelet’s inbound message gateway (Telegram + QQ private chat), admin channel cards, profile pairing, and `message_gateway.inbound` events — no Clipper clip ingest.
|
|
||||||
|
|
||||||
**Architecture:** `pkg/message_gateway` defines Channel/Registry/pairing with zero Gin/GORM. Worker runs adapters (telebot long poll, official botgo C2C). API does CRUD and bind/unbind. Bound inbound messages emit `listener.EmitMessageGatewayInbound`.
|
|
||||||
|
|
||||||
**Tech Stack:** Go 1.25, Gin, GORM, goose, `gopkg.in/telebot.v4`, official QQ `botgo`, Next.js, next-intl.
|
|
||||||
|
|
||||||
**Spec:** `docs/superpowers/specs/2026-08-16-message-gateway-design.md`
|
|
||||||
|
|
||||||
## Global Constraints
|
|
||||||
|
|
||||||
- Wavelet repo only. Do not edit Clipper.
|
|
||||||
- `pkg/message_gateway` must not import Gin, GORM, sessions, or `internal/apps`.
|
|
||||||
- Tables `w_*`. No physical FKs. Dual PG + SQLite goose.
|
|
||||||
- API errors only `response.Abort*`.
|
|
||||||
- No `init()` cross-module registration; wire in `internal/platform/bootstrap` and `internal/cmd`.
|
|
||||||
- Routes only via `internal/router/v1`.
|
|
||||||
- v1: private chat / C2C only; groups logged and dropped.
|
|
||||||
- Pairing: alphabet `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`, length 8, display `XXXX-XXXX`, TTL 15 minutes, one-time.
|
|
||||||
- Event name string is exactly `message_gateway.inbound`.
|
|
||||||
- Channel types: `telegram`, `qq`. `owner_scope` always `system` in v1.
|
|
||||||
- Encrypt credentials with `pkg/util.Encrypt` using `hex.EncodeToString(sha256(session_secret))` as the 64-char key.
|
|
||||||
- After handlers: `make swagger`. After code: `make format` and `make code-check`.
|
|
||||||
- Commits: Conventional Commits.
|
|
||||||
- Tests: `t.TempDir()` only.
|
|
||||||
- Execution: `superpowers:using-git-worktrees` in Wavelet.
|
|
||||||
|
|
||||||
## File map
|
|
||||||
|
|
||||||
| Path | Responsibility |
|
|
||||||
| --- | --- |
|
|
||||||
| `pkg/message_gateway/types.go` | Capability, Attachment, InboundMessage, OutboundMessage, Recipient |
|
|
||||||
| `pkg/message_gateway/channel.go` | `Channel` interface, `Handler` |
|
|
||||||
| `pkg/message_gateway/registry.go` | Register / Lookup factories |
|
|
||||||
| `pkg/message_gateway/pairing.go` | `GenerateCode`, `NormalizeCode`, `FormatCode` |
|
|
||||||
| `pkg/message_gateway/channel/telegram` | telebot private-chat adapter |
|
|
||||||
| `pkg/message_gateway/channel/qq` | botgo C2C adapter |
|
|
||||||
| `internal/model/message_gateway.go` | GORM models |
|
|
||||||
| goose `202608160003_create_message_gateway.sql` | PG + SQLite |
|
|
||||||
| `internal/repository/message_gateway.go` | persistence |
|
|
||||||
| `internal/listener/message_gateway.go` | event + emit |
|
|
||||||
| `internal/apps/message_gateway` | user bind logics + handlers |
|
|
||||||
| `internal/apps/admin/message_gateway` | admin CRUD |
|
|
||||||
| `internal/apps/message_gateway/runner` | Worker Start / reload / inbound handler |
|
|
||||||
| `internal/cmd/worker.go` | start runner before Asynq |
|
|
||||||
| `internal/router/v1/admin.go` + new files | HTTP routes |
|
|
||||||
| frontend admin + profile + i18n | UI |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: Pairing helpers and Channel types
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `pkg/message_gateway/types.go`
|
|
||||||
- Create: `pkg/message_gateway/channel.go`
|
|
||||||
- Create: `pkg/message_gateway/registry.go`
|
|
||||||
- Create: `pkg/message_gateway/pairing.go`
|
|
||||||
- Test: `pkg/message_gateway/pairing_test.go`
|
|
||||||
- Test: `pkg/message_gateway/registry_test.go`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Produces: `GenerateCode() (string, error)` returns 8 chars from the alphabet; `NormalizeCode(s string) string` strips `-` and uppercases; `FormatCode(s string) string` → `XXXX-XXXX`; `Register(typ string, fn Factory)`; `Lookup(typ string) (Factory, bool)`; types `Channel`, `Capability`, `InboundMessage`, `OutboundMessage`, `Recipient`, `Attachment`, `Handler`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write pairing tests**
|
|
||||||
|
|
||||||
```go
|
|
||||||
func TestGenerateCode_AlphabetAndLength(t *testing.T) {
|
|
||||||
code, err := GenerateCode()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(code) != 8 {
|
|
||||||
t.Fatalf("len=%d", len(code))
|
|
||||||
}
|
|
||||||
for _, r := range code {
|
|
||||||
if !strings.ContainsRune(CodeAlphabet, r) {
|
|
||||||
t.Fatalf("bad rune %q", r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNormalizeAndFormat(t *testing.T) {
|
|
||||||
if got := NormalizeCode("ab-cd-ef-gh"); got != "ABCDEFGH" {
|
|
||||||
t.Fatalf("got %q", got)
|
|
||||||
}
|
|
||||||
if got := FormatCode("ABCDEFGH"); got != "ABCD-EFGH" {
|
|
||||||
t.Fatalf("got %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run — must fail compile**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./pkg/message_gateway -count=1
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: undefined `GenerateCode`.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Implement types, pairing, registry**
|
|
||||||
|
|
||||||
`pairing.go`: `const CodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"`, `CodeLength = 8`. `GenerateCode` reads `crypto/rand` indexes.
|
|
||||||
|
|
||||||
`channel.go`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type Channel interface {
|
|
||||||
Type() string
|
|
||||||
Connect(ctx context.Context) error
|
|
||||||
Disconnect(ctx context.Context) error
|
|
||||||
Send(ctx context.Context, to Recipient, msg OutboundMessage) error
|
|
||||||
Capabilities() Capability
|
|
||||||
}
|
|
||||||
|
|
||||||
type Handler func(ctx context.Context, msg InboundMessage) error
|
|
||||||
type Factory func(cfg ChannelConfig, onInbound Handler) (Channel, error)
|
|
||||||
```
|
|
||||||
|
|
||||||
`ChannelConfig` in types: `ID uint64`, `Type string`, `Name string`, `Credentials map[string]string`, `Extra map[string]string`.
|
|
||||||
|
|
||||||
`Attachment`: `Path, FileName, MIME, Error string`.
|
|
||||||
|
|
||||||
Registry: mutex map[string]Factory.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Tests pass**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./pkg/message_gateway -count=1
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add pkg/message_gateway
|
|
||||||
git commit -m "feat(message-gateway): add channel types, registry, and pairing codes"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 2: Models and goose migrations
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `internal/model/message_gateway.go`
|
|
||||||
- Create: `internal/infra/persistence/migrator/goose/postgres/202608160003_create_message_gateway.sql`
|
|
||||||
- Create: `internal/infra/persistence/migrator/goose/sqlite/202608160003_create_message_gateway.sql`
|
|
||||||
- Modify: `internal/testhelper/test_helper.go` AutoMigrate list
|
|
||||||
- Modify: `internal/infra/persistence/migrator/migrator_test.go` only if it asserts table counts that break (do not change `w_system_configs` count)
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Produces: `model.MessageChannel`, `model.MessageBinding`, `model.MessagePairingCode` with `TableName()` `w_message_channels`, `w_message_bindings`, `w_message_pairing_codes`
|
|
||||||
- Constants: `MessageChannelTypeTelegram = "telegram"`, `MessageChannelTypeQQ = "qq"`, `MessageOwnerScopeSystem = "system"`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add models**
|
|
||||||
|
|
||||||
```go
|
|
||||||
type MessageChannel struct {
|
|
||||||
ID uint64 `json:"id,string" gorm:"primaryKey"`
|
|
||||||
Name string `json:"name" gorm:"size:128;not null"`
|
|
||||||
Type string `json:"type" gorm:"size:32;not null;index"`
|
|
||||||
OwnerScope string `json:"owner_scope" gorm:"size:16;not null;default:system"`
|
|
||||||
OwnerID *uint64 `json:"owner_id,string"`
|
|
||||||
Enabled bool `json:"enabled" gorm:"not null;default:true"`
|
|
||||||
Credentials string `json:"-" gorm:"type:text"`
|
|
||||||
Extra string `json:"extra" gorm:"type:text"`
|
|
||||||
CreatedAt time.Time
|
|
||||||
UpdatedAt time.Time
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Binding: `UserID`, `ChannelID`, `PlatformUserID`. Pairing: `Code` PK, `ChannelID`, `PlatformUserID`, `ExpiresAt`.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Goose SQL (both dialects)**
|
|
||||||
|
|
||||||
Postgres up:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE IF NOT EXISTS w_message_channels (
|
|
||||||
id BIGINT PRIMARY KEY,
|
|
||||||
name VARCHAR(128) NOT NULL,
|
|
||||||
type VARCHAR(32) NOT NULL,
|
|
||||||
owner_scope VARCHAR(16) NOT NULL DEFAULT 'system',
|
|
||||||
owner_id BIGINT NULL,
|
|
||||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
credentials TEXT NOT NULL DEFAULT '',
|
|
||||||
extra TEXT NOT NULL DEFAULT '',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_w_message_channels_type ON w_message_channels (type);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS w_message_bindings (
|
|
||||||
id BIGINT PRIMARY KEY,
|
|
||||||
user_id BIGINT NOT NULL,
|
|
||||||
channel_id BIGINT NOT NULL,
|
|
||||||
platform_user_id VARCHAR(128) NOT NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uniq_w_message_bindings_channel_platform
|
|
||||||
ON w_message_bindings (channel_id, platform_user_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_w_message_bindings_user ON w_message_bindings (user_id);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS w_message_pairing_codes (
|
|
||||||
code VARCHAR(16) PRIMARY KEY,
|
|
||||||
channel_id BIGINT NOT NULL,
|
|
||||||
platform_user_id VARCHAR(128) NOT NULL,
|
|
||||||
expires_at TIMESTAMPTZ NOT NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_w_message_pairing_lookup
|
|
||||||
ON w_message_pairing_codes (channel_id, platform_user_id);
|
|
||||||
```
|
|
||||||
|
|
||||||
SQLite twin: `INTEGER`/`TEXT`/`DATETIME` as in existing Wavelet sqlite migrations. Down: drop the three tables.
|
|
||||||
|
|
||||||
- [ ] **Step 3: AutoMigrate the three models in testhelper**
|
|
||||||
|
|
||||||
- [ ] **Step 4:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/model ./internal/infra/persistence/migrator ./internal/testhelper -count=1
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: PASS (migrator applies `202608160003`).
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add internal/model/message_gateway.go \
|
|
||||||
internal/infra/persistence/migrator/goose \
|
|
||||||
internal/testhelper/test_helper.go
|
|
||||||
git commit -m "feat(message-gateway): add w_message_* models and goose migrations"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 3: Repository
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `internal/repository/message_gateway.go`
|
|
||||||
- Test: `internal/repository/message_gateway_test.go`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Consumes: models from Task 2, `db.DB(ctx)` from `internal/infra/persistence`
|
|
||||||
- Produces:
|
|
||||||
- `CreateMessageChannel`, `UpdateMessageChannel`, `GetMessageChannel`, `ListMessageChannels`, `DeleteMessageChannel`
|
|
||||||
- `CreateMessageBinding`, `GetBindingByChannelPlatform(ctx, channelID, platformUserID)`, `ListBindingsByUser`, `DeleteMessageBinding`
|
|
||||||
- `UpsertPairingCode` (reuse unexpired row for same channel+platform user), `GetPairingCode`, `DeletePairingCode`, `DeleteExpiredPairingCodes`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Test upsert reuses unexpired code**
|
|
||||||
|
|
||||||
```go
|
|
||||||
func TestUpsertPairingCode_ReusesUnexpired(t *testing.T) {
|
|
||||||
_, _, cleanup := testhelper.SetupTestEnvironment(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
first, err := repository.UpsertPairingCode(ctx, 1, "tg-1", "ABCD1234", time.Now().Add(15*time.Minute))
|
|
||||||
if err != nil { t.Fatal(err) }
|
|
||||||
second, err := repository.UpsertPairingCode(ctx, 1, "tg-1", "ZZZZ9999", time.Now().Add(15*time.Minute))
|
|
||||||
if err != nil { t.Fatal(err) }
|
|
||||||
if first.Code != second.Code || first.Code != "ABCD1234" {
|
|
||||||
t.Fatalf("reuse failed: %+v %+v", first, second)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run — fail (undefined)**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/repository -count=1 -run Pairing
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Implement repository using `db.DB(ctx)` only**
|
|
||||||
|
|
||||||
`UpsertPairingCode`: `First` where `channel_id` and `platform_user_id` and `expires_at > now`; if found return it; else create with provided code.
|
|
||||||
|
|
||||||
`DeleteMessageChannel`: delete pairings and bindings for that channel then the channel (transaction).
|
|
||||||
|
|
||||||
- [ ] **Step 4: Tests pass**
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add internal/repository/message_gateway.go internal/repository/message_gateway_test.go
|
|
||||||
git commit -m "feat(message-gateway): add channel, binding, and pairing repositories"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 4: Domain event
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `internal/listener/message_gateway.go`
|
|
||||||
- Test: `internal/listener/message_gateway_test.go`
|
|
||||||
- Modify: `internal/platform/bootstrap/bootstrap.go` — register a log-only handler in `RegisterPushDomainEvents` sibling `RegisterMessageGatewayListeners` (`sync.Once`)
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Produces: `const EventMessageGatewayInbound = "message_gateway.inbound"`
|
|
||||||
- `type MessageGatewayInbound struct { Msg message_gateway.InboundMessage }`
|
|
||||||
- `OnMessageGatewayInbound(fn)`
|
|
||||||
- `EmitMessageGatewayInbound(ctx, msg)` — skip if `msg.BindingUserID == nil`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write emit test**
|
|
||||||
|
|
||||||
```go
|
|
||||||
func TestEmitMessageGatewayInbound_SkipsUnbound(t *testing.T) {
|
|
||||||
called := 0
|
|
||||||
OnMessageGatewayInbound(func(ctx context.Context, ev MessageGatewayInbound) { called++ })
|
|
||||||
EmitMessageGatewayInbound(context.Background(), message_gateway.InboundMessage{Text: "x"})
|
|
||||||
if called != 0 {
|
|
||||||
t.Fatal("unbound must not emit")
|
|
||||||
}
|
|
||||||
uid := uint64(9)
|
|
||||||
EmitMessageGatewayInbound(context.Background(), message_gateway.InboundMessage{BindingUserID: &uid, Text: "x"})
|
|
||||||
if called != 1 {
|
|
||||||
t.Fatalf("called=%d", called)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run — fail compile**
|
|
||||||
- [ ] **Step 3: Implement listener + bootstrap log-only handler**
|
|
||||||
- [ ] **Step 4: Test pass**
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git commit -m "feat(message-gateway): emit message_gateway.inbound domain events"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 5: Telegram adapter (private chat)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `pkg/message_gateway/channel/telegram/adapter.go`
|
|
||||||
- Test: `pkg/message_gateway/channel/telegram/adapter_test.go`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Consumes: `ChannelConfig`, `Handler`
|
|
||||||
- Produces: `func New(cfg ChannelConfig, onInbound Handler) (message_gateway.Channel, error)`
|
|
||||||
- Credentials key `bot_token`; extra key `base_url`
|
|
||||||
- `Type() == "telegram"`
|
|
||||||
- `Capabilities`: Text, Image, File, Reply true; Group false
|
|
||||||
|
|
||||||
- [ ] **Step 1: Test group updates are dropped (inject a `handleUpdate` method)**
|
|
||||||
|
|
||||||
Export `HandlePrivate` for tests:
|
|
||||||
|
|
||||||
```go
|
|
||||||
func TestHandleUpdate_DropsGroups(t *testing.T) {
|
|
||||||
var got int
|
|
||||||
a := &Adapter{onInbound: func(ctx context.Context, msg message_gateway.InboundMessage) error {
|
|
||||||
got++
|
|
||||||
return nil
|
|
||||||
}}
|
|
||||||
a.handleTeleMessage(fakeGroupMessage())
|
|
||||||
if got != 0 {
|
|
||||||
t.Fatalf("group must be ignored")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleUpdate_PrivateText(t *testing.T) {
|
|
||||||
var got message_gateway.InboundMessage
|
|
||||||
a := &Adapter{cfg: message_gateway.ChannelConfig{ID: 7, Type: "telegram"}, onInbound: func(ctx context.Context, msg message_gateway.InboundMessage) error {
|
|
||||||
got = msg
|
|
||||||
return nil
|
|
||||||
}}
|
|
||||||
a.handleTeleMessage(fakePrivateText("hi", 42))
|
|
||||||
if got.Text != "hi" || got.PlatformUserID != "42" || got.ChannelID != 7 {
|
|
||||||
t.Fatalf("%+v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Build fakes as small structs / telebot.Message with `Chat.Type = telebot.ChatPrivate` vs `ChatGroup`.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run — fail**
|
|
||||||
|
|
||||||
- [ ] **Step 3: Implement with `gopkg.in/telebot.v4`**
|
|
||||||
|
|
||||||
`Connect`: `telebot.NewBot(Settings{Token, URL: extra base_url, Poller: &telebot.LongPoller{Timeout: 10s}})`, handle `OnText`, `OnPhoto`, `OnDocument` only if private. Download media to `os.MkdirTemp("wg-tg-*")`. `Disconnect`: `bot.Stop()`.
|
|
||||||
|
|
||||||
Register factory in `New` via `message_gateway.Register("telegram", New)` from **apps runner**, not `init()` in pkg if that violates bootstrap rule. Spec said no `init()` for **cross-module** integration. Registering a factory in `telegram` package `init` is OK **or** register explicitly in runner. Prefer **explicit register in runner** to keep pkg side-effect free.
|
|
||||||
|
|
||||||
- [ ] **Step 4: `go test ./pkg/message_gateway/channel/telegram -count=1`**
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git commit -m "feat(message-gateway): add Telegram private-chat telebot adapter"
|
|
||||||
```
|
|
||||||
|
|
||||||
Add `gopkg.in/telebot.v4` with `go get`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 6: QQ adapter (C2C only)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `pkg/message_gateway/channel/qq/adapter.go`
|
|
||||||
- Test: `pkg/message_gateway/channel/qq/adapter_test.go`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Same `New(cfg, onInbound)`
|
|
||||||
- Credentials: `app_id`, `app_secret`
|
|
||||||
- Extra: `portal_host` default `q.qq.com`
|
|
||||||
- Drop non-C2C events in a testable `handleC2C` function
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write C2C-only tests**
|
|
||||||
|
|
||||||
```go
|
|
||||||
func TestHandleEvent_DropsNonC2C(t *testing.T) {
|
|
||||||
var got int
|
|
||||||
a := &Adapter{onInbound: func(ctx context.Context, msg message_gateway.InboundMessage) error {
|
|
||||||
got++
|
|
||||||
return nil
|
|
||||||
}}
|
|
||||||
a.handleEvent(qqEvent{Kind: "group", UserID: "u1", Text: "hi"})
|
|
||||||
if got != 0 {
|
|
||||||
t.Fatal("non-C2C must be ignored")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleEvent_C2CText(t *testing.T) {
|
|
||||||
var got message_gateway.InboundMessage
|
|
||||||
a := &Adapter{cfg: message_gateway.ChannelConfig{ID: 3}, onInbound: func(ctx context.Context, msg message_gateway.InboundMessage) error {
|
|
||||||
got = msg
|
|
||||||
return nil
|
|
||||||
}}
|
|
||||||
a.handleEvent(qqEvent{Kind: "c2c", UserID: "openid-1", Text: "hello", MessageID: "m1"})
|
|
||||||
if got.Text != "hello" || got.PlatformUserID != "openid-1" || got.ChannelID != 3 {
|
|
||||||
t.Fatalf("%+v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Define a local `qqEvent` in the adapter file so tests do not need a live gateway. `Connect` uses official `github.com/tencent-connect/botgo` (or the module path from https://bot.q.qq.com/wiki/develop/gosdk/); pin the resolved module in `go.mod` and name it in the commit body. `Connect` enables C2C intent only. `Send` uses C2C REST.
|
|
||||||
|
|
||||||
- [ ] **Step 2: `go test ./pkg/message_gateway/channel/qq -count=1` — fail compile**
|
|
||||||
- [ ] **Step 3: Implement adapter + `New`**
|
|
||||||
- [ ] **Step 4: Tests pass**
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git commit -m "feat(message-gateway): add QQ official C2C botgo adapter"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 7: Worker gateway runner
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `internal/apps/message_gateway/runner/runner.go`
|
|
||||||
- Create: `internal/apps/message_gateway/runner/inbound.go`
|
|
||||||
- Test: `internal/apps/message_gateway/runner/inbound_test.go`
|
|
||||||
- Modify: `internal/cmd/worker.go` start `runner.Start` in a goroutine **before** `worker.StartWorker()`
|
|
||||||
- Modify: `internal/platform/bootstrap` if needed to register factories
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Consumes: repository, listener, telegram.New, qq.New, pairing.GenerateCode
|
|
||||||
- Produces: `Start(ctx context.Context) error` — load enabled channels, lock, Connect; poll `updated_at` every 5s and reload changed IDs
|
|
||||||
- Inbound handler:
|
|
||||||
1. Lookup binding by channel+platform user
|
|
||||||
2. If none: `UpsertPairingCode` + `ch.Send` formatted code + bind instructions
|
|
||||||
3. If bound: set `BindingUserID`, `EmitMessageGatewayInbound`; on emit error send “could not save”; on success send “received”
|
|
||||||
|
|
||||||
- [ ] **Step 1: Test inbound unbound mints/reuses code and does not emit**
|
|
||||||
|
|
||||||
Use a `fakeChannel` implementing `Channel` that records `Send` calls, and stub repository via a small `inboundService` struct with function fields so the test does not need a live bot.
|
|
||||||
|
|
||||||
```go
|
|
||||||
type inboundDeps struct {
|
|
||||||
LookupBinding func(...) (*model.MessageBinding, error)
|
|
||||||
UpsertCode func(...) (*model.MessagePairingCode, error)
|
|
||||||
Emit func(context.Context, message_gateway.InboundMessage)
|
|
||||||
Send func(context.Context, Recipient, OutboundMessage) error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d inboundDeps) Handle(ctx context.Context, msg message_gateway.InboundMessage) error
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2–4: Implement Handle + Start**
|
|
||||||
|
|
||||||
Credential decrypt: `key := hex.EncodeToString(sum[:])` where `sum := sha256.Sum256([]byte(config.Config.App.SessionSecret))`. If decrypt fails, skip channel and log.
|
|
||||||
|
|
||||||
Lock: Redis `SET wg:channel:{id} {node} NX EX 30` refreshed while running; if Redis disabled, start anyway (single-process dev).
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git commit -m "feat(message-gateway): run adapters on worker and handle pairing inbound"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 8: Admin HTTP API
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `internal/apps/admin/message_gateway/errs.go`
|
|
||||||
- Create: `internal/apps/admin/message_gateway/logics.go`
|
|
||||||
- Create: `internal/apps/admin/message_gateway/handlers.go`
|
|
||||||
- Create: `internal/apps/admin/message_gateway/routers.go`
|
|
||||||
- Test: `internal/apps/admin/message_gateway/logics_test.go`
|
|
||||||
- Modify: `internal/router/v1/admin.go` — `registerAdminMessageGatewayRoutes` under “Messaging”
|
|
||||||
- Create: `internal/router/v1/message_gateway_admin.go` calling `adminmsg.RegisterRoutes`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- `GET /api/v1/admin/message-gateway/channels`
|
|
||||||
- `GET /api/v1/admin/message-gateway/channels/definitions`
|
|
||||||
- `POST /api/v1/admin/message-gateway/channels` body `{name,type,enabled,bot_token|app_id,app_secret,base_url,portal_host}`
|
|
||||||
- `PATCH /api/v1/admin/message-gateway/channels/:id` empty secrets keep previous
|
|
||||||
- `DELETE /api/v1/admin/message-gateway/channels/:id`
|
|
||||||
- `POST /api/v1/admin/message-gateway/channels/:id/test` optional getMe/token probe
|
|
||||||
- List DTO masks secrets as `********` if non-empty
|
|
||||||
|
|
||||||
Definitions JSON:
|
|
||||||
|
|
||||||
```go
|
|
||||||
[]Definition{
|
|
||||||
{Type:"telegram", Name:"Telegram", Fields:[]Field{{Key:"bot_token", Type:"password", Required:true}, {Key:"base_url", Type:"text"}}},
|
|
||||||
{Type:"qq", Name:"QQ", Fields:[]Field{{Key:"app_id", Required:true}, {Key:"app_secret", Type:"password", Required:true}, {Key:"portal_host", Type:"text"}}},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 1: Test create telegram without token fails; create with token stores ciphertext not plaintext**
|
|
||||||
|
|
||||||
- [ ] **Step 2–4: Implement logics + Abort\* handlers + swagger comments**
|
|
||||||
|
|
||||||
- [ ] **Step 5:** `make swagger` then commit
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git commit -m "feat(message-gateway): add admin channel CRUD APIs"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 9: User bind/unbind API
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `internal/apps/message_gateway/errs.go`
|
|
||||||
- Create: `internal/apps/message_gateway/logics.go`
|
|
||||||
- Create: `internal/apps/message_gateway/handlers.go`
|
|
||||||
- Create: `internal/apps/message_gateway/routers.go`
|
|
||||||
- Test: `internal/apps/message_gateway/logics_test.go`
|
|
||||||
- Modify: `internal/router/v1/v1.go` call `RegisterMessageGatewayUserRoutes`
|
|
||||||
- Create: `internal/router/v1/message_gateway.go`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- `GET /api/v1/message-gateway/bindings` — current user, include channel name/type
|
|
||||||
- `POST /api/v1/message-gateway/bindings` `{ "channel_id": "...", "code": "ABCD-EFGH" }`
|
|
||||||
- `DELETE /api/v1/message-gateway/bindings/:id`
|
|
||||||
- Login required. Bind: `NormalizeCode`, `GetPairingCode`, check expiry and `channel_id`, `GetBindingByChannelPlatform` → 409 if other user, insert binding, delete code.
|
|
||||||
- Unbind: only if `binding.UserID == current`.
|
|
||||||
|
|
||||||
- [ ] **Step 1–4: TDD expired code 400; happy path deletes code**
|
|
||||||
|
|
||||||
- [ ] **Step 5: `make swagger` + commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git commit -m "feat(message-gateway): add user bind and unbind APIs"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 10: Frontend service + admin page
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `frontend/lib/services/message-gateway/types.ts`
|
|
||||||
- Create: `frontend/lib/services/message-gateway/admin.service.ts`
|
|
||||||
- Create: `frontend/lib/services/message-gateway/index.ts`
|
|
||||||
- Modify: `frontend/lib/services/index.ts` export
|
|
||||||
- Create: `frontend/app/(main)/admin/message-gateway/page.tsx`
|
|
||||||
- Create: `frontend/app/(main)/admin/message-gateway/components/channel-card.tsx`
|
|
||||||
- Create: `frontend/app/(main)/admin/message-gateway/components/add-channel-dialog.tsx`
|
|
||||||
- Create: `frontend/app/(main)/admin/message-gateway/channels/telegram/form.tsx`
|
|
||||||
- Create: `frontend/app/(main)/admin/message-gateway/channels/qq/form.tsx`
|
|
||||||
- Modify: `frontend/components/layout/sidebar.tsx` admin item after push
|
|
||||||
- Modify: `frontend/messages/zh-CN.json` + `en.json` namespace `admin.messageGateway`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- `AdminMessageGatewayService.list/create/update/remove/definitions`
|
|
||||||
- Empty state + add dialog: type select then `<TelegramForm>` or `<QQForm>`
|
|
||||||
- Card: name, type, enabled switch, delete
|
|
||||||
- `useTranslations('admin.messageGateway')` — no hardcoded UI strings
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add i18n keys to zh-CN only, run `node frontend/scripts/check-i18n-keys.mjs` — fail**
|
|
||||||
- [ ] **Step 2: Add matching en keys — checker pass**
|
|
||||||
- [ ] **Step 3: Implement pages (follow `/admin/push` layout: `w-full py-6`, h1 `text-2xl font-semibold tracking-tight`)**
|
|
||||||
- [ ] **Step 4:** `cd frontend && pnpm tsc --noEmit --jsx preserve`
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git commit -m "feat(message-gateway): add admin channel cards and per-type forms"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 11: Profile bind card
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `frontend/lib/services/message-gateway/user.service.ts`
|
|
||||||
- Create: `frontend/components/common/settings/bot-binding-card.tsx`
|
|
||||||
- Modify: `frontend/components/common/settings/profile.tsx` render the card
|
|
||||||
- Modify: i18n `settings.botBinding`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- List bindings (channel name, type, platform_user_id) + unbind
|
|
||||||
- Bind dialog: select enabled channel + code input
|
|
||||||
- All copy via next-intl
|
|
||||||
|
|
||||||
- [ ] **Step 1–4: keys + component + typecheck**
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git commit -m "feat(message-gateway): add profile bot pairing card"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 12: Repo-wide gates
|
|
||||||
|
|
||||||
**Files:** swagger `docs/*` if not already committed
|
|
||||||
|
|
||||||
- [ ] **Step 1:** `go test ./... -count=1`
|
|
||||||
- [ ] **Step 2:** `make swagger && make format && node frontend/scripts/check-i18n-keys.mjs && make code-check`
|
|
||||||
- [ ] **Step 3:** Confirm `RegisterMessageGatewayUserRoutes` in `v1.go` and admin routes exist; no `init()` in apps
|
|
||||||
- [ ] **Step 4: Commit leftover format/swagger**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git commit -m "chore(message-gateway): swagger and format"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Spec coverage
|
|
||||||
|
|
||||||
| Spec | Task |
|
|
||||||
|---|---|
|
|
||||||
| pkg types / pairing / registry | 1 |
|
|
||||||
| tables | 2 |
|
|
||||||
| repository | 3 |
|
|
||||||
| domain event | 4 |
|
|
||||||
| telegram / qq adapters | 5–6 |
|
|
||||||
| worker runner + pairing inbound | 7 |
|
|
||||||
| admin API + UI | 8, 10 |
|
|
||||||
| user bind + profile | 9, 11 |
|
|
||||||
| gates | 12 |
|
|
||||||
| Clipper ingest | **not in this plan** |
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
# Frontend i18n Design
|
|
||||||
|
|
||||||
Date: 2026-07-24
|
|
||||||
Status: Approved for implementation planning
|
|
||||||
Scope: Frontend UI only
|
|
||||||
|
|
||||||
## 1. Goals
|
|
||||||
|
|
||||||
Add bilingual UI support for Wavelet frontend:
|
|
||||||
|
|
||||||
- Languages: `zh-CN` and `en`
|
|
||||||
- Default locale: `zh-CN`
|
|
||||||
- Locale resolution: explicit user choice → browser language → default
|
|
||||||
- Phase 1: infrastructure + core paths only (layout / auth / settings)
|
|
||||||
- Must remain compatible with `NEXT_STANDALONE_EXPORT` static export
|
|
||||||
|
|
||||||
### Non-goals (Phase 1)
|
|
||||||
|
|
||||||
- Backend API error / message localization
|
|
||||||
- Email / push notification localization
|
|
||||||
- URL locale prefixes (`/en/...`, `/zh-CN/...`) and SEO hreflang
|
|
||||||
- Full translation of all admin business pages
|
|
||||||
|
|
||||||
## 2. Context
|
|
||||||
|
|
||||||
Current state:
|
|
||||||
|
|
||||||
- Root layout hardcodes `lang='zh-CN'`
|
|
||||||
- UI copy is mostly Chinese string literals across many TSX files
|
|
||||||
- Date formatting often hardcodes `zh-CN` / `date-fns/locale` `zhCN`
|
|
||||||
- No i18n library is installed
|
|
||||||
- Frontend supports both normal Next rewrites mode and static export embed mode
|
|
||||||
|
|
||||||
## 3. Approach
|
|
||||||
|
|
||||||
Use **next-intl in non-routing / provider mode**.
|
|
||||||
|
|
||||||
Why this approach:
|
|
||||||
|
|
||||||
- Mature App Router integration and clear `useTranslations` API
|
|
||||||
- ICU message format ready when needed
|
|
||||||
- Avoids locale-prefixed routing, which conflicts with static-export simplicity and current route structure
|
|
||||||
- Cookie + browser detection matches product preference without SEO path requirements
|
|
||||||
|
|
||||||
Rejected alternatives:
|
|
||||||
|
|
||||||
- Fully custom Context + JSON: lower dependency cost, but reimplements interpolation/plurals/type safety poorly
|
|
||||||
- `i18next` + `react-i18next`: powerful, but heavier and less natural for this Next App Router setup
|
|
||||||
|
|
||||||
## 4. Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
RootLayout
|
|
||||||
html lang={locale}
|
|
||||||
ThemeProvider
|
|
||||||
CustomThemeProvider
|
|
||||||
AppQueryProvider
|
|
||||||
NextIntlClientProvider(locale, messages)
|
|
||||||
existing User / Notification / Bell providers
|
|
||||||
pages + components
|
|
||||||
```
|
|
||||||
|
|
||||||
### Key files
|
|
||||||
|
|
||||||
| Path | Responsibility |
|
|
||||||
| --- | --- |
|
|
||||||
| `frontend/i18n/config.ts` | Supported locales, default locale, cookie name, normalize helpers |
|
|
||||||
| `frontend/i18n/request.ts` | `getRequestConfig` for server-side locale/messages resolution when not in export mode |
|
|
||||||
| `frontend/i18n/client.ts` | Client helpers to read/write locale preference |
|
|
||||||
| `frontend/messages/zh-CN.json` | Chinese messages |
|
|
||||||
| `frontend/messages/en.json` | English messages |
|
|
||||||
| `frontend/components/common/language-switcher.tsx` (or under `layout/`) | Language switch UI |
|
|
||||||
| `frontend/lib/i18n-format.ts` (optional location under `i18n/`) | Locale-aware date/number formatting helpers |
|
|
||||||
|
|
||||||
### Runtime flow
|
|
||||||
|
|
||||||
1. Resolve locale: cookie `NEXT_LOCALE` → browser languages → `zh-CN`
|
|
||||||
2. Load `messages/{locale}.json`
|
|
||||||
3. Provide locale + messages through `NextIntlClientProvider`
|
|
||||||
4. Components call `useTranslations('<namespace>')`
|
|
||||||
5. Language switcher writes cookie and refreshes locale/messages
|
|
||||||
6. Update `document.documentElement.lang`
|
|
||||||
|
|
||||||
## 5. Locale Resolution
|
|
||||||
|
|
||||||
Supported locales: `zh-CN`, `en`
|
|
||||||
|
|
||||||
Normalization:
|
|
||||||
|
|
||||||
- `zh`, `zh-CN`, `zh-Hans*` → `zh-CN`
|
|
||||||
- `en`, `en-US`, `en-GB`, other `en-*` → `en`
|
|
||||||
- anything else → `zh-CN`
|
|
||||||
|
|
||||||
Priority:
|
|
||||||
|
|
||||||
1. User explicit choice stored in cookie `NEXT_LOCALE`
|
|
||||||
2. Browser language (`Accept-Language` on server, `navigator.languages` on client)
|
|
||||||
3. Default `zh-CN`
|
|
||||||
|
|
||||||
Invalid cookie values are normalized to a supported locale and may be rewritten to a valid value.
|
|
||||||
|
|
||||||
## 6. Static Export Compatibility
|
|
||||||
|
|
||||||
Constraints:
|
|
||||||
|
|
||||||
- No locale-segment routes
|
|
||||||
- No middleware-based locale rewriting required for correctness
|
|
||||||
- `build:embed` (`NEXT_STANDALONE_EXPORT=true`) must continue to work
|
|
||||||
|
|
||||||
Behavior:
|
|
||||||
|
|
||||||
- **Normal SSR/dev**: resolve locale on server when possible to reduce first-paint language flash
|
|
||||||
- **Static export**: ship both message catalogs; resolve on client from cookie/browser; accept a brief default-language flash similar to theme hydration, using existing `suppressHydrationWarning` patterns where needed
|
|
||||||
|
|
||||||
## 7. Message Organization
|
|
||||||
|
|
||||||
Single catalog files with nested namespaces:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"common": {
|
|
||||||
"save": "保存",
|
|
||||||
"cancel": "取消",
|
|
||||||
"loading": "加载中..."
|
|
||||||
},
|
|
||||||
"layout": {
|
|
||||||
"nav": {
|
|
||||||
"home": "首页",
|
|
||||||
"myFiles": "我的文件"
|
|
||||||
},
|
|
||||||
"userMenu": {
|
|
||||||
"settings": "设置",
|
|
||||||
"logout": "退出登录"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"auth": {
|
|
||||||
"login": {
|
|
||||||
"title": "登录",
|
|
||||||
"submit": "登录"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"settings": {
|
|
||||||
"appearance": {
|
|
||||||
"language": "语言",
|
|
||||||
"languageDesc": "选择界面显示语言"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Conventions:
|
|
||||||
|
|
||||||
- Keys use camelCase and hierarchical grouping
|
|
||||||
- Prefer complete phrases as values; avoid assembling sentences in components
|
|
||||||
- Use ICU only when needed (`{name}`, plural forms)
|
|
||||||
- Backend `error_msg` values are shown as-is in Phase 1
|
|
||||||
- Frontend-owned toast / validation copy is translated
|
|
||||||
|
|
||||||
Both locale files must keep the same key tree. A key-alignment check script is recommended.
|
|
||||||
|
|
||||||
## 8. Language Switcher UX
|
|
||||||
|
|
||||||
Placement:
|
|
||||||
|
|
||||||
- Header toolbar near theme controls
|
|
||||||
- Appearance settings page as an explicit preference row
|
|
||||||
|
|
||||||
UI labels for language options use native names and do not themselves translate:
|
|
||||||
|
|
||||||
- `中文`
|
|
||||||
- `English`
|
|
||||||
|
|
||||||
On change:
|
|
||||||
|
|
||||||
1. Persist `NEXT_LOCALE`
|
|
||||||
2. Apply new locale/messages (via refresh or controlled provider update)
|
|
||||||
3. Sync `document.documentElement.lang`
|
|
||||||
4. Preserve unrelated UI state where practical (theme, auth session, sidebar collapse)
|
|
||||||
|
|
||||||
## 9. Phase 1 Migration Scope
|
|
||||||
|
|
||||||
### In scope
|
|
||||||
|
|
||||||
- Install and wire `next-intl`
|
|
||||||
- Message catalogs for core namespaces
|
|
||||||
- Locale resolution + persistence
|
|
||||||
- `LanguageSwitcher`
|
|
||||||
- Translate:
|
|
||||||
- layout shell: sidebar nav/user menu, header accessible labels / titles
|
|
||||||
- auth: login / register / OTP labels, buttons, validation messages
|
|
||||||
- settings: appearance (including language preference), profile, security, notifications, access-token visible copy
|
|
||||||
- Replace date/number hardcoding only where touched by the above paths
|
|
||||||
- Ensure `html lang` reflects active locale
|
|
||||||
|
|
||||||
### Out of scope
|
|
||||||
|
|
||||||
- Remaining admin pages and deep business modules
|
|
||||||
- Backend localization
|
|
||||||
- Route prefixing / SEO alternate links
|
|
||||||
|
|
||||||
Unmigrated pages may remain Chinese hard-coded; mixed-language UI is acceptable during incremental rollout.
|
|
||||||
|
|
||||||
## 10. Formatting Helpers
|
|
||||||
|
|
||||||
Introduce locale-aware helpers for dates/numbers used by migrated surfaces, e.g.:
|
|
||||||
|
|
||||||
- `formatDateTime(value, locale)`
|
|
||||||
- `formatNumber(value, locale)`
|
|
||||||
|
|
||||||
`date-fns` locale objects should follow active locale (`zhCN` / `enUS`) when a migrated component uses them.
|
|
||||||
|
|
||||||
## 11. Error Handling & Fallbacks
|
|
||||||
|
|
||||||
| Case | Behavior |
|
|
||||||
| --- | --- |
|
|
||||||
| Missing message key | Dev warning; do not crash; show key or fallback language value |
|
|
||||||
| Unsupported cookie locale | Normalize to supported locale / default |
|
|
||||||
| Partial migration | Keep hard-coded Chinese on unmigrated screens |
|
|
||||||
| Backend error strings | Display raw `error_msg` |
|
|
||||||
|
|
||||||
## 12. Testing & Acceptance
|
|
||||||
|
|
||||||
Manual:
|
|
||||||
|
|
||||||
1. No cookie + browser Chinese → Chinese UI
|
|
||||||
2. No cookie + browser English → English UI
|
|
||||||
3. Manual switch to English survives refresh
|
|
||||||
4. Manual switch back to Chinese survives refresh
|
|
||||||
5. Core paths (layout/auth/settings) have no major residual hard-coded Chinese UI copy
|
|
||||||
6. `pnpm build` and `pnpm build:embed` both succeed
|
|
||||||
7. Language switch does not break theme, session, or sidebar state
|
|
||||||
|
|
||||||
Automated (recommended):
|
|
||||||
|
|
||||||
- Unit tests for `normalizeLocale` / resolution priority
|
|
||||||
- Script or test asserting `zh-CN.json` and `en.json` key parity
|
|
||||||
|
|
||||||
## 13. Rollout Plan (high level)
|
|
||||||
|
|
||||||
1. Add i18n infrastructure and empty/core message files
|
|
||||||
2. Mount provider and language switcher
|
|
||||||
3. Migrate layout shell copy
|
|
||||||
4. Migrate auth copy
|
|
||||||
5. Migrate settings copy + appearance language control
|
|
||||||
6. Verify SSR and static-export builds
|
|
||||||
7. Document how later pages should adopt `useTranslations`
|
|
||||||
|
|
||||||
## 14. Open Implementation Notes
|
|
||||||
|
|
||||||
- Prefer cookie name `NEXT_LOCALE` unless an existing project cookie convention conflicts during implementation
|
|
||||||
- Prefer minimal surface-area integration with next-intl; avoid introducing locale-based routing APIs that break static export
|
|
||||||
- Keep `internal/util` and backend packages untouched
|
|
||||||
- After implementation, follow repo frontend conventions and existing provider composition style
|
|
||||||
@@ -1,268 +0,0 @@
|
|||||||
# Message Gateway Design (Wavelet)
|
|
||||||
|
|
||||||
**Date:** 2026-08-16
|
|
||||||
**Status:** Approved for implementation planning
|
|
||||||
**Scope:** Wavelet framework only (sub-project 1 of 3)
|
|
||||||
**Product:** Wavelet scaffold — reusable inbound/outbound messaging channels
|
|
||||||
|
|
||||||
## 1. Goal
|
|
||||||
|
|
||||||
Give Wavelet a Hermes-style **message gateway**: admins configure Telegram and QQ bots in the admin UI; end users bind their private-chat identity with a one-time short code on the profile page. Inbound private messages become **domain events**. Products (Clipper later) subscribe and decide what to persist.
|
|
||||||
|
|
||||||
This spec does **not** write Clipper `c_items`. That is sub-project 3 after Wavelet is merged into Clipper.
|
|
||||||
|
|
||||||
### In scope (Wavelet)
|
|
||||||
|
|
||||||
- `pkg/message_gateway` + `channel/telegram` + `channel/qq`
|
|
||||||
- Admin UI `/admin/message-gateway` (empty state, add channel, per-type forms, cards)
|
|
||||||
- User profile card: bind / list / unbind
|
|
||||||
- Worker-hosted connections (Telegram long poll, QQ official WebSocket)
|
|
||||||
- Pairing codes, bindings, encrypted credentials
|
|
||||||
- Domain event on authorized inbound messages
|
|
||||||
- Text + image + file inbound/outbound at the adapter layer (v1 private chat only)
|
|
||||||
|
|
||||||
### Out of scope
|
|
||||||
|
|
||||||
- Group / guild / channel / @mention routing
|
|
||||||
- Hermes sessions, slash commands, STT, cron, circuit-breaker fleet, 20 platforms
|
|
||||||
- Telegram webhook mode (long poll only)
|
|
||||||
- Writing business tables (`c_*`) or calling `upload.Ingest` from `pkg/`
|
|
||||||
- Changing `pkg/push` (outbound notification remains a separate system)
|
|
||||||
- Multi-tenant hosted bots (`owner_scope=user`) — column exists, v1 only inserts `system`
|
|
||||||
|
|
||||||
### Later sub-projects (not this spec)
|
|
||||||
|
|
||||||
2. Clipper `git fetch upstream && git merge upstream/main`
|
|
||||||
3. Clipper listener: inbound event → `upload.Ingest` + `c_items` with `source=telegram|qq`
|
|
||||||
|
|
||||||
## 2. Decisions (locked)
|
|
||||||
|
|
||||||
| Topic | Choice |
|
|
||||||
|---|---|
|
|
||||||
| Ownership | Instance-level shared bots; users pair to them |
|
|
||||||
| Pairing | 8-char one-time code, 15 minutes, consumed on success |
|
|
||||||
| Profile | Show bound platform user ID; user can unbind |
|
|
||||||
| Surface | Private chat / C2C only; groups ignored + logged |
|
|
||||||
| Runtime | Worker process starts the gateway; API does not poll |
|
|
||||||
| Events | Bound inbound → `internal/listener` domain event |
|
|
||||||
| Telegram SDK | `gopkg.in/telebot.v4` (tucnak/telebot current module) |
|
|
||||||
| QQ SDK | Official QQ Bot Go SDK (`botgo` / [docs](https://bot.q.qq.com/wiki/develop/gosdk/)) |
|
|
||||||
| Hermes | Adapter shape only (`Connect`/`Disconnect`/`Send`/inbound event/capabilities) |
|
|
||||||
|
|
||||||
## 3. Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
Admin UI ──CRUD──► API ──w_message_channels──► Worker Gateway
|
|
||||||
│
|
|
||||||
User DM ──telebot / botgo──► Channel adapter ─────────┤
|
|
||||||
▼
|
|
||||||
unbound? mint pairing code, reply
|
|
||||||
bound? emit InboundMessage event
|
|
||||||
│
|
|
||||||
User profile ──bind/unbind──► API ──w_message_bindings / pairing_codes
|
|
||||||
│
|
|
||||||
product listener (Clipper later)
|
|
||||||
```
|
|
||||||
|
|
||||||
`pkg/message_gateway` has **no** Gin, GORM, sessions, or `internal/apps` imports. Persistence lives in `internal/repository`. Encryption of credentials uses existing Wavelet secret helpers from apps/repository, not from `pkg`.
|
|
||||||
|
|
||||||
### 3.1 Packages
|
|
||||||
|
|
||||||
```
|
|
||||||
pkg/message_gateway/
|
|
||||||
types.go # Message, Attachment, Capability, ChannelType
|
|
||||||
channel.go # Channel interface
|
|
||||||
registry.go # Register / Lookup
|
|
||||||
pairing.go # GenerateCode (alphabet, length) — no storage
|
|
||||||
channel/telegram/ # telebot private chat
|
|
||||||
channel/qq/ # official botgo C2C
|
|
||||||
internal/apps/admin/message_gateway/
|
|
||||||
internal/apps/message_gateway/ # user bind/unbind
|
|
||||||
internal/listener/ # InboundMessage event type + dispatch
|
|
||||||
internal/model/ # channel, binding, pairing row types
|
|
||||||
internal/repository/ # CRUD
|
|
||||||
```
|
|
||||||
|
|
||||||
Frontend:
|
|
||||||
|
|
||||||
```
|
|
||||||
frontend/lib/services/message-gateway/
|
|
||||||
frontend/app/(main)/admin/message-gateway/
|
|
||||||
page.tsx
|
|
||||||
components/channel-card.tsx
|
|
||||||
components/add-channel-dialog.tsx
|
|
||||||
channels/telegram/form.tsx
|
|
||||||
channels/qq/form.tsx
|
|
||||||
frontend/components/common/settings/ # profile bind card
|
|
||||||
```
|
|
||||||
|
|
||||||
Sidebar admin item: `titleKey: 'messageGateway'`, url `/admin/message-gateway`, placed next to push.
|
|
||||||
|
|
||||||
### 3.2 Channel interface
|
|
||||||
|
|
||||||
```go
|
|
||||||
type Channel interface {
|
|
||||||
Type() string
|
|
||||||
Connect(ctx context.Context) error
|
|
||||||
Disconnect(ctx context.Context) error
|
|
||||||
Send(ctx context.Context, to Recipient, msg OutboundMessage) error
|
|
||||||
Capabilities() Capability
|
|
||||||
}
|
|
||||||
|
|
||||||
type Capability struct {
|
|
||||||
Text, Image, File, Reply bool
|
|
||||||
Group bool // always false in v1 adapters
|
|
||||||
}
|
|
||||||
|
|
||||||
type InboundMessage struct {
|
|
||||||
ChannelID uint64
|
|
||||||
ChannelType string
|
|
||||||
PlatformUserID string
|
|
||||||
ChatID string
|
|
||||||
MessageID string
|
|
||||||
Text string
|
|
||||||
Attachments []Attachment // local temp paths + mime; no upload package
|
|
||||||
BindingUserID *uint64 // nil if unbound
|
|
||||||
}
|
|
||||||
|
|
||||||
type Handler func(ctx context.Context, msg InboundMessage) error
|
|
||||||
```
|
|
||||||
|
|
||||||
Gateway runner (Worker) constructs adapters from DB rows, calls `Connect`, and registers one `Handler` that implements pairing + event emit.
|
|
||||||
|
|
||||||
### 3.3 Process model
|
|
||||||
|
|
||||||
- `internal/cmd` Worker path: after bootstrap, `messagegateway.Start(ctx)`.
|
|
||||||
- API process never starts adapters.
|
|
||||||
- Enable / disable / credential change: Worker watches DB (poll every few seconds or Redis pub/sub already used by the platform). v1 may poll `updated_at` every 5s. Hot-reload only the changed channel.
|
|
||||||
- Single Worker assumed. If a second Worker starts, it tries a Redis/DB lock keyed by `channel_id` + token fingerprint; failure → skip that channel and log.
|
|
||||||
- Per-channel reconnect with exponential backoff; one channel crash must not stop others.
|
|
||||||
|
|
||||||
## 4. Data model
|
|
||||||
|
|
||||||
All tables `w_*`. Dual goose SQL (Postgres + SQLite). No physical FKs.
|
|
||||||
|
|
||||||
### 4.1 `w_message_channels`
|
|
||||||
|
|
||||||
| Column | Type | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| id | snowflake | PK |
|
|
||||||
| name | string | admin display name |
|
|
||||||
| type | string | `telegram` \| `qq` |
|
|
||||||
| owner_scope | string | v1 always `system` |
|
|
||||||
| owner_id | bigint null | reserved; null in v1 |
|
|
||||||
| enabled | bool | |
|
|
||||||
| credentials | text | encrypted JSON; never returned raw |
|
|
||||||
| extra | text | optional JSON (base URL, sandbox host) |
|
|
||||||
| created_at, updated_at | timestamptz | |
|
|
||||||
|
|
||||||
### 4.2 `w_message_bindings`
|
|
||||||
|
|
||||||
| Column | Type | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| id | snowflake | PK |
|
|
||||||
| user_id | bigint | Wavelet user |
|
|
||||||
| channel_id | bigint | |
|
|
||||||
| platform_user_id | string | Telegram user id / QQ OpenID |
|
|
||||||
| created_at | timestamptz | |
|
|
||||||
|
|
||||||
Unique `(channel_id, platform_user_id)`. A user may bind many channels; a platform identity binds to at most one user per channel.
|
|
||||||
|
|
||||||
### 4.3 `w_message_pairing_codes`
|
|
||||||
|
|
||||||
| Column | Type | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| code | string | PK, 8 chars |
|
|
||||||
| channel_id | bigint | |
|
|
||||||
| platform_user_id | string | |
|
|
||||||
| expires_at | timestamptz | now + 15m |
|
|
||||||
| created_at | timestamptz | |
|
|
||||||
|
|
||||||
Delete row on successful bind. Expired rows ignored and cleaned by a small periodic delete (may live in the Worker loop).
|
|
||||||
|
|
||||||
**Code alphabet:** `ABCDEFGHJKLMNPQRSTUVWXYZ23456789` (no `0O1I`). Format displayed as `XXXX-XXXX`.
|
|
||||||
|
|
||||||
## 5. Pairing and profile
|
|
||||||
|
|
||||||
1. Unbound private message arrives.
|
|
||||||
2. Worker upserts a pairing row (if an unexpired code already exists for that pair, reuse it).
|
|
||||||
3. Bot replies with the code and “Settings → Profile → Bind a bot”.
|
|
||||||
4. User opens profile card, picks an **enabled** channel, submits the code.
|
|
||||||
5. API validates, inserts binding, deletes the code.
|
|
||||||
6. Profile lists bindings: channel name, type, **platform user ID**, unbind button.
|
|
||||||
|
|
||||||
Unauthorized/unbound never emit `InboundMessage` to product listeners.
|
|
||||||
|
|
||||||
## 6. Admin UI and credentials
|
|
||||||
|
|
||||||
Empty state + “Add channel”. Dialog: choose `telegram` | `qq`, then that type’s form.
|
|
||||||
|
|
||||||
| Type | Required | Optional |
|
|
||||||
|---|---|---|
|
|
||||||
| telegram | name, bot token | API base URL (default `https://api.telegram.org`) |
|
|
||||||
| qq | name, App ID, App Secret | sandbox / portal host (default `q.qq.com`) |
|
|
||||||
|
|
||||||
Cards: name, type, enabled toggle, connected/disconnected (best-effort from Worker), edit, delete. Edit never echoes raw secrets; empty secret field means “keep current”.
|
|
||||||
|
|
||||||
Create: validate field shape, optional SDK probe (`getMe` / token), then insert. Probe failure → 400, no row.
|
|
||||||
|
|
||||||
## 7. HTTP API
|
|
||||||
|
|
||||||
Admin (admin middleware), prefix `/api/v1/admin/message-gateway`:
|
|
||||||
|
|
||||||
- `GET /channels` — list (secrets masked)
|
|
||||||
- `GET /channels/definitions` — form schema per type
|
|
||||||
- `POST /channels` — create
|
|
||||||
- `PATCH /channels/:id` — update
|
|
||||||
- `DELETE /channels/:id` — delete (cascade bindings + pairing rows in logics)
|
|
||||||
- `POST /channels/:id/test` — optional probe
|
|
||||||
|
|
||||||
User (login required), prefix `/api/v1/message-gateway`:
|
|
||||||
|
|
||||||
- `GET /bindings` — current user’s bindings
|
|
||||||
- `POST /bindings` — `{ channel_id, code }`
|
|
||||||
- `DELETE /bindings/:id` — unbind own row only
|
|
||||||
|
|
||||||
Errors via `response.Abort*`. Invalid/expired code → 400. Platform identity already bound → 409.
|
|
||||||
|
|
||||||
## 8. Inbound media and reply
|
|
||||||
|
|
||||||
Adapters accept private-chat **text, images, files**. Voice/STT is out of scope; a voice message is treated as a file attachment if the SDK delivers bytes, otherwise ignored with a log.
|
|
||||||
|
|
||||||
Attachments are written under `t.TempDir()`-equivalent process temp (`os.MkdirTemp`) and passed as filesystem paths on `InboundMessage`. The product listener (Clipper later) must `upload.Ingest` and then delete the temp file. Gateway deletes leftovers older than 1 hour.
|
|
||||||
|
|
||||||
If the product handler returns an error, the bot sends a generic “could not save your message” (no internal error text). Success ACK is a single short reply when `Capabilities().Reply` is true. ACK can be disabled later via extra JSON; v1 always ACKs.
|
|
||||||
|
|
||||||
Group / non-C2C updates: log and drop. Do not mint pairing codes.
|
|
||||||
|
|
||||||
## 9. Domain event
|
|
||||||
|
|
||||||
Name: `message_gateway.inbound` (exact string in `internal/listener`).
|
|
||||||
|
|
||||||
Payload: `InboundMessage` plus `BindingUserID` set. Register in `internal/platform/bootstrap` (no `init()`). Wavelet ships a no-op or log-only listener. Clipper will register the clip writer in sub-project 3.
|
|
||||||
|
|
||||||
## 10. Error handling
|
|
||||||
|
|
||||||
- Adapter panics: recover in the gateway runner, mark channel disconnected, backoff reconnect.
|
|
||||||
- Media download failure: keep text; attachment entry has `Error` string; still emit if bound.
|
|
||||||
- Encrypt/decrypt failure: treat channel as disabled, log, do not start adapter.
|
|
||||||
- i18n: all new UI strings in `zh-CN.json` + `en.json` (`admin.messageGateway`, `settings.botBinding`).
|
|
||||||
|
|
||||||
## 11. Testing and done criteria
|
|
||||||
|
|
||||||
| Gate | Pass |
|
|
||||||
|---|---|
|
|
||||||
| Fake channel + pairing | generate, reuse unexpired, expire, consume, conflict 409 |
|
|
||||||
| Telegram adapter unit | mock telebot updates: text, photo, document; groups dropped |
|
|
||||||
| QQ adapter unit | mock C2C event; non-C2C dropped |
|
|
||||||
| API tests | admin CRUD, bind/unbind, bad code |
|
|
||||||
| `go test ./...` | pass |
|
|
||||||
| Frontend | `pnpm tsc --noEmit --jsx preserve`, `check-i18n-keys.mjs` |
|
|
||||||
| Manual | add TG channel → DM gets code → profile bind → second DM emits event in logs |
|
|
||||||
|
|
||||||
## 12. Risks
|
|
||||||
|
|
||||||
- **Official QQ Bot** requires a published/sandbox app at q.qq.com; local dev may only test Telegram.
|
|
||||||
- **telebot v4 module path** is `gopkg.in/telebot.v4`; pin a version in `go.mod`.
|
|
||||||
- **One poller per token:** documented; second Worker skips the channel.
|
|
||||||
- **Token leak in logs:** never log raw credentials or pairing codes at info level (debug only, redacted).
|
|
||||||
@@ -19,6 +19,7 @@ require (
|
|||||||
github.com/go-jose/go-jose/v4 v4.1.4
|
github.com/go-jose/go-jose/v4 v4.1.4
|
||||||
github.com/google/go-cmp v0.7.0
|
github.com/google/go-cmp v0.7.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/gorilla/sessions v1.4.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/hibiken/asynq v0.26.0
|
github.com/hibiken/asynq v0.26.0
|
||||||
github.com/maypok86/otter/v2 v2.3.0
|
github.com/maypok86/otter/v2 v2.3.0
|
||||||
@@ -118,7 +119,6 @@ require (
|
|||||||
github.com/google/btree v1.0.0 // indirect
|
github.com/google/btree v1.0.0 // indirect
|
||||||
github.com/gorilla/context v1.1.2 // indirect
|
github.com/gorilla/context v1.1.2 // indirect
|
||||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||||
github.com/gorilla/sessions v1.4.0 // indirect
|
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
|
||||||
github.com/hashicorp/go-version v1.9.0 // indirect
|
github.com/hashicorp/go-version v1.9.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package batchwriter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testEvent struct {
|
||||||
|
ID int
|
||||||
|
Data string
|
||||||
|
}
|
||||||
|
|
||||||
|
func testConfig() Config {
|
||||||
|
return Config{
|
||||||
|
Name: "test-writer",
|
||||||
|
QueueSize: 100,
|
||||||
|
MaxBatchSize: 5,
|
||||||
|
FlushInterval: 20 * time.Millisecond,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriter_BatchSizeFlush(t *testing.T) {
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
batches [][]testEvent
|
||||||
|
flushWg sync.WaitGroup
|
||||||
|
)
|
||||||
|
flushWg.Add(1)
|
||||||
|
|
||||||
|
cfg := testConfig()
|
||||||
|
cfg.FlushInterval = time.Hour
|
||||||
|
|
||||||
|
w, err := New(cfg, func(_ context.Context, items []testEvent) error {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
batches = append(batches, items)
|
||||||
|
if len(items) == 5 {
|
||||||
|
flushWg.Done()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
w.Start(context.Background())
|
||||||
|
defer func() { _ = w.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
for i := 1; i <= 5; i++ {
|
||||||
|
ok := w.TryEnqueue(testEvent{ID: i, Data: "payload"})
|
||||||
|
assert.True(t, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
flushWg.Wait()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for batch flush")
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
require.Len(t, batches, 1)
|
||||||
|
assert.Len(t, batches[0], 5)
|
||||||
|
for i, item := range batches[0] {
|
||||||
|
assert.Equal(t, i+1, item.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriter_IntervalFlush(t *testing.T) {
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
flushed []testEvent
|
||||||
|
done = make(chan struct{})
|
||||||
|
)
|
||||||
|
|
||||||
|
cfg := testConfig()
|
||||||
|
cfg.MaxBatchSize = 100
|
||||||
|
cfg.FlushInterval = 30 * time.Millisecond
|
||||||
|
|
||||||
|
w, err := New(cfg, func(_ context.Context, items []testEvent) error {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
flushed = append(flushed, items...)
|
||||||
|
if len(flushed) == 2 {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
default:
|
||||||
|
close(done)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
w.Start(context.Background())
|
||||||
|
defer func() { _ = w.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
assert.True(t, w.TryEnqueue(testEvent{ID: 1}))
|
||||||
|
assert.True(t, w.TryEnqueue(testEvent{ID: 2}))
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for interval flush")
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
assert.Len(t, flushed, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriter_MinBatchSizeThreshold(t *testing.T) {
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
flushed []testEvent
|
||||||
|
done = make(chan struct{})
|
||||||
|
)
|
||||||
|
|
||||||
|
cfg := testConfig()
|
||||||
|
cfg.MaxBatchSize = 100
|
||||||
|
cfg.MinBatchSize = 3
|
||||||
|
cfg.FlushInterval = 20 * time.Millisecond
|
||||||
|
cfg.MaxFlushWait = 60 * time.Millisecond
|
||||||
|
|
||||||
|
w, err := New(cfg, func(_ context.Context, items []testEvent) error {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
flushed = append(flushed, items...)
|
||||||
|
if len(flushed) == 2 {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
default:
|
||||||
|
close(done)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
w.Start(context.Background())
|
||||||
|
defer func() { _ = w.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
assert.True(t, w.TryEnqueue(testEvent{ID: 1}))
|
||||||
|
assert.True(t, w.TryEnqueue(testEvent{ID: 2}))
|
||||||
|
|
||||||
|
time.Sleep(30 * time.Millisecond)
|
||||||
|
mu.Lock()
|
||||||
|
assert.Empty(t, flushed, "items should wait until MinBatchSize or MaxFlushWait")
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for forced max wait flush")
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
assert.Len(t, flushed, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriter_StopDrainsRemaining(t *testing.T) {
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
flushed []testEvent
|
||||||
|
)
|
||||||
|
|
||||||
|
cfg := testConfig()
|
||||||
|
cfg.FlushInterval = time.Hour
|
||||||
|
|
||||||
|
w, err := New(cfg, func(_ context.Context, items []testEvent) error {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
flushed = append(flushed, items...)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
w.Start(context.Background())
|
||||||
|
for i := 1; i <= 3; i++ {
|
||||||
|
assert.True(t, w.TryEnqueue(testEvent{ID: i}))
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
require.NoError(t, w.Stop(ctx))
|
||||||
|
|
||||||
|
assert.False(t, w.Running())
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
assert.Len(t, flushed, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriter_DropWhenFull(t *testing.T) {
|
||||||
|
var (
|
||||||
|
dropped atomic.Int64
|
||||||
|
blockCh = make(chan struct{})
|
||||||
|
)
|
||||||
|
|
||||||
|
cfg := Config{
|
||||||
|
QueueSize: 2,
|
||||||
|
MaxBatchSize: 10,
|
||||||
|
FlushInterval: time.Hour,
|
||||||
|
}
|
||||||
|
|
||||||
|
w, err := New(cfg, func(_ context.Context, _ []testEvent) error {
|
||||||
|
<-blockCh
|
||||||
|
return nil
|
||||||
|
}, WithDropHandler(func(_ testEvent) {
|
||||||
|
dropped.Add(1)
|
||||||
|
}))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
w.Start(context.Background())
|
||||||
|
defer func() {
|
||||||
|
close(blockCh)
|
||||||
|
_ = w.Stop(context.Background())
|
||||||
|
}()
|
||||||
|
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
|
w.ch <- testEvent{ID: 1}
|
||||||
|
w.ch <- testEvent{ID: 2}
|
||||||
|
|
||||||
|
assert.False(t, w.TryEnqueue(testEvent{ID: 3}))
|
||||||
|
assert.Equal(t, int64(1), dropped.Load())
|
||||||
|
assert.Equal(t, int64(1), w.Stats().Drops)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriter_FlushErrorCallback(t *testing.T) {
|
||||||
|
var (
|
||||||
|
called atomic.Bool
|
||||||
|
flushErr = errors.New("clickhouse write timeout")
|
||||||
|
done = make(chan struct{})
|
||||||
|
)
|
||||||
|
|
||||||
|
cfg := testConfig()
|
||||||
|
cfg.MaxBatchSize = 1
|
||||||
|
cfg.FlushInterval = time.Hour
|
||||||
|
|
||||||
|
w, err := New(cfg, func(_ context.Context, _ []testEvent) error {
|
||||||
|
return flushErr
|
||||||
|
}, WithFlushErrorHandler(func(_ context.Context, items []testEvent, err error) {
|
||||||
|
called.Store(true)
|
||||||
|
assert.Equal(t, flushErr, err)
|
||||||
|
assert.Len(t, items, 1)
|
||||||
|
close(done)
|
||||||
|
}))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
w.Start(context.Background())
|
||||||
|
defer func() { _ = w.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
assert.True(t, w.TryEnqueue(testEvent{ID: 1}))
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for error callback")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.True(t, called.Load())
|
||||||
|
assert.Equal(t, int64(1), w.Stats().FlushErrors)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriter_ValidateConfig(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cfg Config
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"valid", DefaultConfig(), false},
|
||||||
|
{"zero queue", Config{QueueSize: 0, MaxBatchSize: 10, FlushInterval: time.Second}, true},
|
||||||
|
{"zero max batch", Config{QueueSize: 10, MaxBatchSize: 0, FlushInterval: time.Second}, true},
|
||||||
|
{"negative min batch", Config{QueueSize: 10, MaxBatchSize: 10, MinBatchSize: -1, FlushInterval: time.Second}, true},
|
||||||
|
{"zero flush interval", Config{QueueSize: 10, MaxBatchSize: 10, FlushInterval: 0}, true},
|
||||||
|
{"negative max flush wait", Config{QueueSize: 10, MaxBatchSize: 10, FlushInterval: time.Second, MaxFlushWait: -1}, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, err := New(tt.cfg, func(_ context.Context, _ []testEvent) error { return nil })
|
||||||
|
if tt.wantErr {
|
||||||
|
assert.Error(t, err)
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriter_NilFlushFunc(t *testing.T) {
|
||||||
|
_, err := New[testEvent](DefaultConfig(), nil)
|
||||||
|
assert.ErrorIs(t, err, errNilFlushFunc)
|
||||||
|
}
|
||||||
@@ -11,8 +11,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/pkg/config"
|
"github.com/Rain-kl/Wavelet/pkg/config"
|
||||||
"github.com/Rain-kl/Wavelet/pkg/persistence"
|
"github.com/Rain-kl/Wavelet/plugins/infra/database"
|
||||||
|
|
||||||
"github.com/pressly/goose/v3"
|
"github.com/pressly/goose/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -59,7 +58,7 @@ func migrationDir() string {
|
|||||||
|
|
||||||
// Migrate 执行数据库迁移
|
// Migrate 执行数据库迁移
|
||||||
func Migrate() Report {
|
func Migrate() Report {
|
||||||
gormDB := db.DB(context.Background())
|
gormDB := database.DB(context.Background())
|
||||||
if gormDB == nil {
|
if gormDB == nil {
|
||||||
log.Fatalf("[%s] database not initialized\n", dbType())
|
log.Fatalf("[%s] database not initialized\n", dbType())
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/pkg/config"
|
"github.com/Rain-kl/Wavelet/pkg/config"
|
||||||
db "github.com/Rain-kl/Wavelet/pkg/persistence"
|
db "github.com/Rain-kl/Wavelet/plugins/infra/database"
|
||||||
"github.com/alicebob/miniredis/v2"
|
"github.com/alicebob/miniredis/v2"
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
@@ -1,491 +0,0 @@
|
|||||||
// Copyright 2026 Arctel.net
|
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
|
||||||
|
|
||||||
package batchwriter
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/go-cmp/cmp"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestNewRejectsInvalidConfig(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
_, err := New[int](Config{}, func(context.Context, []int) error { return nil })
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("New() = nil, want validation error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewRejectsNilFlushFunc(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
|
||||||
_, err := New[int](cfg, nil)
|
|
||||||
if !errors.Is(err, errNilFlushFunc) {
|
|
||||||
t.Fatalf("New() error = %v, want %v", err, errNilFlushFunc)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWriterFlushesOnMaxBatchSize(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var (
|
|
||||||
mu sync.Mutex
|
|
||||||
batches [][]int
|
|
||||||
)
|
|
||||||
cfg := DefaultConfig()
|
|
||||||
cfg.MaxBatchSize = 3
|
|
||||||
cfg.FlushInterval = time.Hour
|
|
||||||
|
|
||||||
writer, err := New[int](cfg, func(_ context.Context, items []int) error {
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
batches = append(batches, append([]int(nil), items...))
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("New() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
writer.Start(context.Background())
|
|
||||||
t.Cleanup(func() {
|
|
||||||
stopCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if err := writer.Stop(stopCtx); err != nil {
|
|
||||||
t.Fatalf("Stop() error = %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
for i := range 3 {
|
|
||||||
if !writer.TryEnqueue(i + 1) {
|
|
||||||
t.Fatalf("TryEnqueue(%d) = false, want true", i+1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deadline := time.Now().Add(time.Second)
|
|
||||||
for {
|
|
||||||
mu.Lock()
|
|
||||||
ready := len(batches) == 1
|
|
||||||
mu.Unlock()
|
|
||||||
if ready || time.Now().After(deadline) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
|
||||||
}
|
|
||||||
|
|
||||||
mu.Lock()
|
|
||||||
got := batches
|
|
||||||
mu.Unlock()
|
|
||||||
|
|
||||||
want := [][]int{{1, 2, 3}}
|
|
||||||
if diff := cmp.Diff(want, got); diff != "" {
|
|
||||||
t.Fatalf("flush batches mismatch (-want +got):\n%s", diff)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWriterFlushesOnInterval(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var (
|
|
||||||
mu sync.Mutex
|
|
||||||
batch []int
|
|
||||||
)
|
|
||||||
cfg := DefaultConfig()
|
|
||||||
cfg.MaxBatchSize = 100
|
|
||||||
cfg.MinBatchSize = 0
|
|
||||||
cfg.FlushInterval = 20 * time.Millisecond
|
|
||||||
|
|
||||||
writer, err := New[int](cfg, func(_ context.Context, items []int) error {
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
batch = append([]int(nil), items...)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("New() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
writer.Start(context.Background())
|
|
||||||
t.Cleanup(func() {
|
|
||||||
stopCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if err := writer.Stop(stopCtx); err != nil {
|
|
||||||
t.Fatalf("Stop() error = %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if !writer.TryEnqueue(42) {
|
|
||||||
t.Fatal("TryEnqueue() = false, want true")
|
|
||||||
}
|
|
||||||
|
|
||||||
deadline := time.Now().Add(time.Second)
|
|
||||||
for {
|
|
||||||
mu.Lock()
|
|
||||||
ready := len(batch) == 1
|
|
||||||
mu.Unlock()
|
|
||||||
if ready || time.Now().After(deadline) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
time.Sleep(5 * time.Millisecond)
|
|
||||||
}
|
|
||||||
|
|
||||||
mu.Lock()
|
|
||||||
got := batch
|
|
||||||
mu.Unlock()
|
|
||||||
|
|
||||||
want := []int{42}
|
|
||||||
if diff := cmp.Diff(want, got); diff != "" {
|
|
||||||
t.Fatalf("interval flush mismatch (-want +got):\n%s", diff)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWriterTryEnqueueDropsWhenFull(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
|
||||||
cfg.QueueSize = 1
|
|
||||||
cfg.MaxBatchSize = 10
|
|
||||||
cfg.FlushInterval = time.Hour
|
|
||||||
|
|
||||||
var dropped int
|
|
||||||
writer, err := New[int](cfg, func(context.Context, []int) error { return nil }, WithDropHandler[int](func(int) {
|
|
||||||
dropped++
|
|
||||||
}))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("New() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
writer.Start(context.Background())
|
|
||||||
t.Cleanup(func() {
|
|
||||||
stopCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
||||||
defer cancel()
|
|
||||||
_ = writer.Stop(stopCtx)
|
|
||||||
})
|
|
||||||
|
|
||||||
if !writer.TryEnqueue(1) {
|
|
||||||
t.Fatal("TryEnqueue(1) = false, want true")
|
|
||||||
}
|
|
||||||
if writer.TryEnqueue(2) {
|
|
||||||
t.Fatal("TryEnqueue(2) = true, want false")
|
|
||||||
}
|
|
||||||
if !writer.IsFull() {
|
|
||||||
t.Fatal("IsFull() = false, want true")
|
|
||||||
}
|
|
||||||
if dropped != 1 {
|
|
||||||
t.Fatalf("dropped = %d, want 1", dropped)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWriterStopDrainsQueuedItems(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
|
||||||
cfg.MaxBatchSize = 10
|
|
||||||
cfg.FlushInterval = time.Hour
|
|
||||||
|
|
||||||
var flushed []int
|
|
||||||
writer, err := New[int](cfg, func(_ context.Context, items []int) error {
|
|
||||||
flushed = append(flushed, items...)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("New() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
writer.Start(context.Background())
|
|
||||||
for i := range 2 {
|
|
||||||
if !writer.TryEnqueue(i + 1) {
|
|
||||||
t.Fatalf("TryEnqueue(%d) = false, want true", i+1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stopCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if err := writer.Stop(stopCtx); err != nil {
|
|
||||||
t.Fatalf("Stop() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
want := []int{1, 2}
|
|
||||||
if diff := cmp.Diff(want, flushed); diff != "" {
|
|
||||||
t.Fatalf("Stop() drain mismatch (-want +got):\n%s", diff)
|
|
||||||
}
|
|
||||||
if writer.Running() {
|
|
||||||
t.Fatal("Running() = true after Stop(), want false")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWriterInvokesFlushErrorHandler(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
|
||||||
cfg.MaxBatchSize = 1
|
|
||||||
cfg.FlushInterval = time.Hour
|
|
||||||
|
|
||||||
flushErr := errors.New("flush failed")
|
|
||||||
var (
|
|
||||||
mu sync.Mutex
|
|
||||||
errCount int
|
|
||||||
gotItems []int
|
|
||||||
)
|
|
||||||
|
|
||||||
writer, err := New[int](cfg, func(context.Context, []int) error {
|
|
||||||
return flushErr
|
|
||||||
}, WithFlushErrorHandler[int](func(_ context.Context, items []int, err error) {
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
errCount++
|
|
||||||
gotItems = append([]int(nil), items...)
|
|
||||||
if !errors.Is(err, flushErr) {
|
|
||||||
t.Errorf("flush error = %v, want %v", err, flushErr)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("New() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
writer.Start(context.Background())
|
|
||||||
t.Cleanup(func() {
|
|
||||||
stopCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
||||||
defer cancel()
|
|
||||||
_ = writer.Stop(stopCtx)
|
|
||||||
})
|
|
||||||
|
|
||||||
if !writer.TryEnqueue(7) {
|
|
||||||
t.Fatal("TryEnqueue() = false, want true")
|
|
||||||
}
|
|
||||||
|
|
||||||
deadline := time.Now().Add(time.Second)
|
|
||||||
for {
|
|
||||||
mu.Lock()
|
|
||||||
ready := errCount == 1
|
|
||||||
mu.Unlock()
|
|
||||||
if ready || time.Now().After(deadline) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
time.Sleep(5 * time.Millisecond)
|
|
||||||
}
|
|
||||||
|
|
||||||
mu.Lock()
|
|
||||||
gotCount := errCount
|
|
||||||
items := gotItems
|
|
||||||
mu.Unlock()
|
|
||||||
|
|
||||||
if gotCount != 1 {
|
|
||||||
t.Fatalf("flush error handler count = %d, want 1", gotCount)
|
|
||||||
}
|
|
||||||
if diff := cmp.Diff([]int{7}, items); diff != "" {
|
|
||||||
t.Fatalf("flush error handler items mismatch (-want +got):\n%s", diff)
|
|
||||||
}
|
|
||||||
|
|
||||||
stats := writer.Stats()
|
|
||||||
if stats.FlushErrors != 1 {
|
|
||||||
t.Fatalf("Stats().FlushErrors = %d, want 1", stats.FlushErrors)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWriterSkipsIntervalFlushBelowMinBatchSize(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var (
|
|
||||||
mu sync.Mutex
|
|
||||||
batch []int
|
|
||||||
)
|
|
||||||
cfg := DefaultConfig()
|
|
||||||
cfg.MaxBatchSize = 100
|
|
||||||
cfg.MinBatchSize = 5
|
|
||||||
cfg.FlushInterval = 20 * time.Millisecond
|
|
||||||
|
|
||||||
writer, err := New[int](cfg, func(_ context.Context, items []int) error {
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
batch = append([]int(nil), items...)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("New() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
writer.Start(context.Background())
|
|
||||||
t.Cleanup(func() {
|
|
||||||
stopCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if err := writer.Stop(stopCtx); err != nil {
|
|
||||||
t.Fatalf("Stop() error = %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
for i := range 3 {
|
|
||||||
if !writer.TryEnqueue(i + 1) {
|
|
||||||
t.Fatalf("TryEnqueue(%d) = false, want true", i+1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
|
|
||||||
mu.Lock()
|
|
||||||
got := batch
|
|
||||||
mu.Unlock()
|
|
||||||
|
|
||||||
if len(got) != 0 {
|
|
||||||
t.Fatalf("interval flush with below-min batch = %v, want no flush", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWriterFlushesOnIntervalWhenMinBatchSizeReached(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var (
|
|
||||||
mu sync.Mutex
|
|
||||||
batch []int
|
|
||||||
)
|
|
||||||
cfg := DefaultConfig()
|
|
||||||
cfg.MaxBatchSize = 100
|
|
||||||
cfg.MinBatchSize = 3
|
|
||||||
cfg.FlushInterval = 20 * time.Millisecond
|
|
||||||
|
|
||||||
writer, err := New[int](cfg, func(_ context.Context, items []int) error {
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
batch = append([]int(nil), items...)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("New() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
writer.Start(context.Background())
|
|
||||||
t.Cleanup(func() {
|
|
||||||
stopCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if err := writer.Stop(stopCtx); err != nil {
|
|
||||||
t.Fatalf("Stop() error = %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
for i := range 3 {
|
|
||||||
if !writer.TryEnqueue(i + 1) {
|
|
||||||
t.Fatalf("TryEnqueue(%d) = false, want true", i+1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deadline := time.Now().Add(time.Second)
|
|
||||||
for {
|
|
||||||
mu.Lock()
|
|
||||||
ready := len(batch) == 3
|
|
||||||
mu.Unlock()
|
|
||||||
if ready || time.Now().After(deadline) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
time.Sleep(5 * time.Millisecond)
|
|
||||||
}
|
|
||||||
|
|
||||||
mu.Lock()
|
|
||||||
got := batch
|
|
||||||
mu.Unlock()
|
|
||||||
|
|
||||||
want := []int{1, 2, 3}
|
|
||||||
if diff := cmp.Diff(want, got); diff != "" {
|
|
||||||
t.Fatalf("interval flush at min batch size mismatch (-want +got):\n%s", diff)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWriterForcesFlushAfterMaxFlushWait(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var (
|
|
||||||
mu sync.Mutex
|
|
||||||
batch []int
|
|
||||||
)
|
|
||||||
cfg := DefaultConfig()
|
|
||||||
cfg.MaxBatchSize = 100
|
|
||||||
cfg.MinBatchSize = 50
|
|
||||||
cfg.FlushInterval = 20 * time.Millisecond
|
|
||||||
cfg.MaxFlushWait = 80 * time.Millisecond
|
|
||||||
|
|
||||||
writer, err := New[int](cfg, func(_ context.Context, items []int) error {
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
batch = append([]int(nil), items...)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("New() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
writer.Start(context.Background())
|
|
||||||
t.Cleanup(func() {
|
|
||||||
stopCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if err := writer.Stop(stopCtx); err != nil {
|
|
||||||
t.Fatalf("Stop() error = %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if !writer.TryEnqueue(1) {
|
|
||||||
t.Fatal("TryEnqueue(1) = false, want true")
|
|
||||||
}
|
|
||||||
|
|
||||||
deadline := time.Now().Add(time.Second)
|
|
||||||
for {
|
|
||||||
mu.Lock()
|
|
||||||
ready := len(batch) == 1
|
|
||||||
mu.Unlock()
|
|
||||||
if ready || time.Now().After(deadline) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
time.Sleep(5 * time.Millisecond)
|
|
||||||
}
|
|
||||||
|
|
||||||
mu.Lock()
|
|
||||||
got := batch
|
|
||||||
mu.Unlock()
|
|
||||||
if diff := cmp.Diff([]int{1}, got); diff != "" {
|
|
||||||
t.Fatalf("max flush wait mismatch (-want +got):\n%s", diff)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWriterStatsTracksDrops(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
|
||||||
cfg.Name = "test-drops"
|
|
||||||
cfg.QueueSize = 1
|
|
||||||
cfg.MaxBatchSize = 10
|
|
||||||
cfg.FlushInterval = time.Hour
|
|
||||||
|
|
||||||
writer, err := New[int](cfg, func(context.Context, []int) error { return nil })
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("New() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
writer.Start(context.Background())
|
|
||||||
t.Cleanup(func() {
|
|
||||||
stopCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
||||||
defer cancel()
|
|
||||||
_ = writer.Stop(stopCtx)
|
|
||||||
})
|
|
||||||
|
|
||||||
if !writer.TryEnqueue(1) {
|
|
||||||
t.Fatal("TryEnqueue(1) = false, want true")
|
|
||||||
}
|
|
||||||
if writer.TryEnqueue(2) {
|
|
||||||
t.Fatal("TryEnqueue(2) = true, want false")
|
|
||||||
}
|
|
||||||
|
|
||||||
stats := writer.Stats()
|
|
||||||
if stats.Drops != 1 {
|
|
||||||
t.Fatalf("Stats().Drops = %d, want 1", stats.Drops)
|
|
||||||
}
|
|
||||||
if stats.Cap != 1 {
|
|
||||||
t.Fatalf("Stats().Cap = %d, want 1", stats.Cap)
|
|
||||||
}
|
|
||||||
if !stats.Running {
|
|
||||||
t.Fatal("Stats().Running = false, want true")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
// Copyright 2025 linux.do
|
|
||||||
// Copyright 2026 Arctel.net
|
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
|
||||||
|
|
||||||
// Package shared 提供跨模块共享的常量、错误定义和通用类型。
|
|
||||||
package shared
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
// Copyright 2025 linux.do
|
|
||||||
// Copyright 2026 Arctel.net
|
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
|
||||||
|
|
||||||
package shared
|
|
||||||
|
|
||||||
// 通用业务错误消息常量
|
|
||||||
const (
|
|
||||||
BannedAccount = "账号已被封禁"
|
|
||||||
UnAuthorized = "未登录"
|
|
||||||
)
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
// Copyright 2026 Arctel.net
|
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
|
||||||
|
|
||||||
package task_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/pkg/task"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestDuplicateTaskMeta(t *testing.T) {
|
|
||||||
dummyMeta := task.TaskMeta{
|
|
||||||
Type: "test_duplicate_task",
|
|
||||||
AsynqTask: "test:duplicate_task",
|
|
||||||
Name: "Test Duplicate Task",
|
|
||||||
}
|
|
||||||
|
|
||||||
task.RegisterTaskMeta(dummyMeta)
|
|
||||||
task.RegisterTaskMeta(dummyMeta)
|
|
||||||
|
|
||||||
metas := task.GetDispatchableTasks()
|
|
||||||
|
|
||||||
// Check if we have duplicates by checking if a Type appears more than once
|
|
||||||
seen := make(map[string]int)
|
|
||||||
for _, m := range metas {
|
|
||||||
seen[m.Type]++
|
|
||||||
}
|
|
||||||
|
|
||||||
for taskType, count := range seen {
|
|
||||||
if count > 1 {
|
|
||||||
t.Errorf("Task type %q registered %d times, expected at most 1", taskType, count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
// Copyright 2026 Arctel.net
|
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
|
||||||
|
|
||||||
package task
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/hibiken/asynq"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestPermanentErrorSkipsRetryWithoutExposingAsynqMessage(t *testing.T) {
|
|
||||||
err := PermanentError(" 来源配置无效 ")
|
|
||||||
|
|
||||||
assert.True(t, errors.Is(err, asynq.SkipRetry))
|
|
||||||
assert.Equal(t, "来源配置无效", err.Error())
|
|
||||||
assert.NotContains(t, err.Error(), asynq.SkipRetry.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPermanentErrorUsesSafeFallbackForBlankMessage(t *testing.T) {
|
|
||||||
err := PermanentError(" ")
|
|
||||||
|
|
||||||
assert.True(t, errors.Is(err, asynq.SkipRetry))
|
|
||||||
assert.Equal(t, defaultPermanentErrorMessage, err.Error())
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
// Copyright 2026 Arctel.net
|
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
|
||||||
|
|
||||||
package task
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/pkg/config"
|
|
||||||
"github.com/redis/go-redis/v9"
|
|
||||||
"github.com/redis/go-redis/v9/maintnotifications"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestNewRedisConnOptConfiguresMaintenanceNotifications(t *testing.T) {
|
|
||||||
previous := config.Config.Redis
|
|
||||||
t.Cleanup(func() { config.Config.Redis = previous })
|
|
||||||
|
|
||||||
for _, test := range []struct {
|
|
||||||
name string
|
|
||||||
enabled bool
|
|
||||||
want maintnotifications.Mode
|
|
||||||
}{
|
|
||||||
{name: "disabled by default", enabled: false, want: maintnotifications.ModeDisabled},
|
|
||||||
{name: "auto when enabled", enabled: true, want: maintnotifications.ModeAuto},
|
|
||||||
} {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
config.Config.Redis.MaintNotifications = test.enabled
|
|
||||||
|
|
||||||
t.Run("standalone", func(t *testing.T) {
|
|
||||||
config.Config.Redis.ClusterMode = false
|
|
||||||
config.Config.Redis.MasterName = ""
|
|
||||||
config.Config.Redis.Addrs = []string{"127.0.0.1:6379"}
|
|
||||||
|
|
||||||
client, ok := NewRedisConnOpt().MakeRedisClient().(*redis.Client)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("standalone option did not create *redis.Client")
|
|
||||||
}
|
|
||||||
defer func() { _ = client.Close() }()
|
|
||||||
assertMaintenanceNotificationsMode(t, client.Options().MaintNotificationsConfig, test.want)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("cluster", func(t *testing.T) {
|
|
||||||
config.Config.Redis.ClusterMode = true
|
|
||||||
config.Config.Redis.MasterName = ""
|
|
||||||
config.Config.Redis.Addrs = []string{"127.0.0.1:6379"}
|
|
||||||
|
|
||||||
client, ok := NewRedisConnOpt().MakeRedisClient().(*redis.ClusterClient)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("cluster option did not create *redis.ClusterClient")
|
|
||||||
}
|
|
||||||
defer func() { _ = client.Close() }()
|
|
||||||
assertMaintenanceNotificationsMode(t, client.Options().MaintNotificationsConfig, test.want)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("sentinel", func(t *testing.T) {
|
|
||||||
config.Config.Redis.ClusterMode = false
|
|
||||||
config.Config.Redis.MasterName = "openflare"
|
|
||||||
config.Config.Redis.Addrs = []string{"127.0.0.1:26379"}
|
|
||||||
|
|
||||||
client, ok := NewRedisConnOpt().MakeRedisClient().(*redis.Client)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("sentinel option did not create *redis.Client")
|
|
||||||
}
|
|
||||||
defer func() { _ = client.Close() }()
|
|
||||||
assertMaintenanceNotificationsMode(t, client.Options().MaintNotificationsConfig, test.want)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func assertMaintenanceNotificationsMode(t *testing.T, cfg *maintnotifications.Config, want maintnotifications.Mode) {
|
|
||||||
t.Helper()
|
|
||||||
if cfg == nil || cfg.Mode != want {
|
|
||||||
t.Fatalf("maintenance notifications mode = %v, want %v", cfg, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
// Copyright 2025 linux.do
|
|
||||||
// Copyright 2026 Arctel.net
|
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
|
||||||
|
|
||||||
// Package worker 提供 Asynq 任务处理服务器与中间件
|
|
||||||
package worker
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"github.com/hibiken/asynq"
|
|
||||||
)
|
|
||||||
|
|
||||||
// taskLoggingMiddleware 任务处理中间件
|
|
||||||
// 注意:OTel Span 创建、日志记录、TaskExecution 状态管理
|
|
||||||
// 已由 task.ProcessTask 统一处理,此中间件保留用于未来扩展(如限流、监控等)
|
|
||||||
func taskLoggingMiddleware(h asynq.Handler) asynq.Handler {
|
|
||||||
return asynq.HandlerFunc(func(ctx context.Context, t *asynq.Task) error {
|
|
||||||
return h.ProcessTask(ctx, t)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
// Copyright 2025 linux.do
|
|
||||||
// Copyright 2026 Arctel.net
|
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
|
||||||
|
|
||||||
package worker
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/pkg/config"
|
|
||||||
"github.com/Rain-kl/Wavelet/pkg/task"
|
|
||||||
|
|
||||||
"github.com/hibiken/asynq"
|
|
||||||
)
|
|
||||||
|
|
||||||
// workerShutdownTimeout Worker 优雅关闭超时时间
|
|
||||||
const workerShutdownTimeout = 3 * time.Minute
|
|
||||||
|
|
||||||
// StartWorker 启动任务处理服务器
|
|
||||||
func StartWorker() error {
|
|
||||||
|
|
||||||
asynqServer := asynq.NewServer(
|
|
||||||
task.RedisOpt,
|
|
||||||
asynq.Config{
|
|
||||||
Concurrency: config.Config.Worker.Concurrency,
|
|
||||||
ShutdownTimeout: workerShutdownTimeout,
|
|
||||||
Queues: BuildQueuesFromConfig(),
|
|
||||||
StrictPriority: config.Config.Worker.StrictPriority,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// 注册 Asynq 任务路由
|
|
||||||
mux := asynq.NewServeMux()
|
|
||||||
mux.Use(taskLoggingMiddleware)
|
|
||||||
|
|
||||||
// 统一使用 task.ProcessTask 处理所有任务类型
|
|
||||||
// 框架内部自动分发到对应的 TaskHandler 实现
|
|
||||||
// 动态注册所有已注册的任务处理器路由,框架内部自动分发到对应的 TaskHandler 实现
|
|
||||||
for _, taskName := range task.GetRegisteredAsynqTasks() {
|
|
||||||
mux.HandleFunc(taskName, task.ProcessTask)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 启动服务器
|
|
||||||
return asynqServer.Run(mux)
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartWorkerServer 异步启动 Asynq 工作器服务并返回 Server 实例以支持平滑停机
|
|
||||||
func StartWorkerServer() (*asynq.Server, error) {
|
|
||||||
|
|
||||||
asynqServer := asynq.NewServer(
|
|
||||||
task.RedisOpt,
|
|
||||||
asynq.Config{
|
|
||||||
Concurrency: config.Config.Worker.Concurrency,
|
|
||||||
ShutdownTimeout: workerShutdownTimeout,
|
|
||||||
Queues: BuildQueuesFromConfig(),
|
|
||||||
StrictPriority: config.Config.Worker.StrictPriority,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
mux := asynq.NewServeMux()
|
|
||||||
mux.Use(taskLoggingMiddleware)
|
|
||||||
|
|
||||||
for _, taskName := range task.GetRegisteredAsynqTasks() {
|
|
||||||
mux.HandleFunc(taskName, task.ProcessTask)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := asynqServer.Start(mux); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return asynqServer, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// BuildQueuesFromConfig 从配置构建队列映射
|
|
||||||
func BuildQueuesFromConfig() map[string]int {
|
|
||||||
queues := make(map[string]int)
|
|
||||||
|
|
||||||
// 从配置读取队列
|
|
||||||
if len(config.Config.Worker.Queues) > 0 {
|
|
||||||
for _, q := range config.Config.Worker.Queues {
|
|
||||||
if q.Name != "" && q.Priority > 0 {
|
|
||||||
queues[q.Name] = q.Priority
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果配置为空,使用默认队列
|
|
||||||
if len(queues) == 0 {
|
|
||||||
queues = map[string]int{
|
|
||||||
task.QueueDefault: 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return queues
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
db "github.com/Rain-kl/Wavelet/pkg/persistence"
|
cachepkg "github.com/Rain-kl/Wavelet/plugins/infra/cache"
|
||||||
|
db "github.com/Rain-kl/Wavelet/plugins/infra/database"
|
||||||
"github.com/alicebob/miniredis/v2"
|
"github.com/alicebob/miniredis/v2"
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
@@ -249,7 +250,7 @@ func SetupTestEnvironment(t *testing.T) (*gorm.DB, *miniredis.Miniredis, func())
|
|||||||
})
|
})
|
||||||
|
|
||||||
db.SetDB(sqliteDB)
|
db.SetDB(sqliteDB)
|
||||||
db.Redis = redisClient
|
cachepkg.Redis = redisClient
|
||||||
|
|
||||||
seedDefaultConfigs(t, sqliteDB)
|
seedDefaultConfigs(t, sqliteDB)
|
||||||
|
|
||||||
@@ -258,7 +259,7 @@ func SetupTestEnvironment(t *testing.T) (*gorm.DB, *miniredis.Miniredis, func())
|
|||||||
_ = redisClient.Close()
|
_ = redisClient.Close()
|
||||||
mr.Close()
|
mr.Close()
|
||||||
db.SetDB(nil)
|
db.SetDB(nil)
|
||||||
db.Redis = nil
|
cachepkg.Redis = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return sqliteDB, mr, cleanup
|
return sqliteDB, mr, cleanup
|
||||||
@@ -327,6 +328,6 @@ func seedDefaultConfigs(t *testing.T, tx *gorm.DB) {
|
|||||||
if _, ok := publicKeys[config.Key]; ok {
|
if _, ok := publicKeys[config.Key]; ok {
|
||||||
config.Visibility = "visible"
|
config.Visibility = "visible"
|
||||||
}
|
}
|
||||||
_ = db.HSetJSON(context.Background(), "system_configs", config.Key, &config)
|
_ = cachepkg.HSetJSON(context.Background(), "system_configs", config.Key, &config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,16 +7,16 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
persistence "github.com/Rain-kl/Wavelet/pkg/persistence"
|
|
||||||
"github.com/Rain-kl/Wavelet/pkg/response"
|
"github.com/Rain-kl/Wavelet/pkg/response"
|
||||||
"github.com/Rain-kl/Wavelet/plugins/domain/auth"
|
"github.com/Rain-kl/Wavelet/plugins/domain/auth"
|
||||||
|
"github.com/Rain-kl/Wavelet/plugins/infra/database"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ListAuthSources lists all configured authentication sources.
|
// ListAuthSources lists all configured authentication sources.
|
||||||
func ListAuthSources(c *gin.Context) {
|
func ListAuthSources(c *gin.Context) {
|
||||||
var sources []auth.AuthSource
|
var sources []auth.AuthSource
|
||||||
gormDB := persistence.DB(c.Request.Context())
|
gormDB := database.DB(c.Request.Context())
|
||||||
if err := gormDB.Order("id ASC").Find(&sources).Error; err != nil {
|
if err := gormDB.Order("id ASC").Find(&sources).Error; err != nil {
|
||||||
response.AbortInternal(c, "获取认证源列表失败")
|
response.AbortInternal(c, "获取认证源列表失败")
|
||||||
return
|
return
|
||||||
@@ -51,7 +51,7 @@ func CreateAuthSource(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
gormDB := persistence.DB(c.Request.Context())
|
gormDB := database.DB(c.Request.Context())
|
||||||
if err := gormDB.Create(&source).Error; err != nil {
|
if err := gormDB.Create(&source).Error; err != nil {
|
||||||
response.AbortBadRequest(c, "创建认证源失败: "+err.Error())
|
response.AbortBadRequest(c, "创建认证源失败: "+err.Error())
|
||||||
return
|
return
|
||||||
@@ -70,7 +70,7 @@ func UpdateAuthSource(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
gormDB := persistence.DB(c.Request.Context())
|
gormDB := database.DB(c.Request.Context())
|
||||||
var existing auth.AuthSource
|
var existing auth.AuthSource
|
||||||
if err := gormDB.First(&existing, id).Error; err != nil {
|
if err := gormDB.First(&existing, id).Error; err != nil {
|
||||||
response.AbortNotFound(c, "认证源不存在")
|
response.AbortNotFound(c, "认证源不存在")
|
||||||
@@ -115,7 +115,7 @@ func ToggleAuthSource(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
gormDB := persistence.DB(c.Request.Context())
|
gormDB := database.DB(c.Request.Context())
|
||||||
var existing auth.AuthSource
|
var existing auth.AuthSource
|
||||||
if err := gormDB.First(&existing, id).Error; err != nil {
|
if err := gormDB.First(&existing, id).Error; err != nil {
|
||||||
response.AbortNotFound(c, "认证源不存在")
|
response.AbortNotFound(c, "认证源不存在")
|
||||||
@@ -147,7 +147,7 @@ func DeleteAuthSource(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
gormDB := persistence.DB(c.Request.Context())
|
gormDB := database.DB(c.Request.Context())
|
||||||
if err := gormDB.Delete(&auth.AuthSource{}, id).Error; err != nil {
|
if err := gormDB.Delete(&auth.AuthSource{}, id).Error; err != nil {
|
||||||
response.AbortInternal(c, "删除认证源失败")
|
response.AbortInternal(c, "删除认证源失败")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ import (
|
|||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/pkg/logger"
|
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||||
mail "github.com/Rain-kl/Wavelet/pkg/mail"
|
mail "github.com/Rain-kl/Wavelet/pkg/mail"
|
||||||
db "github.com/Rain-kl/Wavelet/pkg/persistence"
|
|
||||||
"github.com/Rain-kl/Wavelet/pkg/response"
|
"github.com/Rain-kl/Wavelet/pkg/response"
|
||||||
"github.com/Rain-kl/Wavelet/plugins/domain/cap"
|
"github.com/Rain-kl/Wavelet/plugins/domain/cap"
|
||||||
|
cachepkg "github.com/Rain-kl/Wavelet/plugins/infra/cache"
|
||||||
|
db "github.com/Rain-kl/Wavelet/plugins/infra/database"
|
||||||
"github.com/Rain-kl/Wavelet/plugins/infra/storage/objectstore"
|
"github.com/Rain-kl/Wavelet/plugins/infra/storage/objectstore"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -350,15 +351,15 @@ func invalidateCachesAfterConfigUpdate(ctx context.Context, key string) {
|
|||||||
invalidateSystemConfigCaches(ctx, key)
|
invalidateSystemConfigCaches(ctx, key)
|
||||||
|
|
||||||
if key == ConfigKeyStorageConfig {
|
if key == ConfigKeyStorageConfig {
|
||||||
if db.Redis != nil {
|
if cachepkg.Redis != nil {
|
||||||
_ = db.Redis.Publish(ctx, "upload:access_cache:invalidate", "reset").Err()
|
_ = cachepkg.Redis.Publish(ctx, "upload:access_cache:invalidate", "reset").Err()
|
||||||
}
|
}
|
||||||
objectstore.ResetCache()
|
objectstore.ResetCache()
|
||||||
objectstore.PublishCacheInvalidation(ctx)
|
objectstore.PublishCacheInvalidation(ctx)
|
||||||
}
|
}
|
||||||
if key == ConfigKeyFileAccessWhitelist {
|
if key == ConfigKeyFileAccessWhitelist {
|
||||||
if db.Redis != nil {
|
if cachepkg.Redis != nil {
|
||||||
_ = db.Redis.Publish(ctx, "upload:access_cache:invalidate", "reset").Err()
|
_ = cachepkg.Redis.Publish(ctx, "upload:access_cache:invalidate", "reset").Err()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/pkg/config"
|
"github.com/Rain-kl/Wavelet/pkg/config"
|
||||||
db "github.com/Rain-kl/Wavelet/pkg/persistence"
|
|
||||||
"github.com/Rain-kl/Wavelet/pkg/response"
|
"github.com/Rain-kl/Wavelet/pkg/response"
|
||||||
|
db "github.com/Rain-kl/Wavelet/plugins/infra/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ import (
|
|||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/pkg/config"
|
"github.com/Rain-kl/Wavelet/pkg/config"
|
||||||
"github.com/Rain-kl/Wavelet/pkg/logger"
|
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||||
db "github.com/Rain-kl/Wavelet/pkg/persistence"
|
|
||||||
"github.com/Rain-kl/Wavelet/pkg/persistence/logstore"
|
|
||||||
"github.com/Rain-kl/Wavelet/pkg/response"
|
"github.com/Rain-kl/Wavelet/pkg/response"
|
||||||
"github.com/Rain-kl/Wavelet/pkg/task"
|
|
||||||
"github.com/Rain-kl/Wavelet/pkg/util"
|
"github.com/Rain-kl/Wavelet/pkg/util"
|
||||||
"github.com/Rain-kl/Wavelet/plugins/domain/risk_control"
|
"github.com/Rain-kl/Wavelet/plugins/domain/risk_control"
|
||||||
|
"github.com/Rain-kl/Wavelet/plugins/domain/risk_control/logstore"
|
||||||
|
db "github.com/Rain-kl/Wavelet/plugins/infra/database"
|
||||||
|
"github.com/Rain-kl/Wavelet/plugins/drivers/driver_asynq_worker"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
)
|
)
|
||||||
@@ -497,16 +497,16 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// LogDBSwitchMeta 描述切换日志数据库任务。
|
// LogDBSwitchMeta 描述切换日志数据库任务。
|
||||||
var LogDBSwitchMeta = task.TaskMeta{
|
var LogDBSwitchMeta = driver_asynq_worker.TaskMeta{
|
||||||
Type: TaskTypeLogDBSwitch,
|
Type: TaskTypeLogDBSwitch,
|
||||||
AsynqTask: LogDBSwitchTask,
|
AsynqTask: LogDBSwitchTask,
|
||||||
Name: "切换日志数据库",
|
Name: "切换日志数据库",
|
||||||
Description: "复制迁移用户访问日志并在成功后切换日志主库(期间禁止日志写入)",
|
Description: "复制迁移用户访问日志并在成功后切换日志主库(期间禁止日志写入)",
|
||||||
SupportsTime: false,
|
SupportsTime: false,
|
||||||
MaxRetry: task.DefaultMaxRetry,
|
MaxRetry: driver_asynq_worker.DefaultMaxRetry,
|
||||||
Queue: task.QueueDefault,
|
Queue: driver_asynq_worker.QueueDefault,
|
||||||
Retryable: true,
|
Retryable: true,
|
||||||
Params: []task.TaskParam{
|
Params: []driver_asynq_worker.TaskParam{
|
||||||
{Name: "target", Label: "目标日志库", Type: "string", Required: true,
|
{Name: "target", Label: "目标日志库", Type: "string", Required: true,
|
||||||
Placeholder: "postgres|sqlite|clickhouse", Description: "迁移目标:postgres(主库为 PG 时)、sqlite(主库为 SQLite 时)或 clickhouse"},
|
Placeholder: "postgres|sqlite|clickhouse", Description: "迁移目标:postgres(主库为 PG 时)、sqlite(主库为 SQLite 时)或 clickhouse"},
|
||||||
},
|
},
|
||||||
@@ -553,7 +553,7 @@ func validTarget(v string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Execute 执行迁移。
|
// Execute 执行迁移。
|
||||||
func (h *LogDBSwitchHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) {
|
func (h *LogDBSwitchHandler) Execute(ctx context.Context, payload []byte) (*driver_asynq_worker.TaskResult, error) {
|
||||||
var p logDBSwitchPayload
|
var p logDBSwitchPayload
|
||||||
if err := json.Unmarshal(payload, &p); err != nil {
|
if err := json.Unmarshal(payload, &p); err != nil {
|
||||||
return nil, fmt.Errorf("参数解析失败: %w", err)
|
return nil, fmt.Errorf("参数解析失败: %w", err)
|
||||||
@@ -565,10 +565,10 @@ func (h *LogDBSwitchHandler) Execute(ctx context.Context, payload []byte) (*task
|
|||||||
|
|
||||||
source, err := currentLogDatabase(ctx)
|
source, err := currentLogDatabase(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
task.AppendLog(ctx, "读取日志主库失败: %v", err)
|
driver_asynq_worker.AppendLog(ctx, "读取日志主库失败: %v", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
task.AppendLog(ctx, "开始切换日志数据库:%s -> %s", source, p.Target)
|
driver_asynq_worker.AppendLog(ctx, "开始切换日志数据库:%s -> %s", source, p.Target)
|
||||||
|
|
||||||
if err := setMigrationFlag(ctx, "migrating"); err != nil {
|
if err := setMigrationFlag(ctx, "migrating"); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -612,8 +612,8 @@ func (h *LogDBSwitchHandler) Execute(ctx context.Context, payload []byte) (*task
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
logstore.InvalidateCache()
|
logstore.InvalidateCache()
|
||||||
task.AppendLog(ctx, "日志数据库已切换为 %s,写入恢复", p.Target)
|
driver_asynq_worker.AppendLog(ctx, "日志数据库已切换为 %s,写入恢复", p.Target)
|
||||||
return &task.TaskResult{Message: fmt.Sprintf("日志数据库已从 %s 切换为 %s", source, p.Target)}, nil
|
return &driver_asynq_worker.TaskResult{Message: fmt.Sprintf("日志数据库已从 %s 切换为 %s", source, p.Target)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateSwitch(ctx context.Context, target string) error {
|
func validateSwitch(ctx context.Context, target string) error {
|
||||||
@@ -676,7 +676,7 @@ func copyUserAccessLogs(ctx context.Context, src, dst *logstore.Store) error {
|
|||||||
}
|
}
|
||||||
afterID = rows[len(rows)-1].ID
|
afterID = rows[len(rows)-1].ID
|
||||||
copied += len(rows)
|
copied += len(rows)
|
||||||
task.AppendLog(ctx, "已复制用户访问日志 %d 条", copied)
|
driver_asynq_worker.AppendLog(ctx, "已复制用户访问日志 %d 条", copied)
|
||||||
if len(rows) < copyBatchSize {
|
if len(rows) < copyBatchSize {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import (
|
|||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/pkg/config"
|
"github.com/Rain-kl/Wavelet/pkg/config"
|
||||||
"github.com/Rain-kl/Wavelet/pkg/logger"
|
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||||
"github.com/Rain-kl/Wavelet/pkg/persistence/logstore"
|
|
||||||
"github.com/Rain-kl/Wavelet/pkg/response"
|
"github.com/Rain-kl/Wavelet/pkg/response"
|
||||||
|
"github.com/Rain-kl/Wavelet/plugins/domain/risk_control/logstore"
|
||||||
)
|
)
|
||||||
|
|
||||||
var startTime = time.Now()
|
var startTime = time.Now()
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import (
|
|||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/pkg/logger"
|
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||||
"github.com/Rain-kl/Wavelet/pkg/response"
|
"github.com/Rain-kl/Wavelet/pkg/response"
|
||||||
"github.com/Rain-kl/Wavelet/pkg/task"
|
"github.com/Rain-kl/Wavelet/plugins/drivers/driver_asynq_cron"
|
||||||
"github.com/Rain-kl/Wavelet/pkg/task/scheduler"
|
"github.com/Rain-kl/Wavelet/plugins/drivers/driver_asynq_worker"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ListTaskTypes 获取支持的任务类型列表
|
// ListTaskTypes 获取支持的任务类型列表
|
||||||
@@ -26,12 +26,12 @@ import (
|
|||||||
// @Tags admin
|
// @Tags admin
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Security SessionCookie
|
// @Security SessionCookie
|
||||||
// @Success 200 {object} response.Any{data=[]task.TaskMeta} "任务类型列表"
|
// @Success 200 {object} response.Any{data=[]driver_asynq_worker.TaskMeta} "任务类型列表"
|
||||||
// @Failure 401 {object} response.Any "未登录"
|
// @Failure 401 {object} response.Any "未登录"
|
||||||
// @Failure 403 {object} response.Any "无管理员权限"
|
// @Failure 403 {object} response.Any "无管理员权限"
|
||||||
// @Router /api/v1/admin/tasks/types [get]
|
// @Router /api/v1/admin/tasks/types [get]
|
||||||
func ListTaskTypes(c *gin.Context) {
|
func ListTaskTypes(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, response.OK(task.GetDispatchableTasks()))
|
c.JSON(http.StatusOK, response.OK(driver_asynq_worker.GetDispatchableTasks()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// DispatchTaskRequest 下发任务请求
|
// DispatchTaskRequest 下发任务请求
|
||||||
@@ -64,7 +64,7 @@ func DispatchTask(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
meta := task.GetTaskMeta(req.TaskType)
|
meta := driver_asynq_worker.GetTaskMeta(req.TaskType)
|
||||||
if meta == nil {
|
if meta == nil {
|
||||||
response.AbortBadRequest(c, InvalidTaskType)
|
response.AbortBadRequest(c, InvalidTaskType)
|
||||||
return
|
return
|
||||||
@@ -75,13 +75,13 @@ func DispatchTask(c *gin.Context) {
|
|||||||
payloadBytes = []byte(req.Payload)
|
payloadBytes = []byte(req.Payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
validated, err := task.ValidateAndNormalizePayload(meta.AsynqTask, payloadBytes)
|
validated, err := driver_asynq_worker.ValidateAndNormalizePayload(meta.AsynqTask, payloadBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.AbortBadRequest(c, err.Error())
|
response.AbortBadRequest(c, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
taskID, err := task.DispatchTask(c.Request.Context(), req.TaskType, validated, "manual")
|
taskID, err := driver_asynq_worker.DispatchTask(c.Request.Context(), req.TaskType, validated, "manual")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.AbortInternal(c, fmt.Sprintf("%s: %v", TaskDispatchFailed, err))
|
response.AbortInternal(c, fmt.Sprintf("%s: %v", TaskDispatchFailed, err))
|
||||||
return
|
return
|
||||||
@@ -112,7 +112,7 @@ func ListTaskExecutions(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if req.TaskType != "" {
|
if req.TaskType != "" {
|
||||||
if meta := task.GetTaskMeta(req.TaskType); meta != nil {
|
if meta := driver_asynq_worker.GetTaskMeta(req.TaskType); meta != nil {
|
||||||
req.TaskType = meta.AsynqTask
|
req.TaskType = meta.AsynqTask
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,7 +181,7 @@ func RetryTask(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
newTaskID, err := task.RetryTask(c.Request.Context(), id)
|
newTaskID, err := driver_asynq_worker.RetryTask(c.Request.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errMsg := err.Error()
|
errMsg := err.Error()
|
||||||
switch {
|
switch {
|
||||||
@@ -254,7 +254,7 @@ func CreateSchedule(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 校验关联的异步任务类型
|
// 校验关联的异步任务类型
|
||||||
meta := task.GetTaskMeta(req.TaskType)
|
meta := driver_asynq_worker.GetTaskMeta(req.TaskType)
|
||||||
if meta == nil {
|
if meta == nil {
|
||||||
response.AbortBadRequest(c, InvalidTaskType)
|
response.AbortBadRequest(c, InvalidTaskType)
|
||||||
return
|
return
|
||||||
@@ -265,7 +265,7 @@ func CreateSchedule(c *gin.Context) {
|
|||||||
if strings.TrimSpace(req.Payload) != "" {
|
if strings.TrimSpace(req.Payload) != "" {
|
||||||
payloadBytes = []byte(req.Payload)
|
payloadBytes = []byte(req.Payload)
|
||||||
}
|
}
|
||||||
validated, err := task.ValidateAndNormalizePayload(meta.AsynqTask, payloadBytes)
|
validated, err := driver_asynq_worker.ValidateAndNormalizePayload(meta.AsynqTask, payloadBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.AbortBadRequest(c, err.Error())
|
response.AbortBadRequest(c, err.Error())
|
||||||
return
|
return
|
||||||
@@ -285,7 +285,7 @@ func CreateSchedule(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 触发调度服务重载
|
// 触发调度服务重载
|
||||||
if err := scheduler.ReloadScheduler(); err != nil {
|
if err := driver_asynq_cron.ReloadScheduler(); err != nil {
|
||||||
logger.ErrorF(c.Request.Context(), "[TaskAdmin] 重载调度器失败: %v", err)
|
logger.ErrorF(c.Request.Context(), "[TaskAdmin] 重载调度器失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,7 +343,7 @@ func UpdateSchedule(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 校验关联的异步任务类型
|
// 校验关联的异步任务类型
|
||||||
meta := task.GetTaskMeta(req.TaskType)
|
meta := driver_asynq_worker.GetTaskMeta(req.TaskType)
|
||||||
if meta == nil {
|
if meta == nil {
|
||||||
response.AbortBadRequest(c, InvalidTaskType)
|
response.AbortBadRequest(c, InvalidTaskType)
|
||||||
return
|
return
|
||||||
@@ -354,7 +354,7 @@ func UpdateSchedule(c *gin.Context) {
|
|||||||
if strings.TrimSpace(req.Payload) != "" {
|
if strings.TrimSpace(req.Payload) != "" {
|
||||||
payloadBytes = []byte(req.Payload)
|
payloadBytes = []byte(req.Payload)
|
||||||
}
|
}
|
||||||
validated, err := task.ValidateAndNormalizePayload(meta.AsynqTask, payloadBytes)
|
validated, err := driver_asynq_worker.ValidateAndNormalizePayload(meta.AsynqTask, payloadBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.AbortBadRequest(c, err.Error())
|
response.AbortBadRequest(c, err.Error())
|
||||||
return
|
return
|
||||||
@@ -372,7 +372,7 @@ func UpdateSchedule(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 触发调度服务重载
|
// 触发调度服务重载
|
||||||
if err := scheduler.ReloadScheduler(); err != nil {
|
if err := driver_asynq_cron.ReloadScheduler(); err != nil {
|
||||||
logger.ErrorF(c.Request.Context(), "[TaskAdmin] 重载调度器失败: %v", err)
|
logger.ErrorF(c.Request.Context(), "[TaskAdmin] 重载调度器失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,7 +405,7 @@ func DeleteSchedule(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 触发调度服务重载
|
// 触发调度服务重载
|
||||||
if err := scheduler.ReloadScheduler(); err != nil {
|
if err := driver_asynq_cron.ReloadScheduler(); err != nil {
|
||||||
logger.ErrorF(c.Request.Context(), "[TaskAdmin] 重载调度器失败: %v", err)
|
logger.ErrorF(c.Request.Context(), "[TaskAdmin] 重载调度器失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/core/contracts"
|
"github.com/Rain-kl/Wavelet/core/contracts"
|
||||||
|
"github.com/Rain-kl/Wavelet/pkg/idgen"
|
||||||
"github.com/Rain-kl/Wavelet/pkg/logger"
|
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||||
db "github.com/Rain-kl/Wavelet/pkg/persistence"
|
|
||||||
"github.com/Rain-kl/Wavelet/pkg/persistence/idgen"
|
|
||||||
"github.com/Rain-kl/Wavelet/pkg/response"
|
"github.com/Rain-kl/Wavelet/pkg/response"
|
||||||
"github.com/Rain-kl/Wavelet/pkg/util"
|
"github.com/Rain-kl/Wavelet/pkg/util"
|
||||||
"github.com/Rain-kl/Wavelet/plugins/domain/auth"
|
"github.com/Rain-kl/Wavelet/plugins/domain/auth"
|
||||||
|
db "github.com/Rain-kl/Wavelet/plugins/infra/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
const minPasswordLength = 8
|
const minPasswordLength = 8
|
||||||
|
|||||||
@@ -17,9 +17,10 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/pkg/cache/ram"
|
"github.com/Rain-kl/Wavelet/pkg/cache/ram"
|
||||||
db "github.com/Rain-kl/Wavelet/pkg/persistence"
|
"github.com/Rain-kl/Wavelet/pkg/idgen"
|
||||||
"github.com/Rain-kl/Wavelet/pkg/persistence/idgen"
|
|
||||||
"github.com/Rain-kl/Wavelet/pkg/util"
|
"github.com/Rain-kl/Wavelet/pkg/util"
|
||||||
|
cachepkg "github.com/Rain-kl/Wavelet/plugins/infra/cache"
|
||||||
|
db "github.com/Rain-kl/Wavelet/plugins/infra/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -482,7 +483,7 @@ func GetLatestTaskExecutionByTaskType(ctx context.Context, taskType string) (*Ta
|
|||||||
|
|
||||||
// AppendTaskExecutionLog 将日志追加到 Redis 缓冲,任务完成后再持久化到数据库。
|
// AppendTaskExecutionLog 将日志追加到 Redis 缓冲,任务完成后再持久化到数据库。
|
||||||
func AppendTaskExecutionLog(ctx context.Context, taskID string, logLine string) error {
|
func AppendTaskExecutionLog(ctx context.Context, taskID string, logLine string) error {
|
||||||
if db.Redis == nil {
|
if cachepkg.Redis == nil {
|
||||||
return errors.New("redis client is not initialized")
|
return errors.New("redis client is not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,7 +491,7 @@ func AppendTaskExecutionLog(ctx context.Context, taskID string, logLine string)
|
|||||||
line := fmt.Sprintf("[%s] %s\n", now, logLine)
|
line := fmt.Sprintf("[%s] %s\n", now, logLine)
|
||||||
key := taskExecutionLogRedisKey(taskID)
|
key := taskExecutionLogRedisKey(taskID)
|
||||||
|
|
||||||
_, err := db.Redis.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
|
_, err := cachepkg.Redis.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
|
||||||
pipe.RPush(ctx, key, line)
|
pipe.RPush(ctx, key, line)
|
||||||
pipe.LTrim(ctx, key, -taskExecutionLogMaxLines, -1)
|
pipe.LTrim(ctx, key, -taskExecutionLogMaxLines, -1)
|
||||||
pipe.Expire(ctx, key, taskExecutionLogExpiration)
|
pipe.Expire(ctx, key, taskExecutionLogExpiration)
|
||||||
@@ -504,12 +505,12 @@ func AppendTaskExecutionLog(ctx context.Context, taskID string, logLine string)
|
|||||||
|
|
||||||
// FlushTaskExecutionLog 将 Redis 中的完整任务日志写入数据库,并在成功后清理缓存。
|
// FlushTaskExecutionLog 将 Redis 中的完整任务日志写入数据库,并在成功后清理缓存。
|
||||||
func FlushTaskExecutionLog(ctx context.Context, taskID string) error {
|
func FlushTaskExecutionLog(ctx context.Context, taskID string) error {
|
||||||
if db.Redis == nil {
|
if cachepkg.Redis == nil {
|
||||||
return errors.New("redis client is not initialized")
|
return errors.New("redis client is not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
key := taskExecutionLogRedisKey(taskID)
|
key := taskExecutionLogRedisKey(taskID)
|
||||||
logLines, err := db.Redis.LRange(ctx, key, 0, -1).Result()
|
logLines, err := cachepkg.Redis.LRange(ctx, key, 0, -1).Result()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("get task execution log from redis: %w", err)
|
return fmt.Errorf("get task execution log from redis: %w", err)
|
||||||
}
|
}
|
||||||
@@ -528,7 +529,7 @@ func FlushTaskExecutionLog(ctx context.Context, taskID string) error {
|
|||||||
return fmt.Errorf("persist task execution log: task %q not found", taskID)
|
return fmt.Errorf("persist task execution log: task %q not found", taskID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.Redis.Del(ctx, key).Err(); err != nil {
|
if err := cachepkg.Redis.Del(ctx, key).Err(); err != nil {
|
||||||
return fmt.Errorf("delete persisted task execution log from redis: %w", err)
|
return fmt.Errorf("delete persisted task execution log from redis: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -658,15 +659,15 @@ func CleanupTaskExecutionLogs(ctx context.Context, now time.Time) (TaskExecution
|
|||||||
}
|
}
|
||||||
|
|
||||||
func taskExecutionLogRedisKey(taskID string) string {
|
func taskExecutionLogRedisKey(taskID string) string {
|
||||||
return db.PrefixedKey(taskExecutionLogRedisKeyPrefix + taskID)
|
return cachepkg.PrefixedKey(taskExecutionLogRedisKeyPrefix + taskID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadTaskExecutionLog(ctx context.Context, execution *TaskExecution) error {
|
func loadTaskExecutionLog(ctx context.Context, execution *TaskExecution) error {
|
||||||
if db.Redis == nil {
|
if cachepkg.Redis == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
logLines, err := db.Redis.LRange(ctx, taskExecutionLogRedisKey(execution.TaskID), 0, -1).Result()
|
logLines, err := cachepkg.Redis.LRange(ctx, taskExecutionLogRedisKey(execution.TaskID), 0, -1).Result()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("get task execution log from redis: %w", err)
|
return fmt.Errorf("get task execution log from redis: %w", err)
|
||||||
}
|
}
|
||||||
@@ -679,12 +680,12 @@ func loadTaskExecutionLog(ctx context.Context, execution *TaskExecution) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadTaskExecutionLogs(ctx context.Context, executions []TaskExecution) error {
|
func loadTaskExecutionLogs(ctx context.Context, executions []TaskExecution) error {
|
||||||
if db.Redis == nil || len(executions) == 0 {
|
if cachepkg.Redis == nil || len(executions) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
commands := make([]*redis.StringSliceCmd, len(executions))
|
commands := make([]*redis.StringSliceCmd, len(executions))
|
||||||
_, err := db.Redis.Pipelined(ctx, func(pipe redis.Pipeliner) error {
|
_, err := cachepkg.Redis.Pipelined(ctx, func(pipe redis.Pipeliner) error {
|
||||||
for i := range executions {
|
for i := range executions {
|
||||||
commands[i] = pipe.LRange(ctx, taskExecutionLogRedisKey(executions[i].TaskID), 0, -1)
|
commands[i] = pipe.LRange(ctx, taskExecutionLogRedisKey(executions[i].TaskID), 0, -1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/pkg/cache/ram"
|
"github.com/Rain-kl/Wavelet/pkg/cache/ram"
|
||||||
db "github.com/Rain-kl/Wavelet/pkg/persistence"
|
|
||||||
"github.com/Rain-kl/Wavelet/pkg/util"
|
"github.com/Rain-kl/Wavelet/pkg/util"
|
||||||
|
cachepkg "github.com/Rain-kl/Wavelet/plugins/infra/cache"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -104,14 +104,14 @@ func ensureSystemConfigCacheListener() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func startSystemConfigCacheInvalidationListener() {
|
func startSystemConfigCacheInvalidationListener() {
|
||||||
if db.Redis == nil {
|
if cachepkg.Redis == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
systemConfigListenerCtx, systemConfigListenerCancel = context.WithCancel(context.Background())
|
systemConfigListenerCtx, systemConfigListenerCancel = context.WithCancel(context.Background())
|
||||||
systemConfigListenerDone = make(chan struct{})
|
systemConfigListenerDone = make(chan struct{})
|
||||||
|
|
||||||
redisClient := db.Redis // 捕获当前客户端:goroutine 不读可变全局,避免与测试置空 db.Redis 竞争
|
redisClient := cachepkg.Redis // 捕获当前客户端:goroutine 不读可变全局,避免与测试置空 cachepkg.Redis 竞争
|
||||||
util.Go(func() {
|
util.Go(func() {
|
||||||
listenerCtx := systemConfigListenerCtx
|
listenerCtx := systemConfigListenerCtx
|
||||||
defer close(systemConfigListenerDone)
|
defer close(systemConfigListenerDone)
|
||||||
@@ -169,8 +169,8 @@ func InvalidateSystemConfigCache(ctx context.Context, key string) error {
|
|||||||
ram.Delete(ConfigCacheType, key)
|
ram.Delete(ConfigCacheType, key)
|
||||||
|
|
||||||
// Broadcast to other nodes and clean legacy Redis cache key
|
// Broadcast to other nodes and clean legacy Redis cache key
|
||||||
if db.Redis != nil {
|
if cachepkg.Redis != nil {
|
||||||
_ = db.HDel(ctx, SystemConfigRedisHashKey, key)
|
_ = cachepkg.HDel(ctx, SystemConfigRedisHashKey, key)
|
||||||
publishSystemConfigBroadcast(ctx, ConfigCacheType, key)
|
publishSystemConfigBroadcast(ctx, ConfigCacheType, key)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -184,22 +184,22 @@ func InvalidateAllSystemConfigCaches(ctx context.Context) error {
|
|||||||
ram.UpdateTypeItems(ConfigCacheType, nil)
|
ram.UpdateTypeItems(ConfigCacheType, nil)
|
||||||
|
|
||||||
// Broadcast to other nodes and clean legacy Redis cache keys
|
// Broadcast to other nodes and clean legacy Redis cache keys
|
||||||
if db.Redis != nil {
|
if cachepkg.Redis != nil {
|
||||||
_ = db.Redis.Del(ctx, db.PrefixedKey(SystemConfigRedisHashKey), db.PrefixedKey(SystemConfigVisibleListRedisKey)).Err()
|
_ = cachepkg.Redis.Del(ctx, cachepkg.PrefixedKey(SystemConfigRedisHashKey), cachepkg.PrefixedKey(SystemConfigVisibleListRedisKey)).Err()
|
||||||
publishSystemConfigBroadcast(ctx, ConfigCacheType, "*")
|
publishSystemConfigBroadcast(ctx, ConfigCacheType, "*")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func publishSystemConfigBroadcast(ctx context.Context, configType string, key string) {
|
func publishSystemConfigBroadcast(ctx context.Context, configType string, key string) {
|
||||||
if db.Redis == nil {
|
if cachepkg.Redis == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
payload, err := json.Marshal(systemConfigBroadcastMessage{Type: configType, Key: key})
|
payload, err := json.Marshal(systemConfigBroadcastMessage{Type: configType, Key: key})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = db.Redis.Publish(ctx, SystemConfigBroadcastChannel, payload).Err()
|
_ = cachepkg.Redis.Publish(ctx, SystemConfigBroadcastChannel, payload).Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResetSystemConfigRAMCacheForTest clears only the process-local RAM cache.
|
// ResetSystemConfigRAMCacheForTest clears only the process-local RAM cache.
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import (
|
|||||||
"github.com/redis/go-redis/v9/maintnotifications"
|
"github.com/redis/go-redis/v9/maintnotifications"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
db "github.com/Rain-kl/Wavelet/pkg/persistence"
|
"github.com/Rain-kl/Wavelet/plugins/infra/cache"
|
||||||
|
"github.com/Rain-kl/Wavelet/plugins/infra/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
func setupSystemConfigTest(t *testing.T) (*gorm.DB, func()) {
|
func setupSystemConfigTest(t *testing.T) (*gorm.DB, func()) {
|
||||||
@@ -51,15 +52,15 @@ func setupSystemConfigTest(t *testing.T) (*gorm.DB, func()) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
previousRedis := db.Redis
|
previousRedis := cache.Redis
|
||||||
db.SetDB(sqliteDB)
|
database.SetDB(sqliteDB)
|
||||||
db.Redis = redisClient
|
cache.Redis = redisClient
|
||||||
|
|
||||||
cleanup := func() {
|
cleanup := func() {
|
||||||
StopSystemConfigCacheListener()
|
StopSystemConfigCacheListener()
|
||||||
ResetSystemConfigRAMCacheForTest()
|
ResetSystemConfigRAMCacheForTest()
|
||||||
db.SetDB(nil)
|
database.SetDB(nil)
|
||||||
db.Redis = previousRedis
|
cache.Redis = previousRedis
|
||||||
_ = redisClient.Close()
|
_ = redisClient.Close()
|
||||||
mr.Close()
|
mr.Close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/core/contracts"
|
"github.com/Rain-kl/Wavelet/core/contracts"
|
||||||
|
db "github.com/Rain-kl/Wavelet/plugins/infra/database"
|
||||||
db "github.com/Rain-kl/Wavelet/pkg/persistence"
|
|
||||||
|
|
||||||
"github.com/coreos/go-oidc/v3/oidc"
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import (
|
|||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/core/contracts"
|
"github.com/Rain-kl/Wavelet/core/contracts"
|
||||||
"github.com/Rain-kl/Wavelet/pkg/cache/ram"
|
"github.com/Rain-kl/Wavelet/pkg/cache/ram"
|
||||||
db "github.com/Rain-kl/Wavelet/pkg/persistence"
|
|
||||||
"github.com/Rain-kl/Wavelet/pkg/util"
|
"github.com/Rain-kl/Wavelet/pkg/util"
|
||||||
|
db "github.com/Rain-kl/Wavelet/plugins/infra/cache"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import (
|
|||||||
"github.com/redis/go-redis/v9/maintnotifications"
|
"github.com/redis/go-redis/v9/maintnotifications"
|
||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/core/contracts"
|
"github.com/Rain-kl/Wavelet/core/contracts"
|
||||||
db "github.com/Rain-kl/Wavelet/pkg/persistence"
|
|
||||||
"github.com/Rain-kl/Wavelet/plugins/domain/auth"
|
"github.com/Rain-kl/Wavelet/plugins/domain/auth"
|
||||||
|
db "github.com/Rain-kl/Wavelet/plugins/infra/cache"
|
||||||
)
|
)
|
||||||
|
|
||||||
func setupOauthCacheTest(t *testing.T) (*miniredis.Miniredis, func()) {
|
func setupOauthCacheTest(t *testing.T) (*miniredis.Miniredis, func()) {
|
||||||
|
|||||||
@@ -34,4 +34,6 @@ const (
|
|||||||
errAdminRequired = "无权访问"
|
errAdminRequired = "无权访问"
|
||||||
//nolint:gosec // error message, not hardcoded credentials
|
//nolint:gosec // error message, not hardcoded credentials
|
||||||
errTokenAdminRequired = "令牌无管理员权限"
|
errTokenAdminRequired = "令牌无管理员权限"
|
||||||
|
errBannedAccount = "账号已被封禁"
|
||||||
|
errUnAuthorized = "未登录"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,14 +15,12 @@ import (
|
|||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/core/contracts"
|
"github.com/Rain-kl/Wavelet/core/contracts"
|
||||||
|
|
||||||
|
"github.com/Rain-kl/Wavelet/pkg/idgen"
|
||||||
"github.com/Rain-kl/Wavelet/pkg/logger"
|
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||||
|
|
||||||
db "github.com/Rain-kl/Wavelet/pkg/persistence"
|
|
||||||
"github.com/Rain-kl/Wavelet/pkg/persistence/idgen"
|
|
||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/pkg/response"
|
"github.com/Rain-kl/Wavelet/pkg/response"
|
||||||
"github.com/Rain-kl/Wavelet/pkg/shared"
|
|
||||||
"github.com/Rain-kl/Wavelet/pkg/util"
|
"github.com/Rain-kl/Wavelet/pkg/util"
|
||||||
|
cachepkg "github.com/Rain-kl/Wavelet/plugins/infra/cache"
|
||||||
|
db "github.com/Rain-kl/Wavelet/plugins/infra/database"
|
||||||
"github.com/coreos/go-oidc/v3/oidc"
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
"github.com/gin-contrib/sessions"
|
"github.com/gin-contrib/sessions"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -81,7 +79,7 @@ func GetLoginURL(c *gin.Context) {
|
|||||||
response.AbortInternal(c, err.Error())
|
response.AbortInternal(c, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := db.Redis.Set(ctx, db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, state)), payloadValue, OAuthStateCacheKeyExpiration).Err(); err != nil {
|
if err := cachepkg.Redis.Set(ctx, cachepkg.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, state)), payloadValue, OAuthStateCacheKeyExpiration).Err(); err != nil {
|
||||||
response.AbortInternal(c, err.Error())
|
response.AbortInternal(c, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -110,16 +108,16 @@ func buildAuthorizeURL(ctx context.Context, source *AuthSource, state string) (s
|
|||||||
}
|
}
|
||||||
|
|
||||||
func reserveOAuthStateSlot(ctx context.Context, sessionHash string) error {
|
func reserveOAuthStateSlot(ctx context.Context, sessionHash string) error {
|
||||||
if db.Redis == nil || sessionHash == "" {
|
if cachepkg.Redis == nil || sessionHash == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
key := db.PrefixedKey(fmt.Sprintf(oauthStateLimitKeyFormat, sessionHash))
|
key := cachepkg.PrefixedKey(fmt.Sprintf(oauthStateLimitKeyFormat, sessionHash))
|
||||||
n, err := db.Redis.Incr(ctx, key).Result()
|
n, err := cachepkg.Redis.Incr(ctx, key).Result()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if n == 1 {
|
if n == 1 {
|
||||||
_ = db.Redis.Expire(ctx, key, OAuthStateCacheKeyExpiration).Err()
|
_ = cachepkg.Redis.Expire(ctx, key, OAuthStateCacheKeyExpiration).Err()
|
||||||
}
|
}
|
||||||
if n > oauthStateLimitMax {
|
if n > oauthStateLimitMax {
|
||||||
return errors.New(errOAuthStateRateLimited)
|
return errors.New(errOAuthStateRateLimited)
|
||||||
@@ -153,7 +151,7 @@ func Authorize(c *gin.Context) {
|
|||||||
session := sessions.Default(c)
|
session := sessions.Default(c)
|
||||||
userID := GetUserIDFromSession(session)
|
userID := GetUserIDFromSession(session)
|
||||||
if purpose == OAuthPurposeBind && userID == 0 {
|
if purpose == OAuthPurposeBind && userID == 0 {
|
||||||
response.AbortUnauthorized(c, shared.UnAuthorized)
|
response.AbortUnauthorized(c, errUnAuthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,7 +180,7 @@ func Authorize(c *gin.Context) {
|
|||||||
response.AbortInternal(c, err.Error())
|
response.AbortInternal(c, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := db.Redis.Set(ctx, db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, state)), payloadValue, OAuthStateCacheKeyExpiration).Err(); err != nil {
|
if err := cachepkg.Redis.Set(ctx, cachepkg.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, state)), payloadValue, OAuthStateCacheKeyExpiration).Err(); err != nil {
|
||||||
response.AbortInternal(c, err.Error())
|
response.AbortInternal(c, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -204,13 +202,13 @@ func Callback(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
stateKey := db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, req.State))
|
stateKey := cachepkg.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, req.State))
|
||||||
payloadRaw, err := db.Redis.Get(ctx, stateKey).Result()
|
payloadRaw, err := cachepkg.Redis.Get(ctx, stateKey).Result()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.AbortBadRequest(c, errInvalidState)
|
response.AbortBadRequest(c, errInvalidState)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = db.Redis.Del(ctx, stateKey)
|
_ = cachepkg.Redis.Del(ctx, stateKey)
|
||||||
|
|
||||||
payload, err := decodeOAuthStatePayload(payloadRaw)
|
payload, err := decodeOAuthStatePayload(payloadRaw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -222,7 +220,7 @@ func Callback(c *gin.Context) {
|
|||||||
currentUserID := GetUserIDFromSession(session)
|
currentUserID := GetUserIDFromSession(session)
|
||||||
|
|
||||||
if payload.Purpose == OAuthPurposeBind && currentUserID == 0 {
|
if payload.Purpose == OAuthPurposeBind && currentUserID == 0 {
|
||||||
response.AbortUnauthorized(c, shared.UnAuthorized)
|
response.AbortUnauthorized(c, errUnAuthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,7 +286,7 @@ func Callback(c *gin.Context) {
|
|||||||
func handleCallbackBind(ctx context.Context, c *gin.Context, source *AuthSource, userInfo *contracts.OAuthUserInfoDTO) {
|
func handleCallbackBind(ctx context.Context, c *gin.Context, source *AuthSource, userInfo *contracts.OAuthUserInfoDTO) {
|
||||||
userID := GetUserIDFromContext(c)
|
userID := GetUserIDFromContext(c)
|
||||||
if userID == 0 {
|
if userID == 0 {
|
||||||
response.AbortUnauthorized(c, shared.UnAuthorized)
|
response.AbortUnauthorized(c, errUnAuthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var user contracts.UserDTO
|
var user contracts.UserDTO
|
||||||
@@ -477,7 +475,7 @@ func ListExternalAccounts(c *gin.Context) {
|
|||||||
func DeleteExternalAccount(c *gin.Context) {
|
func DeleteExternalAccount(c *gin.Context) {
|
||||||
userID := GetUserIDFromContext(c)
|
userID := GetUserIDFromContext(c)
|
||||||
if userID == 0 {
|
if userID == 0 {
|
||||||
response.AbortUnauthorized(c, shared.UnAuthorized)
|
response.AbortUnauthorized(c, errUnAuthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rawID := strings.TrimSpace(c.Param("id"))
|
rawID := strings.TrimSpace(c.Param("id"))
|
||||||
|
|||||||
@@ -11,10 +11,9 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/core/contracts"
|
"github.com/Rain-kl/Wavelet/core/contracts"
|
||||||
db "github.com/Rain-kl/Wavelet/pkg/persistence"
|
|
||||||
"github.com/Rain-kl/Wavelet/pkg/response"
|
"github.com/Rain-kl/Wavelet/pkg/response"
|
||||||
"github.com/Rain-kl/Wavelet/pkg/shared"
|
|
||||||
otel_trace "github.com/Rain-kl/Wavelet/pkg/trace"
|
otel_trace "github.com/Rain-kl/Wavelet/pkg/trace"
|
||||||
|
db "github.com/Rain-kl/Wavelet/plugins/infra/database"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -132,7 +131,7 @@ func LoginRequired() gin.HandlerFunc {
|
|||||||
|
|
||||||
user, err := GetUserFromRequest(c)
|
user, err := GetUserFromRequest(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.AbortUnauthorized(c, shared.UnAuthorized)
|
response.AbortUnauthorized(c, errUnAuthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +149,7 @@ func AdminRequired() gin.HandlerFunc {
|
|||||||
|
|
||||||
user, err := GetUserFromRequest(c)
|
user, err := GetUserFromRequest(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.AbortUnauthorized(c, shared.UnAuthorized)
|
response.AbortUnauthorized(c, errUnAuthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user