diff --git a/.agents/skills/file-upload/SKILL.md b/.agents/skills/file-upload/SKILL.md index a2d5aae3..dd90430b 100644 --- a/.agents/skills/file-upload/SKILL.md +++ b/.agents/skills/file-upload/SKILL.md @@ -83,8 +83,8 @@ invoice.FilePath = "uploads/2026/01/02/123.pdf" import ( "bytes" - "github.com/Rain-kl/Wavelet/internal/apps/upload" - "github.com/Rain-kl/Wavelet/internal/model" + "OpenFlare/internal/apps/upload" + "OpenFlare/internal/model" ) func ingestMirrorFile(ctx context.Context, userID uint64, data []byte, hash, filename, mime, ext string) (model.Upload, error) { diff --git a/.agents/skills/new-api/SKILL.md b/.agents/skills/new-api/SKILL.md index d4d2a32b..a3451a53 100644 --- a/.agents/skills/new-api/SKILL.md +++ b/.agents/skills/new-api/SKILL.md @@ -54,7 +54,7 @@ Wavelet 后端路由采用了**严格的框架层与业务层隔离机制**。 package root import ( - "github.com/Rain-kl/Wavelet/internal/apps/custom" + "OpenFlare/internal/apps/custom" "github.com/gin-gonic/gin" ) @@ -75,7 +75,7 @@ Wavelet 后端路由采用了**严格的框架层与业务层隔离机制**。 package v1 import ( - "github.com/Rain-kl/Wavelet/internal/apps/custom" + "OpenFlare/internal/apps/custom" "github.com/gin-gonic/gin" ) @@ -127,7 +127,7 @@ internal/ 在 `internal/apps/custom/routers.go` 中编写 Handler: - 负责请求参数绑定与校验(使用 `ShouldBindJSON`/`ShouldBindQuery`)。 - 负责提取 Session / 用户身份。 -- 调用业务逻辑层,并使用 `github.com/Rain-kl/Wavelet/internal/shared/response` 统一返回响应: +- 调用业务逻辑层,并使用 `OpenFlare/internal/shared/response` 统一返回响应: - 成功时返回:`response.OK(data)` 或 `response.OKNil()` - 失败时返回:`response.Err(msg)` - 编写规范的 Swagger 注释。 diff --git a/.agents/skills/new-api/references/handler_example.go b/.agents/skills/new-api/references/handler_example.go index 7f1efa1f..38bfa78b 100644 --- a/.agents/skills/new-api/references/handler_example.go +++ b/.agents/skills/new-api/references/handler_example.go @@ -6,8 +6,8 @@ package references import ( "net/http" - "github.com/Rain-kl/Wavelet/internal/service" - "github.com/Rain-kl/Wavelet/internal/util" + "OpenFlare/internal/service" + "OpenFlare/internal/util" "github.com/gin-gonic/gin" ) diff --git a/.agents/skills/new-api/references/logics_example.go b/.agents/skills/new-api/references/logics_example.go index c24c2048..7d6c726a 100644 --- a/.agents/skills/new-api/references/logics_example.go +++ b/.agents/skills/new-api/references/logics_example.go @@ -8,7 +8,7 @@ import ( "errors" "fmt" - "github.com/Rain-kl/Wavelet/pkg/logger" + "OpenFlare/pkg/logger" "go.uber.org/zap" ) diff --git a/.agents/skills/new-api/references/service_example.go b/.agents/skills/new-api/references/service_example.go index 97ad6699..9707c016 100644 --- a/.agents/skills/new-api/references/service_example.go +++ b/.agents/skills/new-api/references/service_example.go @@ -8,7 +8,7 @@ import ( "errors" "fmt" - "github.com/Rain-kl/Wavelet/pkg/logger" + "OpenFlare/pkg/logger" "go.uber.org/zap" ) diff --git a/.agents/skills/new-async-task/references/CODE-EXAMPLES.md b/.agents/skills/new-async-task/references/CODE-EXAMPLES.md index 718d4b3b..d07bfbca 100644 --- a/.agents/skills/new-async-task/references/CODE-EXAMPLES.md +++ b/.agents/skills/new-async-task/references/CODE-EXAMPLES.md @@ -10,7 +10,7 @@ package upload import ( - "github.com/Rain-kl/Wavelet/internal/infra/task" + "OpenFlare/internal/infra/task" ) // 异步任务类型标识。格式建议为 "{module}:{action}"。 @@ -83,7 +83,7 @@ package upload import ( "context" - "github.com/Rain-kl/Wavelet/internal/infra/task" + "OpenFlare/internal/infra/task" ) type CleanupUnusedUploadsHandler struct{} @@ -114,7 +114,7 @@ import ( "fmt" "strings" - "github.com/Rain-kl/Wavelet/internal/infra/task" + "OpenFlare/internal/infra/task" ) type SendEmailPayload struct { @@ -169,9 +169,9 @@ func (h *SendEmailHandler) Execute(ctx context.Context, payload []byte) (*task.T package handlers import ( - "github.com/Rain-kl/Wavelet/internal/apps/upload" - "github.com/Rain-kl/Wavelet/internal/apps/user" - "github.com/Rain-kl/Wavelet/internal/infra/task" + "OpenFlare/internal/apps/upload" + "OpenFlare/internal/apps/user" + "OpenFlare/internal/infra/task" ) func Register() { diff --git a/.agents/skills/push-notification/SKILL.md b/.agents/skills/push-notification/SKILL.md index 4de0cccb..d7dc7006 100644 --- a/.agents/skills/push-notification/SKILL.md +++ b/.agents/skills/push-notification/SKILL.md @@ -38,8 +38,8 @@ import ( "context" "time" - "github.com/Rain-kl/Wavelet/internal/apps/admin/push" - "github.com/Rain-kl/Wavelet/internal/listener" + "OpenFlare/internal/apps/admin/push" + "OpenFlare/internal/listener" ) var NewUserRegistered = push.EventMetadata{ @@ -84,7 +84,7 @@ func Register() { 在业务逻辑完成处(如 `internal/apps/user/routers.go`)仅 import `internal/listener` 并发射事件: ```go -import "github.com/Rain-kl/Wavelet/internal/listener" +import "OpenFlare/internal/listener" func Register(c *gin.Context) { // ... 注册成功逻辑 ... diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index e794b0a5..e0ec8b00 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -20,7 +20,7 @@ env: FRONTEND_DIR: frontend FRONTEND_BUILD_COMMAND: pnpm build:embed FRONTEND_OUT_DIR: frontend/out - EMBED_DIST_DIR: internal/router/root/dist + EMBED_DIST_DIR: backend/OpenFlare/plugins/server/router/root/dist EXTRA_FILES: | LICENSE README.md @@ -212,18 +212,18 @@ jobs: binary_name="${binary_name}.exe" fi - ldflags="$GO_LDFLAGS -X github.com/Rain-kl/Wavelet/internal/buildinfo.Version=$VERSION -X github.com/Rain-kl/Wavelet/internal/buildinfo.BuildTime=$BUILD_DATE" + ldflags="$GO_LDFLAGS -X Wavelet/pkg/buildinfo.Version=$VERSION -X Wavelet/pkg/buildinfo.BuildTime=$BUILD_DATE" build_args=( -trimpath -ldflags "$ldflags" - -o "dist/$binary_name" + -o "$GITHUB_WORKSPACE/dist/$binary_name" ) if [[ -n "$GO_BUILD_TAGS" ]]; then build_args=(-tags "$GO_BUILD_TAGS" "${build_args[@]}") fi - go build "${build_args[@]}" "$GO_MAIN" + ( cd backend && go build "${build_args[@]}" "$GO_MAIN" ) - name: Package artifact id: package @@ -307,9 +307,10 @@ jobs: ASSET_NAME: ${{ matrix.asset_name }} VERSION: ${{ needs.create-release.outputs.version }} run: | + cd backend go mod download - mkdir -p dist - go build -trimpath -ldflags "-s -w -X 'github.com/Rain-kl/Wavelet/internal/apps/agent/config.Version=$VERSION'" -o "dist/$ASSET_NAME" ./cmd/agent/main.go + mkdir -p "$GITHUB_WORKSPACE/dist" + go build -trimpath -ldflags "-s -w -X 'Wavelet/OpenFlare/plugins/agent/config.Version=$VERSION'" -o "$GITHUB_WORKSPACE/dist/$ASSET_NAME" ./cmd/agent/main.go - name: Upload release artifact uses: softprops/action-gh-release@v2 @@ -354,9 +355,10 @@ jobs: ASSET_NAME: ${{ matrix.asset_name }} VERSION: ${{ needs.create-release.outputs.version }} run: | + cd backend go mod download - mkdir -p dist - go build -trimpath -ldflags "-s -w -X 'github.com/Rain-kl/Wavelet/internal/apps/relay/config.Version=$VERSION'" -o "dist/$ASSET_NAME" ./cmd/relay/main.go + mkdir -p "$GITHUB_WORKSPACE/dist" + go build -trimpath -ldflags "-s -w -X 'Wavelet/OpenFlare/plugins/relay/config.Version=$VERSION'" -o "$GITHUB_WORKSPACE/dist/$ASSET_NAME" ./cmd/relay/main.go - name: Upload release artifact uses: softprops/action-gh-release@v2 @@ -401,9 +403,10 @@ jobs: ASSET_NAME: ${{ matrix.asset_name }} VERSION: ${{ needs.create-release.outputs.version }} run: | + cd backend go mod download - mkdir -p dist - go build -trimpath -ldflags "-s -w -X 'github.com/Rain-kl/Wavelet/internal/apps/flared/config.Version=$VERSION'" -o "dist/$ASSET_NAME" ./cmd/flared/main.go + mkdir -p "$GITHUB_WORKSPACE/dist" + go build -trimpath -ldflags "-s -w -X 'Wavelet/OpenFlare/plugins/flared/config.Version=$VERSION'" -o "$GITHUB_WORKSPACE/dist/$ASSET_NAME" ./cmd/flared/main.go - name: Upload release artifact uses: softprops/action-gh-release@v2 diff --git a/.gitignore b/.gitignore index 3687ca98..413c25e5 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,10 @@ profile.cov # i18n 生成物(由 scripts/merge-i18n-fragments.mjs 从 fragments 生成) frontend/messages/zh-CN.json frontend/messages/en.json + +# 上游 vendoring 目录内禁止出现运行期产物 +backend/plugins/**/uploads/ +backend/plugins/**/dist/ +backend/core/**/dist/ +backend/pkg/**/uploads/ +/backend/OpenFlare/plugins/server/upload/filesrv/uploads/ diff --git a/AGENTS.md b/AGENTS.md index 1e061cac..78f05381 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,7 +72,7 @@ Strong success criteria let you loop independently. Weak criteria ("make it work | `new-async-task` | Asynq 任务、定时任务、TaskHandler、任务元数据 | | `new-setting` | 系统/业务/公开设置、`/admin/system`、`/admin/settings` | | `database-migration` | 表结构、goose 迁移(PG/SQLite/ClickHouse)、seed | -| `logstore` | 日志/分析用途表、`internal/repository/logstore`、切换日志主库、PG/SQLite 回落 | +| `logstore` | 日志/分析用途表、`backend/internal/repository/logstore`、切换日志主库、PG/SQLite 回落 | | `clickhouse-batchwriter` | CH 批量写入、batchwriter、分析表 flush/背压 | | `file-upload` | 上传/摄取、`upload.Ingest`、文件访问、`w_uploads` | | `cache-framework` | 业务缓存(RAM/Redis/DB)、失效、多节点同步 | @@ -82,19 +82,31 @@ Strong success criteria let you loop independently. Weak criteria ("make it work ## 硬性约束 +### 上游/下游改动归属(Cordis) + +- 触碰框架目录 `backend/{core,pkg,plugins}` 前,先判断能力归属: + - **通用能力**(与 OpenFlare 业务无关、任何下游都用得上)→ 必须同步在 **Wavelet 上游**完成修改, + 本仓库只能通过 `scripts/sync-upstream.sh` 取得,不得长期持有本地补丁。 + - **非通用能力**(OpenFlare 业务特有)→ 在自己的插件内(`backend/OpenFlare/plugins//`)实现, + 或新建一个下游插件,禁止塞进上游目录。 +- 开发下游功能优先**复用上游已有能力**(`core/contracts`、`backend/plugins/*`、`backend/pkg/*`); + 发现上游已提供而下游仍保留本地副本的,删除本地副本改为复用,或把差量回流上游。 +- 上游暂缺而确属通用能力时,可先在本仓库实现并登记到 `backend/OpenFlare/upstream-patches.md` + (`sync-upstream.sh` 同步后会提示核对),回流 Wavelet 后删除登记并重新同步。 + - 禁止删除 `frontend/node_modules`。 -- `pkg/util/` 保持纯净:禁止导入 Gin、GORM、sessions 等 HTTP/Web/DB 框架(会话选项在 `internal/apps/oauth/session.go`)。 +- `backend/pkg/util/` 保持纯净:禁止导入 Gin、GORM、sessions 等 HTTP/Web/DB 框架(会话选项在 `backend/OpenFlare/plugins/server/oauth/session.go`)。 - 测试临时目录只用 `t.TempDir()`,禁止硬编码相对路径写源码树。 -- HTTP 路由仅在 `internal/router/router.go` 注册;`Serve()` 只挂路由与中间件,禁止进程级初始化(如 `SyncEvents`、`InitLogWriter`)。 +- HTTP 路由只由插件在 `Apply` 中经 `ctx.Router()` 声明;`router.BuildEngine()` 只挂引擎级中间件与前端 SPA 兜底,禁止进程级初始化(如 `SyncEvents`、`InitLogWriter`)。 - API 变更后:`make swagger`;开发完成:`make code-check`;提交前:`make format`。 - 缓存/文件管理复用平台实现,业务包禁止自建缓存目录或旁路存储后端。 - 文件摄取走 `upload.Ingest`(`PolicyCreate` / `PolicyDedupNewRecord` / `PolicyResolveExisting`);删除走 `upload.Remove` / `upload.RemoveOwned`。禁止业务直接 `repository.CreateUpload` / `SoftDeleteUpload` 或 `db.Create(&model.Upload{})`。 - **分层**:`apps → repository → model`,`repository → infra/persistence`;禁止 `model → repository`。 - `model`:实体、表名、配置 key、查询 DTO、无 IO 规则。禁止 `db.DB` / Redis / CH;禁止 `import repository`。GORM hook 仅可 mutate 自身字段,禁止在 hook 内再查 DB/缓存。 - `repository`:唯一持久化入口。apps/logics 禁止为业务 CRUD 直调 `db.DB`(管理端 SQL 控制台、infra 内部等例外保留)。禁止新增 `model.Get/List/Create/...` 类数据访问 API。 -- 日志/分析表(节点访问日志、用户访问日志、可观测时序)走 `internal/repository/logstore`,禁止 apps 直连 `repository/analytics` 或 `db.ChConn`/`db.ChDB`。判定与接入步骤见 `logstore` skill。 -- 跨模块集成(任务 Handler、推送事件、域监听、完成钩子)禁止 `init()` 注册;经 `internal/platform/bootstrap` 在 `internal/cmd` 入口显式装配。 -- 核心业务(如 `oauth`、`user`)禁止直接 import push/custom_events;经 `internal/listener` 发域事件,push 在 bootstrap 订阅。 +- 日志/分析表(节点访问日志、用户访问日志、可观测时序)走 `backend/OpenFlare/plugins/server/repository/logstore`,禁止 apps 直连 `repository/analytics` 或 `db.ChConn`/`db.ChDB`。判定与接入步骤见 `logstore` skill。 +- 跨模块集成(任务 Handler、推送事件、域监听、完成钩子)禁止 `init()` 注册;经 `backend/OpenFlare/plugins/server/platform/bootstrap` 在 `backend/cmd` 入口显式装配。 +- 核心业务(如 `oauth`、`user`)禁止直接 import push/custom_events;经 `backend/OpenFlare/plugins/server/listener` 发域事件,push 在 bootstrap 订阅。 - 依赖任务/推送注册的测试须显式 `bootstrap.RegisterTasks()` / `RegisterPushDomainEvents()` 等,不依赖 `init()`。 - API 错误必须 `response.Abort*` + `ErrorHandlerMiddleware`;禁止 Handler 直接 `c.JSON(..., response.Err(...))` 或用 HTTP 200 表示失败。 @@ -132,7 +144,7 @@ Conventional Commits:`(): `(例:`feat(auth): support - 命名:动词 + 名词(`ListUsers`);绑定用 `ShouldBindQuery` / `ShouldBindJSON`。 - 每个 HTTP API 需完整 Swagger 注释;API 变更后 `make swagger`。 - Handler:绑定 → 调 logic → 映射为 `Abort*` 或 `response.OK`。 -- `logics.go`:接受 `context.Context`,返回结果/error;**禁止**依赖 `*gin.Context`、调用 `Abort*` / `c.JSON`。参考 `internal/apps/user/logics.go`。 +- `logics.go`:接受 `context.Context`,返回结果/error;**禁止**依赖 `*gin.Context`、调用 `Abort*` / `c.JSON`。参考 `backend/internal/apps/user/logics.go`。 ### API 响应 @@ -158,7 +170,7 @@ Swagger:`@Success 200` 用具体类型或 `response.Any`;每个可能 Abort ### 日志 -- 运行时错误(DB/Redis/第三方/IO)在 Handler 或 logic 边界用 `pkg/logger`(带 `ctx`)记录,再返回安全 Abort/业务错误。 +- 运行时错误(DB/Redis/第三方/IO)在 Handler 或 logic 边界用 `backend/pkg/logger`(带 `ctx`)记录,再返回安全 Abort/业务错误。 - 吞错、转通用响应、worker 忽略前必须先记日志。 - 禁止 `_ = err` 静默丢弃重要错误;best-effort 可忽略时加简短注释。 - 只在处理/抑制边界记一次,避免重复刷日志。 @@ -166,7 +178,7 @@ Swagger:`@Success 200` 用具体类型或 `response.Any`;每个可能 Abort ### 路由与装配 - `router.go` 只做高层分发,禁止直接挂业务 Handler。归属与开发步骤见 `new-api` skill。 -- 跨模块副作用:在 `bootstrap` 增 `Register*`,于对应 `internal/cmd/*.go` 调用(`RegisterAPI` / `RegisterWorker` / `RegisterAll`)。 +- 跨模块副作用:在 `bootstrap` 增 `Register*`,于对应 `backend/internal/cmd/*.go` 调用(`RegisterAPI` / `RegisterWorker` / `RegisterAll`)。 - API/`all` 模式:`bootstrap.Init` 须在 `RegisterPushDomainEvents()` **之后**调用,保证 `SyncEvents` 同步内置推送元数据。 ### 中间件 @@ -177,13 +189,13 @@ Swagger:`@Success 200` 用具体类型或 `response.Any`;每个可能 Abort ### 配置 - 运行时只读 `config.Config`,禁止 `os.Getenv()`。 -- 新增配置同步 `config.example.yaml` 与 `internal/infra/config/model.go`。 +- 新增配置同步 `config.example.yaml` 与 `backend/internal/infra/config/model.go`。 ### 数据库 - 持久化只经 `repository`(或 analytics);复杂查询不进 Handler;编排在 logics。 - repository 内用 `db.DB(ctx)`(链路追踪)。 -- 迁移:`internal/infra/persistence/migrator/goose/` SQL;禁止 GORM AutoMigrate。 +- 迁移:`backend/internal/infra/persistence/migrator/goose/` SQL;禁止 GORM AutoMigrate。 - 不建物理外键,关系字段加显式索引。 - 列默认值与 Go 零值(`nil`/`0`/`false`/`""`)一致。 diff --git a/Makefile b/Makefile index ff5e78e3..39b37976 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ VERSION ?= dev BUILD_DATE ?= $(shell date -u +'%Y-%m-%dT%H:%M:%SZ') -MODULE := $(shell go list -m) +MODULE := $(shell cd backend && go list -m) swagger: scripts/swagger.sh @@ -19,7 +19,7 @@ format: echo "goimports not found, installing..."; \ go install golang.org/x/tools/cmd/goimports@latest; \ } - goimports -w $$(find . -type f -name '*.go' -not -path './.git/*' -not -path './frontend/*') + goimports -w $$(find backend -type f -name '*.go') @echo "==> Formatting frontend source and removing unused imports..." cd frontend && pnpm format @@ -29,50 +29,50 @@ build-embedded: NEXT_PUBLIC_APP_VERSION="$(VERSION)" \ NEXT_PUBLIC_APP_BUILD_DATE="$(BUILD_DATE)" \ pnpm build:embed - rm -rf internal/router/root/dist - cp -R frontend/out internal/router/root/dist - go build \ + rm -rf backend/OpenFlare/plugins/server/router/root/dist + cp -R frontend/out backend/OpenFlare/plugins/server/router/root/dist + cd backend && go build \ -tags embed_frontend \ - -ldflags "-s -w -X '$(MODULE)/internal/buildinfo.Version=$(VERSION)' -X '$(MODULE)/internal/buildinfo.BuildTime=$(BUILD_DATE)'" \ - -o bin/openflare-server \ + -ldflags "-s -w -X '$(MODULE)/pkg/buildinfo.Version=$(VERSION)' -X '$(MODULE)/pkg/buildinfo.BuildTime=$(BUILD_DATE)'" \ + -o ../bin/openflare-server \ main.go code-check: @echo "==> Architecture guards..." @command -v rg >/dev/null 2>&1 || { echo 'error: rg (ripgrep) is required for architecture guards' >&2; exit 1; } - @if rg -n 'db\.DB\(|db\.Redis' internal/model --glob '*.go' -g '!*_test.go' ; then \ + @if rg -n 'db\.DB\(|db\.Redis' backend/OpenFlare/plugins/server/model --glob '*.go' -g '!*_test.go' ; then \ echo 'error: internal/model must not access db.DB or db.Redis (non-test code)' >&2; \ exit 1; \ fi - golangci-lint run + cd backend && golangci-lint run cd frontend && node scripts/merge-i18n-fragments.mjs && pnpm tsc --noEmit --jsx preserve && npx eslint . --max-warnings 0 build-backend: @echo "==> Building backend version=$(VERSION) build_date=$(BUILD_DATE)..." - go build \ - -ldflags "-s -w -X '$(MODULE)/internal/buildinfo.Version=$(VERSION)' -X '$(MODULE)/internal/buildinfo.BuildTime=$(BUILD_DATE)'" \ - -o bin/openflare-server \ + cd backend && go build \ + -ldflags "-s -w -X '$(MODULE)/pkg/buildinfo.Version=$(VERSION)' -X '$(MODULE)/pkg/buildinfo.BuildTime=$(BUILD_DATE)'" \ + -o ../bin/openflare-server \ main.go build-agent: @echo "==> Building agent version=$(VERSION)..." - go build \ - -ldflags "-s -w -X '$(MODULE)/internal/apps/agent/config.Version=$(VERSION)'" \ - -o bin/openflare-agent \ + cd backend && go build \ + -ldflags "-s -w -X '$(MODULE)/OpenFlare/plugins/agent/config.Version=$(VERSION)'" \ + -o ../bin/openflare-agent \ cmd/agent/main.go build-relay: @echo "==> Building relay version=$(VERSION)..." - go build \ - -ldflags "-s -w -X '$(MODULE)/internal/apps/relay/config.Version=$(VERSION)'" \ - -o bin/openflare-relay \ + cd backend && go build \ + -ldflags "-s -w -X '$(MODULE)/OpenFlare/plugins/relay/config.Version=$(VERSION)'" \ + -o ../bin/openflare-relay \ cmd/relay/main.go build-flared: @echo "==> Building flared version=$(VERSION)..." - go build \ - -ldflags "-s -w -X '$(MODULE)/internal/apps/flared/config.Version=$(VERSION)'" \ - -o bin/flared \ + cd backend && go build \ + -ldflags "-s -w -X '$(MODULE)/OpenFlare/plugins/flared/config.Version=$(VERSION)'" \ + -o ../bin/flared \ cmd/flared/main.go build-all: build-backend build-agent build-relay build-flared @@ -89,7 +89,7 @@ build-test: @PIDS=""; \ STATUS=0; \ ( cd frontend && pnpm build:embed 2>&1 | sed 's/^/[frontend] /' ) & PIDS="$$PIDS $$!"; \ - ( go test ./... && go build -o /dev/null ./... 2>&1 | sed 's/^/[backend] /' ) & PIDS="$$PIDS $$!"; \ + ( cd backend && go test ./... && go build -o /dev/null ./... 2>&1 | sed 's/^/[backend] /' ) & PIDS="$$PIDS $$!"; \ for PID in $$PIDS; do \ wait $$PID || STATUS=1; \ done; \ @@ -124,14 +124,14 @@ dev-f: dev-b: @echo "==> Starting backend development server..." - go run main.go all + cd backend && go run main.go all dev: @echo "==> Starting frontend and backend development servers in parallel..." @PIDS=""; \ STATUS=0; \ ( cd frontend && pnpm dev 2>&1 | sed 's/^/[frontend] /' ) & PIDS="$$PIDS $$!"; \ - ( go run main.go all 2>&1 | sed 's/^/[backend] /' ) & PIDS="$$PIDS $$!"; \ + ( cd backend && go run main.go all 2>&1 | sed 's/^/[backend] /' ) & PIDS="$$PIDS $$!"; \ for PID in $$PIDS; do \ wait $$PID || STATUS=1; \ done; \ diff --git a/backend/OpenFlare/README.md b/backend/OpenFlare/README.md new file mode 100644 index 00000000..3bad65ad --- /dev/null +++ b/backend/OpenFlare/README.md @@ -0,0 +1,48 @@ +# OpenFlare 下游树 + +本目录占据上游 `backend/downstream/` 的位置,存放 OpenFlare 的全部业务, +按功能职责拆为 **4 个插件 + 1 个共享层**。上游目录 +(`backend/{core,pkg,plugins}`)由 `scripts/sync-upstream.sh` 覆盖同步, +本目录与 `backend/cmd` 永不被覆盖。 + +``` +backend/OpenFlare/ +├── plugins/ +│ ├── server/ # 控制面插件:站点/区域/Cloudflare/Pages/WAF/节点/回源/健康/配置版本 +│ │ ├── openflare/ admin/ oauth/ user/ upload/ cap/ config/ health/ # 业务域 +│ │ ├── repository/ model/ infra/ shared/ pkg/ # 支撑层 +│ │ └── router/ platform/ listener/ integration/ testhelper/ # 装配与接线 +│ ├── agent/ # 边缘 nginx/WAF 代理守护进程插件 +│ ├── relay/ # frps 中继守护进程插件 +│ └── flared/ # frpc 隧道客户端守护进程插件 +└── share/ # 插件间共享资源(见 share/README.md) +``` + +装配根在 `backend/cmd`(与上游同构):`main.go` + `cmd/*.go` 为 server 的 +api/worker/schedule/all profile 入口,`cmd/{agent,relay,flared}/main.go` 为三个 +守护进程入口。 + +## 依赖规则 + +1. `plugins/` 与 `plugins/` 之间禁止互相 import;需要协作时走 `core/contracts` + 或 `ctx.Events()`。 +2. 插件只允许 import 本插件内部包、`Wavelet/core`、`Wavelet/core/contracts`、 + `Wavelet/pkg` 与 `Wavelet/OpenFlare/share`。 +3. `share/` 禁止 import 任何插件实现与下游业务包。 +4. 表单一所有者:`of_*` 全部由 `server` 插件建表与读写;`w_*` 由上游平台插件拥有, + 下游只能经契约或事件访问。 + +## 收敛路线 + +- 已完成:`agent`/`relay`/`flared` 各有 `plugin.go` 实现 `core.Plugin` + `core.Driver` + (`DriverTypeAgent`/`DriverTypeRelay`/`DriverTypeFlared`),入口 `backend/cmd/{agent,relay,flared}/main.go` + 已改为 `core.NewApp(core.WithProfile(...))` + `app.Run()` 装配,`-config` 旗标、 + 默认路径、退出码与启动日志保持原样;JSON 配置由各插件 `Apply` 自行加载。 +- 已完成:`server/plugin.go` 实现 `core.Plugin`,`Apply` 以 `ctx.Router().Group(api_prefix)` + 声明根级与 `/v1` 全部路由;`router.Serve` 已删除,装配根改为 + `core.App` + `driver_http.New(WithEngine(router.BuildEngine()))`,监听与优雅退出归内核。 + 路由保真由 `plugin_parity_test.go` 对拍 `baseline/routes-engine.txt`(256 条方法+路径)保证。 +- 待办:`platform/bootstrap` 的任务、设置与迁移注册迁入 `Apply`;各业务域按插件标准 + 分层规范收敛到 `handler/ service/ repository/ model/ errs/ migrations/`(模式 2); + 引擎级中间件、NoRoute 前端兜底与白名单生效三项能力回流上游后,去掉 + `BuildEngine` 交给 `WithEngine` 这一例外。 diff --git a/internal/apps/agent/agent/runner.go b/backend/OpenFlare/plugins/agent/agent/runner.go similarity index 97% rename from internal/apps/agent/agent/runner.go rename to backend/OpenFlare/plugins/agent/agent/runner.go index b2141483..c5b8b0d4 100644 --- a/internal/apps/agent/agent/runner.go +++ b/backend/OpenFlare/plugins/agent/agent/runner.go @@ -12,13 +12,13 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/apps/agent/config" - agentheartbeat "github.com/Rain-kl/Wavelet/internal/apps/agent/heartbeat" - "github.com/Rain-kl/Wavelet/internal/apps/agent/protocol" - "github.com/Rain-kl/Wavelet/internal/apps/agent/state" - "github.com/Rain-kl/Wavelet/internal/apps/agent/wsclient" - edgeheartbeat "github.com/Rain-kl/Wavelet/internal/apps/edge/heartbeat" - "github.com/Rain-kl/Wavelet/pkg/util" + "Wavelet/OpenFlare/plugins/agent/config" + agentheartbeat "Wavelet/OpenFlare/plugins/agent/heartbeat" + "Wavelet/OpenFlare/plugins/agent/protocol" + "Wavelet/OpenFlare/plugins/agent/state" + "Wavelet/OpenFlare/plugins/agent/wsclient" + edgeheartbeat "Wavelet/OpenFlare/share/edge/heartbeat" + "Wavelet/pkg/util" ) // HeartbeatService handles node registration and periodic heartbeat reporting. diff --git a/internal/apps/agent/agent/runner_test.go b/backend/OpenFlare/plugins/agent/agent/runner_test.go similarity index 98% rename from internal/apps/agent/agent/runner_test.go rename to backend/OpenFlare/plugins/agent/agent/runner_test.go index 1ec211a1..a2f968b4 100644 --- a/internal/apps/agent/agent/runner_test.go +++ b/backend/OpenFlare/plugins/agent/agent/runner_test.go @@ -13,11 +13,11 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/apps/agent/config" - agentheartbeat "github.com/Rain-kl/Wavelet/internal/apps/agent/heartbeat" - "github.com/Rain-kl/Wavelet/internal/apps/agent/protocol" - "github.com/Rain-kl/Wavelet/internal/apps/agent/state" - "github.com/Rain-kl/Wavelet/internal/apps/agent/updater" + "Wavelet/OpenFlare/plugins/agent/config" + agentheartbeat "Wavelet/OpenFlare/plugins/agent/heartbeat" + "Wavelet/OpenFlare/plugins/agent/protocol" + "Wavelet/OpenFlare/plugins/agent/state" + "Wavelet/OpenFlare/plugins/agent/updater" ) func withHeartbeatCycle(runner *Runner, observabilityBuffer *state.ObservabilityBufferStore) *Runner { diff --git a/internal/apps/agent/config/config.go b/backend/OpenFlare/plugins/agent/config/config.go similarity index 99% rename from internal/apps/agent/config/config.go rename to backend/OpenFlare/plugins/agent/config/config.go index 06a2de7a..9f25fa36 100644 --- a/internal/apps/agent/config/config.go +++ b/backend/OpenFlare/plugins/agent/config/config.go @@ -15,8 +15,8 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/apps/edge/nodeip" - "github.com/Rain-kl/Wavelet/pkg/util" + "Wavelet/OpenFlare/share/edge/nodeip" + "Wavelet/pkg/util" ) const ( diff --git a/internal/apps/agent/config/config_test.go b/backend/OpenFlare/plugins/agent/config/config_test.go similarity index 99% rename from internal/apps/agent/config/config_test.go rename to backend/OpenFlare/plugins/agent/config/config_test.go index 673ad127..d11b594d 100644 --- a/internal/apps/agent/config/config_test.go +++ b/backend/OpenFlare/plugins/agent/config/config_test.go @@ -13,9 +13,9 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/apps/edge/nodeip" - "github.com/Rain-kl/Wavelet/pkg/geoip" - "github.com/Rain-kl/Wavelet/pkg/geoip/iputil" + "Wavelet/OpenFlare/share/edge/nodeip" + "Wavelet/OpenFlare/share/geoip" + "Wavelet/OpenFlare/share/geoip/iputil" ) func TestLoadDefaultsToManagedBinaryPaths(t *testing.T) { diff --git a/internal/apps/agent/config/duration.go b/backend/OpenFlare/plugins/agent/config/duration.go similarity index 76% rename from internal/apps/agent/config/duration.go rename to backend/OpenFlare/plugins/agent/config/duration.go index 83881e4c..7b75aa4e 100644 --- a/internal/apps/agent/config/duration.go +++ b/backend/OpenFlare/plugins/agent/config/duration.go @@ -3,7 +3,7 @@ package config -import edgeconfig "github.com/Rain-kl/Wavelet/internal/apps/edge/config" +import edgeconfig "Wavelet/OpenFlare/share/edge/config" // MillisecondDuration is an alias for the edge config millisecond-precision duration type. type MillisecondDuration = edgeconfig.MillisecondDuration diff --git a/internal/apps/agent/config/version.go b/backend/OpenFlare/plugins/agent/config/version.go similarity index 100% rename from internal/apps/agent/config/version.go rename to backend/OpenFlare/plugins/agent/config/version.go diff --git a/internal/apps/agent/geoipdata/data.go b/backend/OpenFlare/plugins/agent/geoipdata/data.go similarity index 100% rename from internal/apps/agent/geoipdata/data.go rename to backend/OpenFlare/plugins/agent/geoipdata/data.go diff --git a/internal/apps/agent/geoipupdate/updater.go b/backend/OpenFlare/plugins/agent/geoipupdate/updater.go similarity index 98% rename from internal/apps/agent/geoipupdate/updater.go rename to backend/OpenFlare/plugins/agent/geoipupdate/updater.go index 4d32e14b..fb180f2d 100644 --- a/internal/apps/agent/geoipupdate/updater.go +++ b/backend/OpenFlare/plugins/agent/geoipupdate/updater.go @@ -13,7 +13,7 @@ import ( "path/filepath" "time" - "github.com/Rain-kl/Wavelet/pkg/geoip" + "Wavelet/OpenFlare/share/geoip" ) // Updater periodically downloads a fresh GeoIP MMDB file and seeds missing diff --git a/internal/apps/agent/geoipupdate/updater_test.go b/backend/OpenFlare/plugins/agent/geoipupdate/updater_test.go similarity index 100% rename from internal/apps/agent/geoipupdate/updater_test.go rename to backend/OpenFlare/plugins/agent/geoipupdate/updater_test.go diff --git a/internal/apps/agent/heartbeat/cycle.go b/backend/OpenFlare/plugins/agent/heartbeat/cycle.go similarity index 95% rename from internal/apps/agent/heartbeat/cycle.go rename to backend/OpenFlare/plugins/agent/heartbeat/cycle.go index 9f614eab..90be9f30 100644 --- a/internal/apps/agent/heartbeat/cycle.go +++ b/backend/OpenFlare/plugins/agent/heartbeat/cycle.go @@ -11,13 +11,13 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/apps/agent/config" - "github.com/Rain-kl/Wavelet/internal/apps/agent/observability" - "github.com/Rain-kl/Wavelet/internal/apps/agent/protocol" - "github.com/Rain-kl/Wavelet/internal/apps/agent/state" - "github.com/Rain-kl/Wavelet/internal/apps/agent/updater" - edgeheartbeat "github.com/Rain-kl/Wavelet/internal/apps/edge/heartbeat" - "github.com/Rain-kl/Wavelet/internal/apps/edge/nodeip" + "Wavelet/OpenFlare/plugins/agent/config" + "Wavelet/OpenFlare/plugins/agent/observability" + "Wavelet/OpenFlare/plugins/agent/protocol" + "Wavelet/OpenFlare/plugins/agent/state" + "Wavelet/OpenFlare/plugins/agent/updater" + edgeheartbeat "Wavelet/OpenFlare/share/edge/heartbeat" + "Wavelet/OpenFlare/share/edge/nodeip" ) // SyncService is the interface used by Cycle to sync active configuration and WAF IP groups. diff --git a/internal/apps/agent/heartbeat/service.go b/backend/OpenFlare/plugins/agent/heartbeat/service.go similarity index 96% rename from internal/apps/agent/heartbeat/service.go rename to backend/OpenFlare/plugins/agent/heartbeat/service.go index eae24939..2286553d 100644 --- a/internal/apps/agent/heartbeat/service.go +++ b/backend/OpenFlare/plugins/agent/heartbeat/service.go @@ -6,7 +6,7 @@ package heartbeat import ( "context" - "github.com/Rain-kl/Wavelet/internal/apps/agent/protocol" + "Wavelet/OpenFlare/plugins/agent/protocol" ) // RemoteClient is the interface that abstracts the remote API calls performed by Service. diff --git a/internal/apps/agent/httpclient/client.go b/backend/OpenFlare/plugins/agent/httpclient/client.go similarity index 98% rename from internal/apps/agent/httpclient/client.go rename to backend/OpenFlare/plugins/agent/httpclient/client.go index 4e4e2efb..f10ae4f6 100644 --- a/internal/apps/agent/httpclient/client.go +++ b/backend/OpenFlare/plugins/agent/httpclient/client.go @@ -13,8 +13,8 @@ import ( "net/http" "time" - "github.com/Rain-kl/Wavelet/internal/apps/agent/protocol" - edgehttp "github.com/Rain-kl/Wavelet/internal/apps/edge/httpclient" + "Wavelet/OpenFlare/plugins/agent/protocol" + edgehttp "Wavelet/OpenFlare/share/edge/httpclient" ) const pagesControlResponseMaxBytes = int64(64 * 1024) diff --git a/internal/apps/agent/httpclient/client_test.go b/backend/OpenFlare/plugins/agent/httpclient/client_test.go similarity index 100% rename from internal/apps/agent/httpclient/client_test.go rename to backend/OpenFlare/plugins/agent/httpclient/client_test.go diff --git a/internal/apps/agent/logging/logger.go b/backend/OpenFlare/plugins/agent/logging/logger.go similarity index 79% rename from internal/apps/agent/logging/logger.go rename to backend/OpenFlare/plugins/agent/logging/logger.go index 4d493850..9cbf9938 100644 --- a/internal/apps/agent/logging/logger.go +++ b/backend/OpenFlare/plugins/agent/logging/logger.go @@ -4,7 +4,7 @@ // Package logging configures structured logging for the agent process. package logging -import edgelogging "github.com/Rain-kl/Wavelet/internal/apps/edge/logging" +import edgelogging "Wavelet/OpenFlare/share/edge/logging" // Setup initialises structured logging for the agent process. func Setup() { diff --git a/internal/apps/agent/nginx/manager.go b/backend/OpenFlare/plugins/agent/nginx/manager.go similarity index 99% rename from internal/apps/agent/nginx/manager.go rename to backend/OpenFlare/plugins/agent/nginx/manager.go index 43ac6951..d5d25d01 100644 --- a/internal/apps/agent/nginx/manager.go +++ b/backend/OpenFlare/plugins/agent/nginx/manager.go @@ -26,12 +26,12 @@ import ( "sync" "time" - sharedprotocol "github.com/Rain-kl/Wavelet/pkg/protocol" - openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty" - "github.com/Rain-kl/Wavelet/pkg/util" + sharedprotocol "Wavelet/OpenFlare/share/protocol" + openrestyrender "Wavelet/OpenFlare/share/render/openresty" + "Wavelet/pkg/util" - "github.com/Rain-kl/Wavelet/internal/apps/agent/protocol" - "github.com/Rain-kl/Wavelet/internal/apps/agent/runtimeuser" + "Wavelet/OpenFlare/plugins/agent/protocol" + "Wavelet/OpenFlare/plugins/agent/runtimeuser" ) // RuntimeConfigDirPlaceholder is substituted into generated configs at apply time. diff --git a/internal/apps/agent/nginx/manager_test.go b/backend/OpenFlare/plugins/agent/nginx/manager_test.go similarity index 99% rename from internal/apps/agent/nginx/manager_test.go rename to backend/OpenFlare/plugins/agent/nginx/manager_test.go index a55d3061..9768b963 100644 --- a/internal/apps/agent/nginx/manager_test.go +++ b/backend/OpenFlare/plugins/agent/nginx/manager_test.go @@ -16,9 +16,9 @@ import ( "strings" "testing" - "github.com/Rain-kl/Wavelet/internal/apps/agent/protocol" - sharedprotocol "github.com/Rain-kl/Wavelet/pkg/protocol" - openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty" + "Wavelet/OpenFlare/plugins/agent/protocol" + sharedprotocol "Wavelet/OpenFlare/share/protocol" + openrestyrender "Wavelet/OpenFlare/share/render/openresty" ) type runCall struct { diff --git a/internal/apps/agent/nginx/mimetypes.go b/backend/OpenFlare/plugins/agent/nginx/mimetypes.go similarity index 100% rename from internal/apps/agent/nginx/mimetypes.go rename to backend/OpenFlare/plugins/agent/nginx/mimetypes.go diff --git a/internal/apps/agent/nginx/observability_assets.go b/backend/OpenFlare/plugins/agent/nginx/observability_assets.go similarity index 97% rename from internal/apps/agent/nginx/observability_assets.go rename to backend/OpenFlare/plugins/agent/nginx/observability_assets.go index 8c0f4a49..fac64029 100644 --- a/internal/apps/agent/nginx/observability_assets.go +++ b/backend/OpenFlare/plugins/agent/nginx/observability_assets.go @@ -3,7 +3,7 @@ package nginx -import "github.com/Rain-kl/Wavelet/internal/apps/agent/protocol" +import "Wavelet/OpenFlare/plugins/agent/protocol" // Local OpenResty observability endpoint (target model): // GET /openflare/observability returns instantaneous health/connections only. diff --git a/internal/apps/agent/nginx/observability_assets_test.go b/backend/OpenFlare/plugins/agent/nginx/observability_assets_test.go similarity index 100% rename from internal/apps/agent/nginx/observability_assets_test.go rename to backend/OpenFlare/plugins/agent/nginx/observability_assets_test.go diff --git a/internal/apps/agent/nginx/pow_assets.go b/backend/OpenFlare/plugins/agent/nginx/pow_assets.go similarity index 99% rename from internal/apps/agent/nginx/pow_assets.go rename to backend/OpenFlare/plugins/agent/nginx/pow_assets.go index 39571142..f2f3339e 100644 --- a/internal/apps/agent/nginx/pow_assets.go +++ b/backend/OpenFlare/plugins/agent/nginx/pow_assets.go @@ -8,7 +8,7 @@ import ( "path/filepath" "strings" - "github.com/Rain-kl/Wavelet/internal/apps/agent/protocol" + "Wavelet/OpenFlare/plugins/agent/protocol" ) //go:embed pow_static diff --git a/internal/apps/agent/nginx/pow_assets_test.go b/backend/OpenFlare/plugins/agent/nginx/pow_assets_test.go similarity index 100% rename from internal/apps/agent/nginx/pow_assets_test.go rename to backend/OpenFlare/plugins/agent/nginx/pow_assets_test.go diff --git a/internal/apps/agent/nginx/pow_static/css/static/geist.woff2 b/backend/OpenFlare/plugins/agent/nginx/pow_static/css/static/geist.woff2 similarity index 100% rename from internal/apps/agent/nginx/pow_static/css/static/geist.woff2 rename to backend/OpenFlare/plugins/agent/nginx/pow_static/css/static/geist.woff2 diff --git a/internal/apps/agent/nginx/pow_static/css/static/iosevka-curly.woff2 b/backend/OpenFlare/plugins/agent/nginx/pow_static/css/static/iosevka-curly.woff2 similarity index 100% rename from internal/apps/agent/nginx/pow_static/css/static/iosevka-curly.woff2 rename to backend/OpenFlare/plugins/agent/nginx/pow_static/css/static/iosevka-curly.woff2 diff --git a/internal/apps/agent/nginx/pow_static/css/static/podkova.css b/backend/OpenFlare/plugins/agent/nginx/pow_static/css/static/podkova.css similarity index 100% rename from internal/apps/agent/nginx/pow_static/css/static/podkova.css rename to backend/OpenFlare/plugins/agent/nginx/pow_static/css/static/podkova.css diff --git a/internal/apps/agent/nginx/pow_static/css/static/podkova.woff2 b/backend/OpenFlare/plugins/agent/nginx/pow_static/css/static/podkova.woff2 similarity index 100% rename from internal/apps/agent/nginx/pow_static/css/static/podkova.woff2 rename to backend/OpenFlare/plugins/agent/nginx/pow_static/css/static/podkova.woff2 diff --git a/internal/apps/agent/nginx/pow_static/css/xess.css b/backend/OpenFlare/plugins/agent/nginx/pow_static/css/xess.css similarity index 100% rename from internal/apps/agent/nginx/pow_static/css/xess.css rename to backend/OpenFlare/plugins/agent/nginx/pow_static/css/xess.css diff --git a/internal/apps/agent/nginx/pow_static/img/happy.webp b/backend/OpenFlare/plugins/agent/nginx/pow_static/img/happy.webp similarity index 100% rename from internal/apps/agent/nginx/pow_static/img/happy.webp rename to backend/OpenFlare/plugins/agent/nginx/pow_static/img/happy.webp diff --git a/internal/apps/agent/nginx/pow_static/img/pensive.webp b/backend/OpenFlare/plugins/agent/nginx/pow_static/img/pensive.webp similarity index 100% rename from internal/apps/agent/nginx/pow_static/img/pensive.webp rename to backend/OpenFlare/plugins/agent/nginx/pow_static/img/pensive.webp diff --git a/internal/apps/agent/nginx/pow_static/img/reject.webp b/backend/OpenFlare/plugins/agent/nginx/pow_static/img/reject.webp similarity index 100% rename from internal/apps/agent/nginx/pow_static/img/reject.webp rename to backend/OpenFlare/plugins/agent/nginx/pow_static/img/reject.webp diff --git a/internal/apps/agent/nginx/pow_static/js/algorithms/index.mjs b/backend/OpenFlare/plugins/agent/nginx/pow_static/js/algorithms/index.mjs similarity index 100% rename from internal/apps/agent/nginx/pow_static/js/algorithms/index.mjs rename to backend/OpenFlare/plugins/agent/nginx/pow_static/js/algorithms/index.mjs diff --git a/internal/apps/agent/nginx/pow_static/js/main.mjs b/backend/OpenFlare/plugins/agent/nginx/pow_static/js/main.mjs similarity index 100% rename from internal/apps/agent/nginx/pow_static/js/main.mjs rename to backend/OpenFlare/plugins/agent/nginx/pow_static/js/main.mjs diff --git a/internal/apps/agent/nginx/pow_static/js/worker/sha256-purejs.mjs b/backend/OpenFlare/plugins/agent/nginx/pow_static/js/worker/sha256-purejs.mjs similarity index 100% rename from internal/apps/agent/nginx/pow_static/js/worker/sha256-purejs.mjs rename to backend/OpenFlare/plugins/agent/nginx/pow_static/js/worker/sha256-purejs.mjs diff --git a/internal/apps/agent/nginx/pow_static/js/worker/sha256-webcrypto.mjs b/backend/OpenFlare/plugins/agent/nginx/pow_static/js/worker/sha256-webcrypto.mjs similarity index 100% rename from internal/apps/agent/nginx/pow_static/js/worker/sha256-webcrypto.mjs rename to backend/OpenFlare/plugins/agent/nginx/pow_static/js/worker/sha256-webcrypto.mjs diff --git a/internal/apps/agent/nginx/pow_static/locales/en.json b/backend/OpenFlare/plugins/agent/nginx/pow_static/locales/en.json similarity index 100% rename from internal/apps/agent/nginx/pow_static/locales/en.json rename to backend/OpenFlare/plugins/agent/nginx/pow_static/locales/en.json diff --git a/internal/apps/agent/nginx/pow_static/locales/zh-CN.json b/backend/OpenFlare/plugins/agent/nginx/pow_static/locales/zh-CN.json similarity index 100% rename from internal/apps/agent/nginx/pow_static/locales/zh-CN.json rename to backend/OpenFlare/plugins/agent/nginx/pow_static/locales/zh-CN.json diff --git a/internal/apps/agent/nginx/resty/ipmatcher.LICENSE b/backend/OpenFlare/plugins/agent/nginx/resty/ipmatcher.LICENSE similarity index 100% rename from internal/apps/agent/nginx/resty/ipmatcher.LICENSE rename to backend/OpenFlare/plugins/agent/nginx/resty/ipmatcher.LICENSE diff --git a/internal/apps/agent/nginx/resty/ipmatcher.lua b/backend/OpenFlare/plugins/agent/nginx/resty/ipmatcher.lua similarity index 100% rename from internal/apps/agent/nginx/resty/ipmatcher.lua rename to backend/OpenFlare/plugins/agent/nginx/resty/ipmatcher.lua diff --git a/internal/apps/agent/nginx/runtime_access.go b/backend/OpenFlare/plugins/agent/nginx/runtime_access.go similarity index 94% rename from internal/apps/agent/nginx/runtime_access.go rename to backend/OpenFlare/plugins/agent/nginx/runtime_access.go index 320e734e..791050be 100644 --- a/internal/apps/agent/nginx/runtime_access.go +++ b/backend/OpenFlare/plugins/agent/nginx/runtime_access.go @@ -4,7 +4,7 @@ package nginx import ( - "github.com/Rain-kl/Wavelet/internal/apps/agent/runtimeuser" + "Wavelet/OpenFlare/plugins/agent/runtimeuser" ) // OpenFlareRuntimeUser is the shared OS account for the agent process and diff --git a/internal/apps/agent/nginx/runtime_access_test.go b/backend/OpenFlare/plugins/agent/nginx/runtime_access_test.go similarity index 100% rename from internal/apps/agent/nginx/runtime_access_test.go rename to backend/OpenFlare/plugins/agent/nginx/runtime_access_test.go diff --git a/internal/apps/agent/nginx/sw_assets.go b/backend/OpenFlare/plugins/agent/nginx/sw_assets.go similarity index 98% rename from internal/apps/agent/nginx/sw_assets.go rename to backend/OpenFlare/plugins/agent/nginx/sw_assets.go index 4462fc49..bbf3f0d3 100644 --- a/internal/apps/agent/nginx/sw_assets.go +++ b/backend/OpenFlare/plugins/agent/nginx/sw_assets.go @@ -4,7 +4,7 @@ package nginx import ( - "github.com/Rain-kl/Wavelet/internal/apps/agent/protocol" + "Wavelet/OpenFlare/plugins/agent/protocol" ) const openRestySWRuntimeLua = `local _M = {} diff --git a/internal/apps/agent/nginx/sw_assets_test.go b/backend/OpenFlare/plugins/agent/nginx/sw_assets_test.go similarity index 100% rename from internal/apps/agent/nginx/sw_assets_test.go rename to backend/OpenFlare/plugins/agent/nginx/sw_assets_test.go diff --git a/internal/apps/agent/nginx/sw_runtime_spec.lua b/backend/OpenFlare/plugins/agent/nginx/sw_runtime_spec.lua similarity index 100% rename from internal/apps/agent/nginx/sw_runtime_spec.lua rename to backend/OpenFlare/plugins/agent/nginx/sw_runtime_spec.lua diff --git a/internal/apps/agent/nginx/waf_assets.go b/backend/OpenFlare/plugins/agent/nginx/waf_assets.go similarity index 96% rename from internal/apps/agent/nginx/waf_assets.go rename to backend/OpenFlare/plugins/agent/nginx/waf_assets.go index ba41afb5..3ba3e5ba 100644 --- a/internal/apps/agent/nginx/waf_assets.go +++ b/backend/OpenFlare/plugins/agent/nginx/waf_assets.go @@ -6,7 +6,7 @@ package nginx import ( _ "embed" - "github.com/Rain-kl/Wavelet/internal/apps/agent/protocol" + "Wavelet/OpenFlare/plugins/agent/protocol" ) //go:embed waf_runtime.lua diff --git a/internal/apps/agent/nginx/waf_assets_test.go b/backend/OpenFlare/plugins/agent/nginx/waf_assets_test.go similarity index 100% rename from internal/apps/agent/nginx/waf_assets_test.go rename to backend/OpenFlare/plugins/agent/nginx/waf_assets_test.go diff --git a/internal/apps/agent/nginx/waf_ip_groups.lua b/backend/OpenFlare/plugins/agent/nginx/waf_ip_groups.lua similarity index 100% rename from internal/apps/agent/nginx/waf_ip_groups.lua rename to backend/OpenFlare/plugins/agent/nginx/waf_ip_groups.lua diff --git a/internal/apps/agent/nginx/waf_ip_groups_spec.lua b/backend/OpenFlare/plugins/agent/nginx/waf_ip_groups_spec.lua similarity index 100% rename from internal/apps/agent/nginx/waf_ip_groups_spec.lua rename to backend/OpenFlare/plugins/agent/nginx/waf_ip_groups_spec.lua diff --git a/internal/apps/agent/nginx/waf_runtime.lua b/backend/OpenFlare/plugins/agent/nginx/waf_runtime.lua similarity index 100% rename from internal/apps/agent/nginx/waf_runtime.lua rename to backend/OpenFlare/plugins/agent/nginx/waf_runtime.lua diff --git a/internal/apps/agent/nginx/waf_runtime_spec.lua b/backend/OpenFlare/plugins/agent/nginx/waf_runtime_spec.lua similarity index 100% rename from internal/apps/agent/nginx/waf_runtime_spec.lua rename to backend/OpenFlare/plugins/agent/nginx/waf_runtime_spec.lua diff --git a/internal/apps/agent/observability/collector.go b/backend/OpenFlare/plugins/agent/observability/collector.go similarity index 95% rename from internal/apps/agent/observability/collector.go rename to backend/OpenFlare/plugins/agent/observability/collector.go index ce991aba..4793e57c 100644 --- a/internal/apps/agent/observability/collector.go +++ b/backend/OpenFlare/plugins/agent/observability/collector.go @@ -13,10 +13,10 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/apps/agent/config" - "github.com/Rain-kl/Wavelet/internal/apps/agent/protocol" - "github.com/Rain-kl/Wavelet/internal/apps/agent/state" - edgeobs "github.com/Rain-kl/Wavelet/internal/apps/edge/observability" + "Wavelet/OpenFlare/plugins/agent/config" + "Wavelet/OpenFlare/plugins/agent/protocol" + "Wavelet/OpenFlare/plugins/agent/state" + edgeobs "Wavelet/OpenFlare/share/edge/observability" ) const nodeHealthEventInitialCapacity = 2 diff --git a/internal/apps/agent/observability/openresty_local.go b/backend/OpenFlare/plugins/agent/observability/openresty_local.go similarity index 97% rename from internal/apps/agent/observability/openresty_local.go rename to backend/OpenFlare/plugins/agent/observability/openresty_local.go index d77355e2..8217986c 100644 --- a/internal/apps/agent/observability/openresty_local.go +++ b/backend/OpenFlare/plugins/agent/observability/openresty_local.go @@ -10,7 +10,7 @@ import ( "net/http" "time" - "github.com/Rain-kl/Wavelet/internal/apps/agent/config" + "Wavelet/OpenFlare/plugins/agent/config" ) const openRestyObservabilityPath = "/openflare/observability" diff --git a/internal/apps/agent/observability/openresty_local_test.go b/backend/OpenFlare/plugins/agent/observability/openresty_local_test.go similarity index 96% rename from internal/apps/agent/observability/openresty_local_test.go rename to backend/OpenFlare/plugins/agent/observability/openresty_local_test.go index 20fbae76..22ed4450 100644 --- a/internal/apps/agent/observability/openresty_local_test.go +++ b/backend/OpenFlare/plugins/agent/observability/openresty_local_test.go @@ -9,7 +9,7 @@ import ( "net/http/httptest" "testing" - "github.com/Rain-kl/Wavelet/internal/apps/agent/config" + "Wavelet/OpenFlare/plugins/agent/config" ) func TestCollectEdgeHealth(t *testing.T) { diff --git a/internal/apps/agent/observability/traffic.go b/backend/OpenFlare/plugins/agent/observability/traffic.go similarity index 97% rename from internal/apps/agent/observability/traffic.go rename to backend/OpenFlare/plugins/agent/observability/traffic.go index 5bd22853..1973cc53 100644 --- a/internal/apps/agent/observability/traffic.go +++ b/backend/OpenFlare/plugins/agent/observability/traffic.go @@ -15,9 +15,9 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/apps/agent/config" - "github.com/Rain-kl/Wavelet/internal/apps/agent/protocol" - "github.com/Rain-kl/Wavelet/internal/apps/agent/state" + "Wavelet/OpenFlare/plugins/agent/config" + "Wavelet/OpenFlare/plugins/agent/protocol" + "Wavelet/OpenFlare/plugins/agent/state" ) type accessLogRecord struct { diff --git a/internal/apps/agent/observability/traffic_test.go b/backend/OpenFlare/plugins/agent/observability/traffic_test.go similarity index 97% rename from internal/apps/agent/observability/traffic_test.go rename to backend/OpenFlare/plugins/agent/observability/traffic_test.go index dd8d658b..886b093f 100644 --- a/internal/apps/agent/observability/traffic_test.go +++ b/backend/OpenFlare/plugins/agent/observability/traffic_test.go @@ -9,8 +9,8 @@ import ( "strings" "testing" - "github.com/Rain-kl/Wavelet/internal/apps/agent/config" - "github.com/Rain-kl/Wavelet/internal/apps/agent/state" + "Wavelet/OpenFlare/plugins/agent/config" + "Wavelet/OpenFlare/plugins/agent/state" ) func TestCollectAccessLogsReturnsFactsOnly(t *testing.T) { diff --git a/backend/OpenFlare/plugins/agent/plugin.go b/backend/OpenFlare/plugins/agent/plugin.go new file mode 100644 index 00000000..5142110b --- /dev/null +++ b/backend/OpenFlare/plugins/agent/plugin.go @@ -0,0 +1,198 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package agent 装载 OpenFlare 边缘 agent 插件:openresty/WAF 运行时管理、 +// 心跳同步、配置下发与 WebSocket 控制通道,以 Cordis 驱动形态在 profile "agent" 下运行。 +package agent + +import ( + "context" + "errors" + "fmt" + "log/slog" + + "Wavelet/OpenFlare/plugins/agent/agent" + "Wavelet/OpenFlare/plugins/agent/config" + "Wavelet/OpenFlare/plugins/agent/geoipupdate" + "Wavelet/OpenFlare/plugins/agent/heartbeat" + "Wavelet/OpenFlare/plugins/agent/httpclient" + "Wavelet/OpenFlare/plugins/agent/nginx" + "Wavelet/OpenFlare/plugins/agent/runtimeuser" + "Wavelet/OpenFlare/plugins/agent/state" + syncservice "Wavelet/OpenFlare/plugins/agent/sync" + "Wavelet/OpenFlare/plugins/agent/updater" + "Wavelet/OpenFlare/plugins/agent/wsclient" + "Wavelet/core" + "Wavelet/pkg/util" +) + +// DriverTypeAgent 是边缘 agent 守护进程专属的驱动类型: +// 只有 profile 与之相等时内核才会 Start/Stop 本插件。 +const DriverTypeAgent core.DriverType = "agent" + +// Plugin 实现 core.Plugin 与 core.Driver,承载 agent 进程的全部装配与生命周期。 +type Plugin struct { + configPath string + + runner *agent.Runner + geo *geoipupdate.Updater + done chan error + started bool +} + +// New 创建 agent 插件,configPath 指向其 JSON 配置文件。 +func New(configPath string) *Plugin { + return &Plugin{configPath: configPath, done: make(chan error, 1)} +} + +// Name 返回插件标识。 +func (p *Plugin) Name() string { return "agent" } + +// Type 返回驱动类型。 +func (p *Plugin) Type() core.DriverType { return DriverTypeAgent } + +// Apply 加载配置、确保运行环境、装配各服务并注册驱动。 +func (p *Plugin) Apply(ctx *core.Context) error { + cfg, err := config.Load(p.configPath) + if err != nil { + return fmt.Errorf("load agent config: %w", err) + } + if err := runtimeuser.EnsureProcessUser(); err != nil { + return fmt.Errorf("ensure runtime user: %w", err) + } + if err := runtimeuser.EnsurePathOwnership( + cfg.DataDir, runtimeuser.DefaultDirPerm, runtimeuser.DefaultFilePerm, + ); err != nil { + return fmt.Errorf("ensure data dir ownership, data_dir=%s: %w", cfg.DataDir, err) + } + + nginxOptions := nginx.ExecutorOptions{ + NginxPath: cfg.OpenrestyPath, + MainConfigPath: cfg.MainConfigPath, + RouteConfigPath: cfg.RouteConfigPath, + CertDir: cfg.CertDir, + NginxCertDir: cfg.OpenrestyCertDir, + LuaDir: cfg.LuaDir, + NginxLuaDir: cfg.OpenrestyLuaDir, + OpenrestyObservabilityPort: cfg.OpenrestyObservabilityPort, + } + cfg.ExtVersion = nginx.DetectVersion(context.Background(), nginxOptions) + logConfigLoaded(cfg) + + runtimeManager := newRuntimeManager(cfg, nginxOptions) + if err := runtimeManager.EnsureLuaAssets(); err != nil { + return fmt.Errorf("ensure managed lua assets: %w", err) + } + + client := httpclient.New(cfg.ServerURL, cfg.InitialAuthToken(), cfg.RequestTimeout.Duration()) + wsClient := wsclient.New(cfg.ServerURL, cfg.InitialAuthToken(), cfg.RequestTimeout.Duration()) + stateStore := state.NewStore(cfg.StatePath) + observabilityBuffer := state.NewObservabilityBufferStore(cfg.ObservabilityBufferPath) + syncService := syncservice.New(client, runtimeManager, stateStore) + syncService.SetPagesDir(cfg.PagesDir) + heartbeatService := heartbeat.New(client) + updateService := updater.New() + + p.runner = &agent.Runner{ + Config: cfg, + StateStore: stateStore, + HeartbeatCycle: &heartbeat.Cycle{ + Config: cfg, + StateStore: stateStore, + ObservabilityBuffer: observabilityBuffer, + Heartbeat: heartbeatService, + Sync: syncService, + Updater: updateService, + }, + HeartbeatService: heartbeatService, + SyncService: syncService, + RuntimeManager: runtimeManager, + WebSocketService: wsClient, + } + p.geo = newGeoIPUpdater(cfg) + + return ctx.RegisterDriver(p) +} + +// Start 预热 GeoIP 库并拉起心跳/同步/控制通道主循环。 +// +// runner.Run 自身阻塞至 ctx 取消,因此放到独立 goroutine 中执行, +// 由 Stop 收敛其结果——内核要求驱动 Start 不得阻塞。 +func (p *Plugin) Start(ctx context.Context) error { + if err := p.geo.EnsureInitialDatabases(ctx); err != nil { + slog.Warn("failed to prepare GeoIP databases before agent startup", "error", err) + } + util.Go(func() { p.geo.Run(ctx) }) + + util.Go(func() { p.done <- p.runner.Run(ctx) }) + p.started = true + slog.Info("agent process started") + return nil +} + +// Stop 等待主循环退出并返回其结果;ctx 超时时报告超时而非静默成功。 +func (p *Plugin) Stop(ctx context.Context) error { + if !p.started { + return nil + } + select { + case err := <-p.done: + p.started = false + if err != nil && !errors.Is(err, context.Canceled) { + return err + } + slog.Info("agent process stopped") + return nil + case <-ctx.Done(): + p.started = false + return fmt.Errorf("agent shutdown timeout: %w", ctx.Err()) + } +} + +// newGeoIPUpdater 装配国家/城市两套 mmdb 的下载与周期更新。 +func newGeoIPUpdater(cfg *config.Config) *geoipupdate.Updater { + return &geoipupdate.Updater{ + MMDBPath: cfg.MMDBPath, + DownloadURL: cfg.MMDBDownloadURL, + CityMMDBPath: cfg.CityMMDBPath, + CityDownloadURL: cfg.CityMMDBDownloadURL, + UpdateInterval: cfg.MMDBUpdateInterval.Duration(), + } +} + +// newRuntimeManager 构造 openresty 配置与证书/Lua 资产的运行时管理器。 +func newRuntimeManager(cfg *config.Config, nginxOptions nginx.ExecutorOptions) *nginx.Manager { + return &nginx.Manager{ + MainConfigPath: cfg.MainConfigPath, + RouteConfigPath: cfg.RouteConfigPath, + AccessLogPath: cfg.AccessLogPath, + CertDir: cfg.CertDir, + NginxCertDir: cfg.OpenrestyCertDir, + LuaDir: cfg.LuaDir, + NginxLuaDir: cfg.OpenrestyLuaDir, + RuntimeConfigDir: cfg.RuntimeConfigDir, + MMDBPath: cfg.MMDBPath, + CityMMDBPath: cfg.CityMMDBPath, + PagesDir: cfg.PagesDir, + OpenrestyObservabilityListen: nginx.ObservabilityListenAddress(cfg.OpenrestyObservabilityPort), + OpenrestyObservabilityPort: cfg.OpenrestyObservabilityPort, + OpenrestyResolverDirective: "", + Executor: nginx.NewExecutor(nginxOptions), + } +} + +func logConfigLoaded(cfg *config.Config) { + slog.Info("agent config loaded", + "server", cfg.ServerURL, + "node", cfg.NodeName, + "ip", cfg.NodeIP, + "heartbeat_interval", cfg.HeartbeatInterval, + "route_config", cfg.RouteConfigPath, + "access_log", cfg.AccessLogPath, + "cert_dir", cfg.CertDir, + "lua_dir", cfg.LuaDir, + "runtime_config_dir", cfg.RuntimeConfigDir, + "mmdb_path", cfg.MMDBPath, + "city_mmdb_path", cfg.CityMMDBPath, + ) +} diff --git a/backend/OpenFlare/plugins/agent/plugin_test.go b/backend/OpenFlare/plugins/agent/plugin_test.go new file mode 100644 index 00000000..bf3d83fe --- /dev/null +++ b/backend/OpenFlare/plugins/agent/plugin_test.go @@ -0,0 +1,52 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package agent + +import ( + "context" + "testing" + "time" + + "Wavelet/OpenFlare/plugins/agent/config" + "Wavelet/core" +) + +func TestPluginIdentity(t *testing.T) { + p := New("./agent.json") + if got := p.Name(); got != "agent" { + t.Errorf("Name() = %q, want %q", got, "agent") + } + if got := p.Type(); got != DriverTypeAgent { + t.Errorf("Type() = %q, want %q", got, DriverTypeAgent) + } + // 驱动类型必须等于 profile 字符串,否则内核的 profile 过滤会漏掉本驱动。 + if got, want := string(p.Type()), string(core.Profile("agent")); got != want { + t.Errorf("driver type %q must equal profile %q", got, want) + } +} + +func TestApplyFailsOnMissingConfig(t *testing.T) { + ctx := core.NewContext(context.Background()) + if err := New("./does-not-exist.json").Apply(ctx); err == nil { + t.Fatal("Apply(missing config) error = nil, want error") + } +} + +func TestNewGeoIPUpdaterWiresCountryAndCity(t *testing.T) { + cfg := &config.Config{ + MMDBPath: "/data/GeoLite2-Country.mmdb", + MMDBDownloadURL: "https://geo.example/GeoLite2-Country.mmdb", + CityMMDBPath: "/data/GeoLite2-City.mmdb", + CityMMDBDownloadURL: "https://geo.example/GeoLite2-City.mmdb", + MMDBUpdateInterval: config.MillisecondDuration(time.Hour), + } + got := newGeoIPUpdater(cfg) + if got.MMDBPath != cfg.MMDBPath || got.DownloadURL != cfg.MMDBDownloadURL || + got.CityMMDBPath != cfg.CityMMDBPath || got.CityDownloadURL != cfg.CityMMDBDownloadURL { + t.Fatalf("GeoIP updater wiring incomplete: %#v", got) + } + if want := cfg.MMDBUpdateInterval.Duration(); got.UpdateInterval != want { + t.Errorf("UpdateInterval = %v, want %v", got.UpdateInterval, want) + } +} diff --git a/internal/apps/agent/protocol/alias.go b/backend/OpenFlare/plugins/agent/protocol/alias.go similarity index 98% rename from internal/apps/agent/protocol/alias.go rename to backend/OpenFlare/plugins/agent/protocol/alias.go index 653670b2..856fe54f 100644 --- a/internal/apps/agent/protocol/alias.go +++ b/backend/OpenFlare/plugins/agent/protocol/alias.go @@ -4,7 +4,7 @@ // Package protocol defines type aliases and constants for the agent protocol. package protocol -import pkgprotocol "github.com/Rain-kl/Wavelet/pkg/protocol" +import pkgprotocol "Wavelet/OpenFlare/share/protocol" // APIResponse is an alias for pkgprotocol.APIResponse. type APIResponse[T any] = pkgprotocol.APIResponse[T] diff --git a/internal/apps/agent/runtimeuser/runtimeuser.go b/backend/OpenFlare/plugins/agent/runtimeuser/runtimeuser.go similarity index 98% rename from internal/apps/agent/runtimeuser/runtimeuser.go rename to backend/OpenFlare/plugins/agent/runtimeuser/runtimeuser.go index 691f8030..ceb9d9fd 100644 --- a/internal/apps/agent/runtimeuser/runtimeuser.go +++ b/backend/OpenFlare/plugins/agent/runtimeuser/runtimeuser.go @@ -15,7 +15,7 @@ import ( "strconv" "strings" - openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty" + openrestyrender "Wavelet/OpenFlare/share/render/openresty" ) // Name is the dedicated service account shared by the agent and OpenResty workers. diff --git a/internal/apps/agent/runtimeuser/runtimeuser_test.go b/backend/OpenFlare/plugins/agent/runtimeuser/runtimeuser_test.go similarity index 100% rename from internal/apps/agent/runtimeuser/runtimeuser_test.go rename to backend/OpenFlare/plugins/agent/runtimeuser/runtimeuser_test.go diff --git a/internal/apps/agent/runtimeuser/runtimeuser_unix.go b/backend/OpenFlare/plugins/agent/runtimeuser/runtimeuser_unix.go similarity index 100% rename from internal/apps/agent/runtimeuser/runtimeuser_unix.go rename to backend/OpenFlare/plugins/agent/runtimeuser/runtimeuser_unix.go diff --git a/internal/apps/agent/state/observability_buffer.go b/backend/OpenFlare/plugins/agent/state/observability_buffer.go similarity index 99% rename from internal/apps/agent/state/observability_buffer.go rename to backend/OpenFlare/plugins/agent/state/observability_buffer.go index a369f9a0..cd6b92f1 100644 --- a/internal/apps/agent/state/observability_buffer.go +++ b/backend/OpenFlare/plugins/agent/state/observability_buffer.go @@ -14,7 +14,7 @@ import ( "strings" "sync" - "github.com/Rain-kl/Wavelet/internal/apps/agent/protocol" + "Wavelet/OpenFlare/plugins/agent/protocol" ) const observabilityBufferWindowSeconds = 60 diff --git a/internal/apps/agent/state/observability_buffer_test.go b/backend/OpenFlare/plugins/agent/state/observability_buffer_test.go similarity index 99% rename from internal/apps/agent/state/observability_buffer_test.go rename to backend/OpenFlare/plugins/agent/state/observability_buffer_test.go index 19fb527c..27b9b335 100644 --- a/internal/apps/agent/state/observability_buffer_test.go +++ b/backend/OpenFlare/plugins/agent/state/observability_buffer_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "github.com/Rain-kl/Wavelet/internal/apps/agent/protocol" + "Wavelet/OpenFlare/plugins/agent/protocol" ) func TestObservabilityBufferStoreUpsertReplayAndAck(t *testing.T) { diff --git a/internal/apps/agent/state/state.go b/backend/OpenFlare/plugins/agent/state/state.go similarity index 100% rename from internal/apps/agent/state/state.go rename to backend/OpenFlare/plugins/agent/state/state.go diff --git a/internal/apps/agent/state/state_test.go b/backend/OpenFlare/plugins/agent/state/state_test.go similarity index 100% rename from internal/apps/agent/state/state_test.go rename to backend/OpenFlare/plugins/agent/state/state_test.go diff --git a/internal/apps/agent/sync/pages.go b/backend/OpenFlare/plugins/agent/sync/pages.go similarity index 99% rename from internal/apps/agent/sync/pages.go rename to backend/OpenFlare/plugins/agent/sync/pages.go index 840eee8c..c4d19a7c 100644 --- a/internal/apps/agent/sync/pages.go +++ b/backend/OpenFlare/plugins/agent/sync/pages.go @@ -18,9 +18,9 @@ import ( "strconv" "strings" - "github.com/Rain-kl/Wavelet/internal/apps/agent/protocol" - "github.com/Rain-kl/Wavelet/internal/apps/agent/state" - "github.com/Rain-kl/Wavelet/pkg/pagesarchive" + "Wavelet/OpenFlare/plugins/agent/protocol" + "Wavelet/OpenFlare/plugins/agent/state" + "Wavelet/OpenFlare/share/pagesarchive" ) const ( diff --git a/internal/apps/agent/sync/pages_stream_test.go b/backend/OpenFlare/plugins/agent/sync/pages_stream_test.go similarity index 99% rename from internal/apps/agent/sync/pages_stream_test.go rename to backend/OpenFlare/plugins/agent/sync/pages_stream_test.go index 5db5ae91..72a532a0 100644 --- a/internal/apps/agent/sync/pages_stream_test.go +++ b/backend/OpenFlare/plugins/agent/sync/pages_stream_test.go @@ -11,8 +11,8 @@ import ( "strings" "testing" - "github.com/Rain-kl/Wavelet/internal/apps/agent/protocol" - "github.com/Rain-kl/Wavelet/internal/apps/agent/state" + "Wavelet/OpenFlare/plugins/agent/protocol" + "Wavelet/OpenFlare/plugins/agent/state" ) func TestEnsurePagesProjectRejectsMetadataBeyondAgentCapsBeforeDownload(t *testing.T) { diff --git a/internal/apps/agent/sync/service.go b/backend/OpenFlare/plugins/agent/sync/service.go similarity index 98% rename from internal/apps/agent/sync/service.go rename to backend/OpenFlare/plugins/agent/sync/service.go index ee5d4ce1..80a31da6 100644 --- a/internal/apps/agent/sync/service.go +++ b/backend/OpenFlare/plugins/agent/sync/service.go @@ -17,11 +17,11 @@ import ( "strings" "sync" - openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty" + openrestyrender "Wavelet/OpenFlare/share/render/openresty" - "github.com/Rain-kl/Wavelet/internal/apps/agent/nginx" - "github.com/Rain-kl/Wavelet/internal/apps/agent/protocol" - "github.com/Rain-kl/Wavelet/internal/apps/agent/state" + "Wavelet/OpenFlare/plugins/agent/nginx" + "Wavelet/OpenFlare/plugins/agent/protocol" + "Wavelet/OpenFlare/plugins/agent/state" ) // Apply result constants indicate the outcome reported back to the server. diff --git a/internal/apps/agent/sync/service_test.go b/backend/OpenFlare/plugins/agent/sync/service_test.go similarity index 99% rename from internal/apps/agent/sync/service_test.go rename to backend/OpenFlare/plugins/agent/sync/service_test.go index d8d02745..0719289e 100644 --- a/internal/apps/agent/sync/service_test.go +++ b/backend/OpenFlare/plugins/agent/sync/service_test.go @@ -17,10 +17,10 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/apps/agent/nginx" - "github.com/Rain-kl/Wavelet/internal/apps/agent/protocol" - "github.com/Rain-kl/Wavelet/internal/apps/agent/state" - "github.com/Rain-kl/Wavelet/pkg/pagesarchive" + "Wavelet/OpenFlare/plugins/agent/nginx" + "Wavelet/OpenFlare/plugins/agent/protocol" + "Wavelet/OpenFlare/plugins/agent/state" + "Wavelet/OpenFlare/share/pagesarchive" ) type fakeExecutor struct { diff --git a/internal/apps/agent/sync/sync_helpers.go b/backend/OpenFlare/plugins/agent/sync/sync_helpers.go similarity index 98% rename from internal/apps/agent/sync/sync_helpers.go rename to backend/OpenFlare/plugins/agent/sync/sync_helpers.go index e4973267..7dde7940 100644 --- a/internal/apps/agent/sync/sync_helpers.go +++ b/backend/OpenFlare/plugins/agent/sync/sync_helpers.go @@ -8,9 +8,9 @@ import ( "log/slog" "strings" - "github.com/Rain-kl/Wavelet/internal/apps/agent/nginx" - "github.com/Rain-kl/Wavelet/internal/apps/agent/protocol" - "github.com/Rain-kl/Wavelet/internal/apps/agent/state" + "Wavelet/OpenFlare/plugins/agent/nginx" + "Wavelet/OpenFlare/plugins/agent/protocol" + "Wavelet/OpenFlare/plugins/agent/state" ) func syncMode(startup bool) string { diff --git a/internal/apps/agent/updater/updater.go b/backend/OpenFlare/plugins/agent/updater/updater.go similarity index 83% rename from internal/apps/agent/updater/updater.go rename to backend/OpenFlare/plugins/agent/updater/updater.go index e8749d32..929630cd 100644 --- a/internal/apps/agent/updater/updater.go +++ b/backend/OpenFlare/plugins/agent/updater/updater.go @@ -5,8 +5,8 @@ package updater import ( - "github.com/Rain-kl/Wavelet/internal/apps/agent/config" - edgeupdater "github.com/Rain-kl/Wavelet/internal/apps/edge/updater" + "Wavelet/OpenFlare/plugins/agent/config" + edgeupdater "Wavelet/OpenFlare/share/edge/updater" ) // Service is an alias for the edge updater service type used by the agent. diff --git a/internal/apps/agent/wsclient/client.go b/backend/OpenFlare/plugins/agent/wsclient/client.go similarity index 91% rename from internal/apps/agent/wsclient/client.go rename to backend/OpenFlare/plugins/agent/wsclient/client.go index f25e2149..385b1f41 100644 --- a/internal/apps/agent/wsclient/client.go +++ b/backend/OpenFlare/plugins/agent/wsclient/client.go @@ -8,8 +8,8 @@ import ( "context" "time" - "github.com/Rain-kl/Wavelet/internal/apps/agent/protocol" - edgews "github.com/Rain-kl/Wavelet/internal/apps/edge/wsclient" + "Wavelet/OpenFlare/plugins/agent/protocol" + edgews "Wavelet/OpenFlare/share/edge/wsclient" ) // WSMessage is an alias for the WebSocket message type. diff --git a/internal/apps/flared/config/config.go b/backend/OpenFlare/plugins/flared/config/config.go similarity index 98% rename from internal/apps/flared/config/config.go rename to backend/OpenFlare/plugins/flared/config/config.go index 0e61a88a..87458ccd 100644 --- a/internal/apps/flared/config/config.go +++ b/backend/OpenFlare/plugins/flared/config/config.go @@ -12,7 +12,7 @@ import ( "strings" "time" - edgeconfig "github.com/Rain-kl/Wavelet/internal/apps/edge/config" + edgeconfig "Wavelet/OpenFlare/share/edge/config" ) const ( diff --git a/internal/apps/flared/config/version.go b/backend/OpenFlare/plugins/flared/config/version.go similarity index 100% rename from internal/apps/flared/config/version.go rename to backend/OpenFlare/plugins/flared/config/version.go diff --git a/internal/apps/flared/flared/runner.go b/backend/OpenFlare/plugins/flared/flared/runner.go similarity index 82% rename from internal/apps/flared/flared/runner.go rename to backend/OpenFlare/plugins/flared/flared/runner.go index 5253964d..a715e3bf 100644 --- a/internal/apps/flared/flared/runner.go +++ b/backend/OpenFlare/plugins/flared/flared/runner.go @@ -8,13 +8,13 @@ import ( "context" "log/slog" - edgerunner "github.com/Rain-kl/Wavelet/internal/apps/edge/runner" - "github.com/Rain-kl/Wavelet/internal/apps/flared/config" - "github.com/Rain-kl/Wavelet/internal/apps/flared/frpc" - "github.com/Rain-kl/Wavelet/internal/apps/flared/heartbeat" - "github.com/Rain-kl/Wavelet/internal/apps/flared/httpclient" - "github.com/Rain-kl/Wavelet/internal/apps/flared/sync" - "github.com/Rain-kl/Wavelet/internal/apps/flared/wsclient" + "Wavelet/OpenFlare/plugins/flared/config" + "Wavelet/OpenFlare/plugins/flared/frpc" + "Wavelet/OpenFlare/plugins/flared/heartbeat" + "Wavelet/OpenFlare/plugins/flared/httpclient" + "Wavelet/OpenFlare/plugins/flared/sync" + "Wavelet/OpenFlare/plugins/flared/wsclient" + edgerunner "Wavelet/OpenFlare/share/edge/runner" ) // Runner is the top-level orchestrator for the flared agent. It wires together diff --git a/internal/apps/flared/frpc/manager.go b/backend/OpenFlare/plugins/flared/frpc/manager.go similarity index 98% rename from internal/apps/flared/frpc/manager.go rename to backend/OpenFlare/plugins/flared/frpc/manager.go index b6053c50..d4d2933b 100644 --- a/internal/apps/flared/frpc/manager.go +++ b/backend/OpenFlare/plugins/flared/frpc/manager.go @@ -20,9 +20,9 @@ import ( "syscall" "time" - "github.com/Rain-kl/Wavelet/internal/apps/flared/config" - service "github.com/Rain-kl/Wavelet/pkg/protocol" - "github.com/Rain-kl/Wavelet/pkg/util" + "Wavelet/OpenFlare/plugins/flared/config" + service "Wavelet/OpenFlare/share/protocol" + "Wavelet/pkg/util" ) const ( diff --git a/internal/apps/flared/frpc/manager_test.go b/backend/OpenFlare/plugins/flared/frpc/manager_test.go similarity index 98% rename from internal/apps/flared/frpc/manager_test.go rename to backend/OpenFlare/plugins/flared/frpc/manager_test.go index 88e6af4e..d05a9fc8 100644 --- a/internal/apps/flared/frpc/manager_test.go +++ b/backend/OpenFlare/plugins/flared/frpc/manager_test.go @@ -14,8 +14,8 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/apps/flared/config" - service "github.com/Rain-kl/Wavelet/pkg/protocol" + "Wavelet/OpenFlare/plugins/flared/config" + service "Wavelet/OpenFlare/share/protocol" ) // Helper to write control file for the dummy script diff --git a/internal/apps/flared/heartbeat/service.go b/backend/OpenFlare/plugins/flared/heartbeat/service.go similarity index 83% rename from internal/apps/flared/heartbeat/service.go rename to backend/OpenFlare/plugins/flared/heartbeat/service.go index 3441b14d..80134923 100644 --- a/internal/apps/flared/heartbeat/service.go +++ b/backend/OpenFlare/plugins/flared/heartbeat/service.go @@ -8,13 +8,13 @@ import ( "context" "log/slog" - edgeheartbeat "github.com/Rain-kl/Wavelet/internal/apps/edge/heartbeat" - "github.com/Rain-kl/Wavelet/internal/apps/edge/nodeip" - "github.com/Rain-kl/Wavelet/internal/apps/flared/config" - "github.com/Rain-kl/Wavelet/internal/apps/flared/frpc" - "github.com/Rain-kl/Wavelet/internal/apps/flared/httpclient" - "github.com/Rain-kl/Wavelet/internal/apps/flared/updater" - service "github.com/Rain-kl/Wavelet/pkg/protocol" + "Wavelet/OpenFlare/plugins/flared/config" + "Wavelet/OpenFlare/plugins/flared/frpc" + "Wavelet/OpenFlare/plugins/flared/httpclient" + "Wavelet/OpenFlare/plugins/flared/updater" + edgeheartbeat "Wavelet/OpenFlare/share/edge/heartbeat" + "Wavelet/OpenFlare/share/edge/nodeip" + service "Wavelet/OpenFlare/share/protocol" ) // Service sends periodic heartbeat payloads and applies tunnel settings from responses. diff --git a/internal/apps/flared/httpclient/client.go b/backend/OpenFlare/plugins/flared/httpclient/client.go similarity index 94% rename from internal/apps/flared/httpclient/client.go rename to backend/OpenFlare/plugins/flared/httpclient/client.go index b46e5ce4..796b32ff 100644 --- a/internal/apps/flared/httpclient/client.go +++ b/backend/OpenFlare/plugins/flared/httpclient/client.go @@ -8,8 +8,8 @@ import ( "context" "time" - edgehttp "github.com/Rain-kl/Wavelet/internal/apps/edge/httpclient" - service "github.com/Rain-kl/Wavelet/pkg/protocol" + edgehttp "Wavelet/OpenFlare/share/edge/httpclient" + service "Wavelet/OpenFlare/share/protocol" ) // APIResponse is an alias for service.APIResponse. diff --git a/backend/OpenFlare/plugins/flared/plugin.go b/backend/OpenFlare/plugins/flared/plugin.go new file mode 100644 index 00000000..6f1554c6 --- /dev/null +++ b/backend/OpenFlare/plugins/flared/plugin.go @@ -0,0 +1,106 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package flared 装载 OpenFlare 隧道客户端插件:frpc 进程管理、配置同步与心跳上报, +// 以 Cordis 驱动形态在 profile "flared" 下运行。 +package flared + +import ( + "context" + "errors" + "fmt" + "log/slog" + + "Wavelet/OpenFlare/plugins/flared/config" + flaredrunner "Wavelet/OpenFlare/plugins/flared/flared" + "Wavelet/OpenFlare/plugins/flared/frpc" + "Wavelet/OpenFlare/plugins/flared/heartbeat" + "Wavelet/OpenFlare/plugins/flared/httpclient" + "Wavelet/OpenFlare/plugins/flared/sync" + "Wavelet/OpenFlare/plugins/flared/wsclient" + "Wavelet/core" + "Wavelet/pkg/util" +) + +// DriverTypeFlared 是隧道客户端守护进程专属的驱动类型。 +const DriverTypeFlared core.DriverType = "flared" + +// Plugin 实现 core.Plugin 与 core.Driver。 +type Plugin struct { + configPath string + + runner *flaredrunner.Runner + done chan error + started bool +} + +// New 创建 flared 插件,configPath 指向其 JSON 配置文件。 +func New(configPath string) *Plugin { + return &Plugin{configPath: configPath, done: make(chan error, 1)} +} + +// Name 返回插件标识。 +func (p *Plugin) Name() string { return "flared" } + +// Type 返回驱动类型。 +func (p *Plugin) Type() core.DriverType { return DriverTypeFlared } + +// Apply 加载配置、恢复 frpc 状态并装配各服务,然后注册驱动。 +func (p *Plugin) Apply(ctx *core.Context) error { + cfg, err := config.Load(p.configPath) + if err != nil { + return fmt.Errorf("load flared config: %w", err) + } + slog.Info("flared config loaded", + "server", cfg.ServerURL, + "frpc_path", cfg.FrpcPath, + "data_dir", cfg.DataDir, + "heartbeat_interval", cfg.HeartbeatInterval, + "sync_interval", cfg.SyncInterval, + ) + + frpcManager := frpc.NewManager(cfg) + _ = frpcManager.LoadState() + slog.Info("detected frpc version", "version", frpcManager.GetVersion(context.Background())) + + httpClient := httpclient.New(cfg.ServerURL, cfg.InitialAuthToken(), cfg.RequestTimeout.Duration()) + wsClient := wsclient.New(cfg.ServerURL, cfg.InitialAuthToken(), cfg.RequestTimeout.Duration()) + + p.runner = &flaredrunner.Runner{ + Config: cfg, + FrpcManager: frpcManager, + HTTPClient: httpClient, + WebSocketService: wsClient, + HeartbeatService: heartbeat.New(httpClient, frpcManager, cfg), + SyncService: sync.New(httpClient, frpcManager, cfg), + } + + return ctx.RegisterDriver(p) +} + +// Start 拉起隧道主循环;runner.Run 阻塞至 ctx 取消,故置于独立 goroutine。 +func (p *Plugin) Start(ctx context.Context) error { + util.Go(func() { p.done <- p.runner.Run(ctx) }) + p.started = true + slog.Info("flared process started") + return nil +} + +// Stop 等待主循环退出并回传其结果。 +func (p *Plugin) Stop(ctx context.Context) error { + if !p.started { + return nil + } + select { + case err := <-p.done: + p.started = false + if err != nil && !errors.Is(err, context.Canceled) { + return err + } + slog.Info("flared process stopped") + return nil + case <-ctx.Done(): + p.started = false + return fmt.Errorf("flared shutdown timeout: %w", ctx.Err()) + } +} diff --git a/backend/OpenFlare/plugins/flared/plugin_test.go b/backend/OpenFlare/plugins/flared/plugin_test.go new file mode 100644 index 00000000..771b7441 --- /dev/null +++ b/backend/OpenFlare/plugins/flared/plugin_test.go @@ -0,0 +1,29 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package flared + +import ( + "context" + "testing" + + "Wavelet/core" +) + +func TestPluginIdentity(t *testing.T) { + p := New("./flared.json") + if got := p.Name(); got != "flared" { + t.Errorf("Name() = %q, want %q", got, "flared") + } + // 驱动类型必须等于 profile 字符串,否则内核的 profile 过滤会漏掉本驱动。 + if got, want := string(p.Type()), string(core.Profile("flared")); got != want { + t.Errorf("Type() = %q, want profile %q", got, want) + } +} + +func TestApplyFailsOnMissingConfig(t *testing.T) { + ctx := core.NewContext(context.Background()) + if err := New("./does-not-exist.json").Apply(ctx); err == nil { + t.Fatal("Apply(missing config) error = nil, want error") + } +} diff --git a/internal/apps/flared/sync/service.go b/backend/OpenFlare/plugins/flared/sync/service.go similarity index 92% rename from internal/apps/flared/sync/service.go rename to backend/OpenFlare/plugins/flared/sync/service.go index 0bc86e82..53eee1aa 100644 --- a/internal/apps/flared/sync/service.go +++ b/backend/OpenFlare/plugins/flared/sync/service.go @@ -9,10 +9,10 @@ import ( "log/slog" "time" - "github.com/Rain-kl/Wavelet/internal/apps/flared/config" - "github.com/Rain-kl/Wavelet/internal/apps/flared/frpc" - "github.com/Rain-kl/Wavelet/internal/apps/flared/httpclient" - service "github.com/Rain-kl/Wavelet/pkg/protocol" + "Wavelet/OpenFlare/plugins/flared/config" + "Wavelet/OpenFlare/plugins/flared/frpc" + "Wavelet/OpenFlare/plugins/flared/httpclient" + service "Wavelet/OpenFlare/share/protocol" ) // Service synchronizes tunnel configuration from the control plane to the local frpc manager. diff --git a/internal/apps/flared/updater/updater.go b/backend/OpenFlare/plugins/flared/updater/updater.go similarity index 81% rename from internal/apps/flared/updater/updater.go rename to backend/OpenFlare/plugins/flared/updater/updater.go index fa9ad6de..cd3d1a67 100644 --- a/internal/apps/flared/updater/updater.go +++ b/backend/OpenFlare/plugins/flared/updater/updater.go @@ -5,8 +5,8 @@ package updater import ( - edgeupdater "github.com/Rain-kl/Wavelet/internal/apps/edge/updater" - "github.com/Rain-kl/Wavelet/internal/apps/flared/config" + "Wavelet/OpenFlare/plugins/flared/config" + edgeupdater "Wavelet/OpenFlare/share/edge/updater" ) // Service is an alias for the edge updater Service. diff --git a/internal/apps/flared/wsclient/client.go b/backend/OpenFlare/plugins/flared/wsclient/client.go similarity index 94% rename from internal/apps/flared/wsclient/client.go rename to backend/OpenFlare/plugins/flared/wsclient/client.go index 9fef647f..93bfe44c 100644 --- a/internal/apps/flared/wsclient/client.go +++ b/backend/OpenFlare/plugins/flared/wsclient/client.go @@ -8,7 +8,7 @@ import ( "context" "time" - edgews "github.com/Rain-kl/Wavelet/internal/apps/edge/wsclient" + edgews "Wavelet/OpenFlare/share/edge/wsclient" ) // WSMessage is a WebSocket message exchanged with the control plane. diff --git a/internal/apps/relay/config/config.go b/backend/OpenFlare/plugins/relay/config/config.go similarity index 97% rename from internal/apps/relay/config/config.go rename to backend/OpenFlare/plugins/relay/config/config.go index 1d9ce32e..a0c71f39 100644 --- a/internal/apps/relay/config/config.go +++ b/backend/OpenFlare/plugins/relay/config/config.go @@ -12,8 +12,8 @@ import ( "strings" "time" - edgeconfig "github.com/Rain-kl/Wavelet/internal/apps/edge/config" - "github.com/Rain-kl/Wavelet/internal/apps/edge/nodeip" + edgeconfig "Wavelet/OpenFlare/share/edge/config" + "Wavelet/OpenFlare/share/edge/nodeip" ) const ( diff --git a/internal/apps/relay/config/version.go b/backend/OpenFlare/plugins/relay/config/version.go similarity index 100% rename from internal/apps/relay/config/version.go rename to backend/OpenFlare/plugins/relay/config/version.go diff --git a/internal/apps/relay/frps/manager.go b/backend/OpenFlare/plugins/relay/frps/manager.go similarity index 99% rename from internal/apps/relay/frps/manager.go rename to backend/OpenFlare/plugins/relay/frps/manager.go index 06faab0a..e97cbd58 100644 --- a/internal/apps/relay/frps/manager.go +++ b/backend/OpenFlare/plugins/relay/frps/manager.go @@ -20,7 +20,7 @@ import ( "syscall" "time" - service "github.com/Rain-kl/Wavelet/pkg/protocol" + service "Wavelet/OpenFlare/share/protocol" ) const ( diff --git a/internal/apps/relay/frps/manager_test.go b/backend/OpenFlare/plugins/relay/frps/manager_test.go similarity index 99% rename from internal/apps/relay/frps/manager_test.go rename to backend/OpenFlare/plugins/relay/frps/manager_test.go index 52b5a7c4..00b63312 100644 --- a/internal/apps/relay/frps/manager_test.go +++ b/backend/OpenFlare/plugins/relay/frps/manager_test.go @@ -14,7 +14,7 @@ import ( "testing" "time" - service "github.com/Rain-kl/Wavelet/pkg/protocol" + service "Wavelet/OpenFlare/share/protocol" ) // Helper to write control file for the dummy script diff --git a/internal/apps/relay/heartbeat/service.go b/backend/OpenFlare/plugins/relay/heartbeat/service.go similarity index 83% rename from internal/apps/relay/heartbeat/service.go rename to backend/OpenFlare/plugins/relay/heartbeat/service.go index 268f9569..d780c4c5 100644 --- a/internal/apps/relay/heartbeat/service.go +++ b/backend/OpenFlare/plugins/relay/heartbeat/service.go @@ -8,15 +8,15 @@ import ( "context" "log/slog" - edgeheartbeat "github.com/Rain-kl/Wavelet/internal/apps/edge/heartbeat" - "github.com/Rain-kl/Wavelet/internal/apps/edge/nodeip" - "github.com/Rain-kl/Wavelet/internal/apps/relay/config" - "github.com/Rain-kl/Wavelet/internal/apps/relay/frps" - "github.com/Rain-kl/Wavelet/internal/apps/relay/httpclient" - "github.com/Rain-kl/Wavelet/internal/apps/relay/observability" - "github.com/Rain-kl/Wavelet/internal/apps/relay/state" - "github.com/Rain-kl/Wavelet/internal/apps/relay/updater" - service "github.com/Rain-kl/Wavelet/pkg/protocol" + "Wavelet/OpenFlare/plugins/relay/config" + "Wavelet/OpenFlare/plugins/relay/frps" + "Wavelet/OpenFlare/plugins/relay/httpclient" + "Wavelet/OpenFlare/plugins/relay/observability" + "Wavelet/OpenFlare/plugins/relay/state" + "Wavelet/OpenFlare/plugins/relay/updater" + edgeheartbeat "Wavelet/OpenFlare/share/edge/heartbeat" + "Wavelet/OpenFlare/share/edge/nodeip" + service "Wavelet/OpenFlare/share/protocol" ) // Service sends periodic heartbeat payloads to the server, updates the frps diff --git a/internal/apps/relay/httpclient/client.go b/backend/OpenFlare/plugins/relay/httpclient/client.go similarity index 91% rename from internal/apps/relay/httpclient/client.go rename to backend/OpenFlare/plugins/relay/httpclient/client.go index 2d413ae3..b68153c2 100644 --- a/internal/apps/relay/httpclient/client.go +++ b/backend/OpenFlare/plugins/relay/httpclient/client.go @@ -8,8 +8,8 @@ import ( "context" "time" - edgehttp "github.com/Rain-kl/Wavelet/internal/apps/edge/httpclient" - service "github.com/Rain-kl/Wavelet/pkg/protocol" + edgehttp "Wavelet/OpenFlare/share/edge/httpclient" + service "Wavelet/OpenFlare/share/protocol" ) // APIResponse is an alias for service.APIResponse. diff --git a/internal/apps/relay/observability/collector.go b/backend/OpenFlare/plugins/relay/observability/collector.go similarity index 93% rename from internal/apps/relay/observability/collector.go rename to backend/OpenFlare/plugins/relay/observability/collector.go index 76aeed0d..14c4a564 100644 --- a/internal/apps/relay/observability/collector.go +++ b/backend/OpenFlare/plugins/relay/observability/collector.go @@ -13,11 +13,11 @@ import ( "strings" "time" - edgeobs "github.com/Rain-kl/Wavelet/internal/apps/edge/observability" - "github.com/Rain-kl/Wavelet/internal/apps/relay/config" - "github.com/Rain-kl/Wavelet/internal/apps/relay/frps" - "github.com/Rain-kl/Wavelet/internal/apps/relay/state" - service "github.com/Rain-kl/Wavelet/pkg/protocol" + "Wavelet/OpenFlare/plugins/relay/config" + "Wavelet/OpenFlare/plugins/relay/frps" + "Wavelet/OpenFlare/plugins/relay/state" + edgeobs "Wavelet/OpenFlare/share/edge/observability" + service "Wavelet/OpenFlare/share/protocol" ) // BuildProfile collects the node's system profile and returns it only when the diff --git a/backend/OpenFlare/plugins/relay/plugin.go b/backend/OpenFlare/plugins/relay/plugin.go new file mode 100644 index 00000000..98cf29e9 --- /dev/null +++ b/backend/OpenFlare/plugins/relay/plugin.go @@ -0,0 +1,107 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package relay 装载 OpenFlare 中继节点插件:frps 进程管理与心跳上报, +// 以 Cordis 驱动形态在 profile "relay" 下运行。 +package relay + +import ( + "context" + "errors" + "fmt" + "log/slog" + + "Wavelet/OpenFlare/plugins/relay/config" + "Wavelet/OpenFlare/plugins/relay/frps" + "Wavelet/OpenFlare/plugins/relay/heartbeat" + "Wavelet/OpenFlare/plugins/relay/httpclient" + relayrunner "Wavelet/OpenFlare/plugins/relay/relay" + "Wavelet/OpenFlare/plugins/relay/state" + "Wavelet/OpenFlare/plugins/relay/wsclient" + "Wavelet/core" + "Wavelet/pkg/util" +) + +// DriverTypeRelay 是中继守护进程专属的驱动类型。 +const DriverTypeRelay core.DriverType = "relay" + +// Plugin 实现 core.Plugin 与 core.Driver。 +type Plugin struct { + configPath string + + runner *relayrunner.Runner + done chan error + started bool +} + +// New 创建 relay 插件,configPath 指向其 JSON 配置文件。 +func New(configPath string) *Plugin { + return &Plugin{configPath: configPath, done: make(chan error, 1)} +} + +// Name 返回插件标识。 +func (p *Plugin) Name() string { return "relay" } + +// Type 返回驱动类型。 +func (p *Plugin) Type() core.DriverType { return DriverTypeRelay } + +// Apply 加载配置、装配 frps 管理器与各客户端,并注册驱动。 +func (p *Plugin) Apply(ctx *core.Context) error { + cfg, err := config.Load(p.configPath) + if err != nil { + return fmt.Errorf("load relay config: %w", err) + } + slog.Info("relay config loaded", + "server", cfg.ServerURL, + "node", cfg.NodeName, + "ip", cfg.NodeIP, + "frps_path", cfg.FrpsPath, + "data_dir", cfg.DataDir, + "heartbeat_interval", cfg.HeartbeatInterval, + ) + + stateStore := state.NewStore(cfg.StatePath) + frpsManager := frps.NewManager(cfg.FrpsPath, cfg.DataDir, cfg.InitialAuthToken()) + slog.Info("detected frps version", "version", frpsManager.GetVersion(context.Background())) + + httpClient := httpclient.New(cfg.ServerURL, cfg.InitialAuthToken(), cfg.RequestTimeout.Duration()) + wsClient := wsclient.New(cfg.ServerURL, cfg.InitialAuthToken(), cfg.RequestTimeout.Duration()) + + p.runner = &relayrunner.Runner{ + Config: cfg, + StateStore: stateStore, + FrpsManager: frpsManager, + HTTPClient: httpClient, + WebSocketService: wsClient, + HeartbeatService: heartbeat.New(httpClient, frpsManager, cfg, stateStore), + } + + return ctx.RegisterDriver(p) +} + +// Start 拉起中继主循环;runner.Run 阻塞至 ctx 取消,故置于独立 goroutine。 +func (p *Plugin) Start(ctx context.Context) error { + util.Go(func() { p.done <- p.runner.Run(ctx) }) + p.started = true + slog.Info("relay process started") + return nil +} + +// Stop 等待主循环退出并回传其结果。 +func (p *Plugin) Stop(ctx context.Context) error { + if !p.started { + return nil + } + select { + case err := <-p.done: + p.started = false + if err != nil && !errors.Is(err, context.Canceled) { + return err + } + slog.Info("relay process stopped") + return nil + case <-ctx.Done(): + p.started = false + return fmt.Errorf("relay shutdown timeout: %w", ctx.Err()) + } +} diff --git a/backend/OpenFlare/plugins/relay/plugin_test.go b/backend/OpenFlare/plugins/relay/plugin_test.go new file mode 100644 index 00000000..f75845ac --- /dev/null +++ b/backend/OpenFlare/plugins/relay/plugin_test.go @@ -0,0 +1,29 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package relay + +import ( + "context" + "testing" + + "Wavelet/core" +) + +func TestPluginIdentity(t *testing.T) { + p := New("./relay.json") + if got := p.Name(); got != "relay" { + t.Errorf("Name() = %q, want %q", got, "relay") + } + // 驱动类型必须等于 profile 字符串,否则内核的 profile 过滤会漏掉本驱动。 + if got, want := string(p.Type()), string(core.Profile("relay")); got != want { + t.Errorf("Type() = %q, want profile %q", got, want) + } +} + +func TestApplyFailsOnMissingConfig(t *testing.T) { + ctx := core.NewContext(context.Background()) + if err := New("./does-not-exist.json").Apply(ctx); err == nil { + t.Fatal("Apply(missing config) error = nil, want error") + } +} diff --git a/internal/apps/relay/relay/runner.go b/backend/OpenFlare/plugins/relay/relay/runner.go similarity index 80% rename from internal/apps/relay/relay/runner.go rename to backend/OpenFlare/plugins/relay/relay/runner.go index 8d484b21..1bc1a953 100644 --- a/internal/apps/relay/relay/runner.go +++ b/backend/OpenFlare/plugins/relay/relay/runner.go @@ -9,14 +9,14 @@ import ( "encoding/json" "log/slog" - edgerunner "github.com/Rain-kl/Wavelet/internal/apps/edge/runner" - "github.com/Rain-kl/Wavelet/internal/apps/relay/config" - "github.com/Rain-kl/Wavelet/internal/apps/relay/frps" - "github.com/Rain-kl/Wavelet/internal/apps/relay/heartbeat" - "github.com/Rain-kl/Wavelet/internal/apps/relay/httpclient" - "github.com/Rain-kl/Wavelet/internal/apps/relay/state" - "github.com/Rain-kl/Wavelet/internal/apps/relay/wsclient" - service "github.com/Rain-kl/Wavelet/pkg/protocol" + "Wavelet/OpenFlare/plugins/relay/config" + "Wavelet/OpenFlare/plugins/relay/frps" + "Wavelet/OpenFlare/plugins/relay/heartbeat" + "Wavelet/OpenFlare/plugins/relay/httpclient" + "Wavelet/OpenFlare/plugins/relay/state" + "Wavelet/OpenFlare/plugins/relay/wsclient" + edgerunner "Wavelet/OpenFlare/share/edge/runner" + service "Wavelet/OpenFlare/share/protocol" ) // Runner manages the relay process. diff --git a/internal/apps/relay/state/store.go b/backend/OpenFlare/plugins/relay/state/store.go similarity index 100% rename from internal/apps/relay/state/store.go rename to backend/OpenFlare/plugins/relay/state/store.go diff --git a/internal/apps/relay/updater/updater.go b/backend/OpenFlare/plugins/relay/updater/updater.go similarity index 82% rename from internal/apps/relay/updater/updater.go rename to backend/OpenFlare/plugins/relay/updater/updater.go index 8ff93ba2..196f8f8b 100644 --- a/internal/apps/relay/updater/updater.go +++ b/backend/OpenFlare/plugins/relay/updater/updater.go @@ -5,8 +5,8 @@ package updater import ( - edgeupdater "github.com/Rain-kl/Wavelet/internal/apps/edge/updater" - "github.com/Rain-kl/Wavelet/internal/apps/relay/config" + "Wavelet/OpenFlare/plugins/relay/config" + edgeupdater "Wavelet/OpenFlare/share/edge/updater" ) // Service is an alias for the edge updater Service. diff --git a/internal/apps/relay/wsclient/client.go b/backend/OpenFlare/plugins/relay/wsclient/client.go similarity index 94% rename from internal/apps/relay/wsclient/client.go rename to backend/OpenFlare/plugins/relay/wsclient/client.go index 94e7e6c4..99b8e3b3 100644 --- a/internal/apps/relay/wsclient/client.go +++ b/backend/OpenFlare/plugins/relay/wsclient/client.go @@ -8,7 +8,7 @@ import ( "context" "time" - edgews "github.com/Rain-kl/Wavelet/internal/apps/edge/wsclient" + edgews "Wavelet/OpenFlare/share/edge/wsclient" ) // WSMessage is a WebSocket message exchanged with the control plane. diff --git a/internal/apps/admin/auth_source/routers.go b/backend/OpenFlare/plugins/server/admin/auth_source/routers.go similarity index 97% rename from internal/apps/admin/auth_source/routers.go rename to backend/OpenFlare/plugins/server/admin/auth_source/routers.go index b4bbf430..900b56c2 100644 --- a/internal/apps/admin/auth_source/routers.go +++ b/backend/OpenFlare/plugins/server/admin/auth_source/routers.go @@ -10,13 +10,14 @@ import ( "net/http" "strings" - "github.com/Rain-kl/Wavelet/internal/apps/admin" - "github.com/Rain-kl/Wavelet/internal/apps/oauth" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/admin" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/oauth" + "Wavelet/OpenFlare/plugins/server/repository" + "github.com/gin-gonic/gin" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/pkg/response" ) // AuthSourceRequest 创建或更新认证源的请求参数 diff --git a/internal/apps/admin/auth_source/routers_test.go b/backend/OpenFlare/plugins/server/admin/auth_source/routers_test.go similarity index 98% rename from internal/apps/admin/auth_source/routers_test.go rename to backend/OpenFlare/plugins/server/admin/auth_source/routers_test.go index 3d05bf49..be28bf3f 100644 --- a/internal/apps/admin/auth_source/routers_test.go +++ b/backend/OpenFlare/plugins/server/admin/auth_source/routers_test.go @@ -11,12 +11,13 @@ import ( "net/http/httptest" "testing" - "github.com/Rain-kl/Wavelet/internal/apps/oauth" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/testhelper" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/oauth" + "Wavelet/OpenFlare/plugins/server/testhelper" + "github.com/gin-gonic/gin" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/pkg/response" ) func setupTestRouter(authUser *model.User) *gin.Engine { diff --git a/internal/apps/admin/cache/logics.go b/backend/OpenFlare/plugins/server/admin/cache/logics.go similarity index 82% rename from internal/apps/admin/cache/logics.go rename to backend/OpenFlare/plugins/server/admin/cache/logics.go index 76f8446a..eb5ffc79 100644 --- a/internal/apps/admin/cache/logics.go +++ b/backend/OpenFlare/plugins/server/admin/cache/logics.go @@ -6,7 +6,7 @@ package cache import ( "context" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" ) func saveOrUpdateConfig(ctx context.Context, key, value string) error { diff --git a/internal/apps/admin/cache/routers.go b/backend/OpenFlare/plugins/server/admin/cache/routers.go similarity index 95% rename from internal/apps/admin/cache/routers.go rename to backend/OpenFlare/plugins/server/admin/cache/routers.go index b9b1c799..8a9433f2 100644 --- a/internal/apps/admin/cache/routers.go +++ b/backend/OpenFlare/plugins/server/admin/cache/routers.go @@ -8,11 +8,12 @@ import ( "net/http" "strconv" - "github.com/Rain-kl/Wavelet/internal/infra/diskcache" - "github.com/Rain-kl/Wavelet/internal/model" + "Wavelet/OpenFlare/plugins/server/infra/diskcache" + "Wavelet/OpenFlare/plugins/server/model" + "github.com/gin-gonic/gin" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/pkg/response" ) type updateCacheConfigRequest struct { diff --git a/internal/apps/admin/db_manage/routers.go b/backend/OpenFlare/plugins/server/admin/db_manage/routers.go similarity index 98% rename from internal/apps/admin/db_manage/routers.go rename to backend/OpenFlare/plugins/server/admin/db_manage/routers.go index 04fe6b6d..66838049 100644 --- a/internal/apps/admin/db_manage/routers.go +++ b/backend/OpenFlare/plugins/server/admin/db_manage/routers.go @@ -15,12 +15,13 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/infra/config" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" + "Wavelet/OpenFlare/plugins/server/infra/config" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "github.com/gin-gonic/gin" "gorm.io/gorm" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/pkg/response" ) const ( diff --git a/internal/apps/admin/errs.go b/backend/OpenFlare/plugins/server/admin/errs.go similarity index 100% rename from internal/apps/admin/errs.go rename to backend/OpenFlare/plugins/server/admin/errs.go diff --git a/internal/apps/admin/logs/routers.go b/backend/OpenFlare/plugins/server/admin/logs/routers.go similarity index 97% rename from internal/apps/admin/logs/routers.go rename to backend/OpenFlare/plugins/server/admin/logs/routers.go index 6a6b4731..aea910aa 100644 --- a/internal/apps/admin/logs/routers.go +++ b/backend/OpenFlare/plugins/server/admin/logs/routers.go @@ -13,15 +13,16 @@ import ( "strconv" "time" - "github.com/Rain-kl/Wavelet/internal/apps/admin" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/repository/logstore" - "github.com/Rain-kl/Wavelet/pkg/logger" - "github.com/Rain-kl/Wavelet/pkg/util" + "Wavelet/OpenFlare/plugins/server/admin" + analyticsmodel "Wavelet/OpenFlare/plugins/server/model/analytics" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/repository/logstore" + "Wavelet/pkg/logger" + "Wavelet/pkg/util" + "github.com/gin-gonic/gin" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/pkg/response" ) const ( diff --git a/internal/apps/admin/logs/utils.go b/backend/OpenFlare/plugins/server/admin/logs/utils.go similarity index 93% rename from internal/apps/admin/logs/utils.go rename to backend/OpenFlare/plugins/server/admin/logs/utils.go index 95acf9a8..6b86922c 100644 --- a/internal/apps/admin/logs/utils.go +++ b/backend/OpenFlare/plugins/server/admin/logs/utils.go @@ -12,8 +12,8 @@ import ( "github.com/gorilla/websocket" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" ) // getUpgrader 返回 WebSocket 升级器并执行 Origin 安全检查以防止 CSWSH 攻击 diff --git a/internal/apps/admin/logs/utils_test.go b/backend/OpenFlare/plugins/server/admin/logs/utils_test.go similarity index 96% rename from internal/apps/admin/logs/utils_test.go rename to backend/OpenFlare/plugins/server/admin/logs/utils_test.go index daddc539..fc087218 100644 --- a/internal/apps/admin/logs/utils_test.go +++ b/backend/OpenFlare/plugins/server/admin/logs/utils_test.go @@ -11,8 +11,8 @@ import ( "gorm.io/driver/sqlite" "gorm.io/gorm" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" ) func setupTestDB(t *testing.T) *gorm.DB { diff --git a/internal/apps/admin/middlewares.go b/backend/OpenFlare/plugins/server/admin/middlewares.go similarity index 80% rename from internal/apps/admin/middlewares.go rename to backend/OpenFlare/plugins/server/admin/middlewares.go index c40215cb..271916b8 100644 --- a/internal/apps/admin/middlewares.go +++ b/backend/OpenFlare/plugins/server/admin/middlewares.go @@ -5,12 +5,13 @@ package admin import ( - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/shared/response" - "github.com/Rain-kl/Wavelet/pkg/logger" - otel_trace "github.com/Rain-kl/Wavelet/pkg/trace" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/pkg/logger" + "Wavelet/pkg/response" + otel_trace "Wavelet/pkg/trace" + + "Wavelet/OpenFlare/plugins/server/oauth" - "github.com/Rain-kl/Wavelet/internal/apps/oauth" "github.com/gin-gonic/gin" ) diff --git a/internal/apps/admin/push/channels.go b/backend/OpenFlare/plugins/server/admin/push/channels.go similarity index 98% rename from internal/apps/admin/push/channels.go rename to backend/OpenFlare/plugins/server/admin/push/channels.go index 0b205734..1f5cbdb2 100644 --- a/internal/apps/admin/push/channels.go +++ b/backend/OpenFlare/plugins/server/admin/push/channels.go @@ -10,12 +10,13 @@ import ( "strconv" "strings" - pkgpush "github.com/Rain-kl/Wavelet/pkg/push" + pkgpush "Wavelet/OpenFlare/plugins/server/pkg/push" + "github.com/gin-gonic/gin" "gorm.io/gorm" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/pkg/response" ) // ListChannelDefinitions 获取各种消息通道的表单配置定义列表 diff --git a/internal/apps/admin/push/channels_definition.go b/backend/OpenFlare/plugins/server/admin/push/channels_definition.go similarity index 100% rename from internal/apps/admin/push/channels_definition.go rename to backend/OpenFlare/plugins/server/admin/push/channels_definition.go diff --git a/internal/apps/admin/push/constants.go b/backend/OpenFlare/plugins/server/admin/push/constants.go similarity index 100% rename from internal/apps/admin/push/constants.go rename to backend/OpenFlare/plugins/server/admin/push/constants.go diff --git a/internal/apps/admin/push/custom_events/admin_login.go b/backend/OpenFlare/plugins/server/admin/push/custom_events/admin_login.go similarity index 89% rename from internal/apps/admin/push/custom_events/admin_login.go rename to backend/OpenFlare/plugins/server/admin/push/custom_events/admin_login.go index 5fb25226..b93cb447 100644 --- a/internal/apps/admin/push/custom_events/admin_login.go +++ b/backend/OpenFlare/plugins/server/admin/push/custom_events/admin_login.go @@ -8,8 +8,8 @@ import ( "context" "time" - "github.com/Rain-kl/Wavelet/internal/apps/admin/push" - "github.com/Rain-kl/Wavelet/internal/listener" + "Wavelet/OpenFlare/plugins/server/admin/push" + "Wavelet/OpenFlare/plugins/server/listener" ) // AdminLogin is the metadata definition for the admin login event. diff --git a/internal/apps/admin/push/custom_events/admin_login_test.go b/backend/OpenFlare/plugins/server/admin/push/custom_events/admin_login_test.go similarity index 93% rename from internal/apps/admin/push/custom_events/admin_login_test.go rename to backend/OpenFlare/plugins/server/admin/push/custom_events/admin_login_test.go index 9ab14e8c..550b4d0d 100644 --- a/internal/apps/admin/push/custom_events/admin_login_test.go +++ b/backend/OpenFlare/plugins/server/admin/push/custom_events/admin_login_test.go @@ -10,12 +10,13 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/apps/admin/push" - "github.com/Rain-kl/Wavelet/internal/infra/task" - "github.com/Rain-kl/Wavelet/internal/listener" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/testhelper" + "Wavelet/OpenFlare/plugins/server/admin/push" + "Wavelet/OpenFlare/plugins/server/infra/task" + "Wavelet/OpenFlare/plugins/server/listener" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/testhelper" + "github.com/hibiken/asynq" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/internal/apps/admin/push/custom_events/register.go b/backend/OpenFlare/plugins/server/admin/push/custom_events/register.go similarity index 79% rename from internal/apps/admin/push/custom_events/register.go rename to backend/OpenFlare/plugins/server/admin/push/custom_events/register.go index 08ec205c..8696ad76 100644 --- a/internal/apps/admin/push/custom_events/register.go +++ b/backend/OpenFlare/plugins/server/admin/push/custom_events/register.go @@ -4,8 +4,8 @@ package custom_events import ( - "github.com/Rain-kl/Wavelet/internal/apps/admin/push" - "github.com/Rain-kl/Wavelet/internal/listener" + "Wavelet/OpenFlare/plugins/server/admin/push" + "Wavelet/OpenFlare/plugins/server/listener" ) // Register wires push notification handlers for domain events and registers diff --git a/internal/apps/admin/push/events.go b/backend/OpenFlare/plugins/server/admin/push/events.go similarity index 97% rename from internal/apps/admin/push/events.go rename to backend/OpenFlare/plugins/server/admin/push/events.go index 66f47f19..3cc6fea5 100644 --- a/internal/apps/admin/push/events.go +++ b/backend/OpenFlare/plugins/server/admin/push/events.go @@ -12,12 +12,13 @@ import ( "maps" "strings" - "github.com/Rain-kl/Wavelet/internal/infra/task" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/pkg/logger" - pkgpush "github.com/Rain-kl/Wavelet/pkg/push" - "github.com/Rain-kl/Wavelet/pkg/util" + "Wavelet/OpenFlare/plugins/server/infra/task" + "Wavelet/OpenFlare/plugins/server/model" + pkgpush "Wavelet/OpenFlare/plugins/server/pkg/push" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/pkg/logger" + "Wavelet/pkg/util" + "gorm.io/gorm" ) diff --git a/internal/apps/admin/push/logics.go b/backend/OpenFlare/plugins/server/admin/push/logics.go similarity index 98% rename from internal/apps/admin/push/logics.go rename to backend/OpenFlare/plugins/server/admin/push/logics.go index 713797e9..8117a478 100644 --- a/internal/apps/admin/push/logics.go +++ b/backend/OpenFlare/plugins/server/admin/push/logics.go @@ -10,10 +10,11 @@ import ( "strconv" "strings" - "github.com/Rain-kl/Wavelet/internal/infra/task" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - pkgpush "github.com/Rain-kl/Wavelet/pkg/push" + "Wavelet/OpenFlare/plugins/server/infra/task" + "Wavelet/OpenFlare/plugins/server/model" + pkgpush "Wavelet/OpenFlare/plugins/server/pkg/push" + "Wavelet/OpenFlare/plugins/server/repository" + "gorm.io/gorm" ) diff --git a/internal/apps/admin/push/push_test.go b/backend/OpenFlare/plugins/server/admin/push/push_test.go similarity index 98% rename from internal/apps/admin/push/push_test.go rename to backend/OpenFlare/plugins/server/admin/push/push_test.go index 0a30a855..ac7089c1 100644 --- a/internal/apps/admin/push/push_test.go +++ b/backend/OpenFlare/plugins/server/admin/push/push_test.go @@ -14,10 +14,11 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/infra/task" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/testhelper" - pkgpush "github.com/Rain-kl/Wavelet/pkg/push" + "Wavelet/OpenFlare/plugins/server/infra/task" + "Wavelet/OpenFlare/plugins/server/model" + pkgpush "Wavelet/OpenFlare/plugins/server/pkg/push" + "Wavelet/OpenFlare/plugins/server/testhelper" + "github.com/alicebob/miniredis/v2" "github.com/gin-gonic/gin" "github.com/hibiken/asynq" @@ -25,9 +26,9 @@ import ( "github.com/stretchr/testify/require" "gorm.io/gorm" - "github.com/Rain-kl/Wavelet/internal/apps/oauth" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/OpenFlare/plugins/server/oauth" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/pkg/response" ) var adminLoginEvent = EventMetadata{ diff --git a/internal/apps/admin/push/routers.go b/backend/OpenFlare/plugins/server/admin/push/routers.go similarity index 97% rename from internal/apps/admin/push/routers.go rename to backend/OpenFlare/plugins/server/admin/push/routers.go index 7f937c13..10ee84d6 100644 --- a/internal/apps/admin/push/routers.go +++ b/backend/OpenFlare/plugins/server/admin/push/routers.go @@ -11,13 +11,14 @@ import ( "net/http" "strconv" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/pkg/push" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/pkg/push" + "Wavelet/OpenFlare/plugins/server/repository" + "github.com/gin-gonic/gin" "gorm.io/gorm" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/pkg/response" ) // UpdateEventRequest 更新事件请求参数 diff --git a/internal/apps/admin/push/task_listener.go b/backend/OpenFlare/plugins/server/admin/push/task_listener.go similarity index 95% rename from internal/apps/admin/push/task_listener.go rename to backend/OpenFlare/plugins/server/admin/push/task_listener.go index ec119710..738f7e69 100644 --- a/internal/apps/admin/push/task_listener.go +++ b/backend/OpenFlare/plugins/server/admin/push/task_listener.go @@ -9,9 +9,9 @@ import ( "strconv" "time" - "github.com/Rain-kl/Wavelet/internal/infra/task" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/pkg/logger" + "Wavelet/OpenFlare/plugins/server/infra/task" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/pkg/logger" ) // RegisterTaskListeners subscribes push notification handlers to task completion events. diff --git a/internal/apps/admin/push/tasks.go b/backend/OpenFlare/plugins/server/admin/push/tasks.go similarity index 97% rename from internal/apps/admin/push/tasks.go rename to backend/OpenFlare/plugins/server/admin/push/tasks.go index de310e87..f3f3bfa9 100644 --- a/internal/apps/admin/push/tasks.go +++ b/backend/OpenFlare/plugins/server/admin/push/tasks.go @@ -10,8 +10,8 @@ import ( "errors" "fmt" - "github.com/Rain-kl/Wavelet/internal/infra/task" - "github.com/Rain-kl/Wavelet/pkg/push" + "Wavelet/OpenFlare/plugins/server/infra/task" + "Wavelet/OpenFlare/plugins/server/pkg/push" ) const ( diff --git a/internal/apps/admin/status/clickhouse.go b/backend/OpenFlare/plugins/server/admin/status/clickhouse.go similarity index 81% rename from internal/apps/admin/status/clickhouse.go rename to backend/OpenFlare/plugins/server/admin/status/clickhouse.go index 3785466c..e7b989ec 100644 --- a/internal/apps/admin/status/clickhouse.go +++ b/backend/OpenFlare/plugins/server/admin/status/clickhouse.go @@ -8,15 +8,15 @@ import ( "errors" "net/http" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/chwriter" - "github.com/Rain-kl/Wavelet/internal/infra/config" - "github.com/Rain-kl/Wavelet/internal/infra/persistence/batchwriter" - "github.com/Rain-kl/Wavelet/internal/model" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/repository/logstore" - "github.com/Rain-kl/Wavelet/internal/shared/response" - "github.com/Rain-kl/Wavelet/pkg/logger" + "Wavelet/OpenFlare/plugins/server/infra/config" + "Wavelet/OpenFlare/plugins/server/model" + analyticsmodel "Wavelet/OpenFlare/plugins/server/model/analytics" + "Wavelet/OpenFlare/plugins/server/openflare/chwriter" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/repository/logstore" + "Wavelet/pkg/logger" + "Wavelet/pkg/response" + "github.com/gin-gonic/gin" "gorm.io/gorm" ) @@ -126,10 +126,20 @@ func availableTargets(active string) []string { return []string{} } -func collectBatchWriterStats() []batchwriter.Stats { - out := chwriter.WriterStats() - if out == nil { - out = make([]batchwriter.Stats, 0, 1) +// collectBatchWriterStats 把批量写入器的运行期指标转成 status 层 DTO。 +// 转换留在消费侧:model 不应反向依赖上游 pkg/batchwriter 的类型。 +func collectBatchWriterStats() []analyticsmodel.BatchWriterStats { + in := chwriter.WriterStats() + out := make([]analyticsmodel.BatchWriterStats, 0, len(in)) + for _, s := range in { + out = append(out, analyticsmodel.BatchWriterStats{ + Name: s.Name, + Depth: s.Depth, + Cap: s.Cap, + Drops: s.Drops, + FlushErrors: s.FlushErrors, + Running: s.Running, + }) } return out } diff --git a/internal/apps/admin/status/clickhouse_test.go b/backend/OpenFlare/plugins/server/admin/status/clickhouse_test.go similarity index 97% rename from internal/apps/admin/status/clickhouse_test.go rename to backend/OpenFlare/plugins/server/admin/status/clickhouse_test.go index a9ea9598..e7ede05a 100644 --- a/internal/apps/admin/status/clickhouse_test.go +++ b/backend/OpenFlare/plugins/server/admin/status/clickhouse_test.go @@ -11,8 +11,9 @@ import ( "reflect" "testing" - "github.com/Rain-kl/Wavelet/internal/infra/config" - "github.com/Rain-kl/Wavelet/internal/repository/logstore" + "Wavelet/OpenFlare/plugins/server/infra/config" + "Wavelet/OpenFlare/plugins/server/repository/logstore" + "github.com/gin-gonic/gin" ) diff --git a/internal/apps/admin/status/routers.go b/backend/OpenFlare/plugins/server/admin/status/routers.go similarity index 98% rename from internal/apps/admin/status/routers.go rename to backend/OpenFlare/plugins/server/admin/status/routers.go index 37ee1434..66e950d6 100644 --- a/internal/apps/admin/status/routers.go +++ b/backend/OpenFlare/plugins/server/admin/status/routers.go @@ -17,11 +17,12 @@ import ( "strconv" "time" - "github.com/Rain-kl/Wavelet/internal/infra/config" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" + "Wavelet/OpenFlare/plugins/server/infra/config" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "github.com/gin-gonic/gin" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/pkg/response" ) // startTime 记录服务启动时间 diff --git a/internal/apps/admin/system_config/errs.go b/backend/OpenFlare/plugins/server/admin/system_config/errs.go similarity index 100% rename from internal/apps/admin/system_config/errs.go rename to backend/OpenFlare/plugins/server/admin/system_config/errs.go diff --git a/internal/apps/admin/system_config/logics.go b/backend/OpenFlare/plugins/server/admin/system_config/logics.go similarity index 94% rename from internal/apps/admin/system_config/logics.go rename to backend/OpenFlare/plugins/server/admin/system_config/logics.go index 31240bee..76fab90b 100644 --- a/internal/apps/admin/system_config/logics.go +++ b/backend/OpenFlare/plugins/server/admin/system_config/logics.go @@ -9,10 +9,11 @@ import ( "errors" "time" - "github.com/Rain-kl/Wavelet/internal/infra/objectstore" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/pkg/logger" + "Wavelet/OpenFlare/plugins/server/infra/objectstore" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/pkg/logger" + "gorm.io/gorm" ) diff --git a/internal/apps/admin/system_config/routers.go b/backend/OpenFlare/plugins/server/admin/system_config/routers.go similarity index 97% rename from internal/apps/admin/system_config/routers.go rename to backend/OpenFlare/plugins/server/admin/system_config/routers.go index 9d6e6830..520bf286 100644 --- a/internal/apps/admin/system_config/routers.go +++ b/backend/OpenFlare/plugins/server/admin/system_config/routers.go @@ -15,14 +15,14 @@ import ( "github.com/gin-gonic/gin" "gorm.io/gorm" - "github.com/Rain-kl/Wavelet/internal/apps/cap" - "github.com/Rain-kl/Wavelet/internal/apps/upload" - "github.com/Rain-kl/Wavelet/internal/infra/objectstore" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/shared/response" - "github.com/Rain-kl/Wavelet/pkg/logger" - mail "github.com/Rain-kl/Wavelet/pkg/mail" + "Wavelet/OpenFlare/plugins/server/cap" + "Wavelet/OpenFlare/plugins/server/infra/objectstore" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/upload" + "Wavelet/pkg/logger" + mail "Wavelet/pkg/mail" + "Wavelet/pkg/response" ) const maskedConfigValue = "******" diff --git a/internal/apps/admin/system_config/routers_test.go b/backend/OpenFlare/plugins/server/admin/system_config/routers_test.go similarity index 97% rename from internal/apps/admin/system_config/routers_test.go rename to backend/OpenFlare/plugins/server/admin/system_config/routers_test.go index dbf907bb..076f0e09 100644 --- a/internal/apps/admin/system_config/routers_test.go +++ b/backend/OpenFlare/plugins/server/admin/system_config/routers_test.go @@ -16,15 +16,16 @@ import ( "strings" "testing" - "github.com/Rain-kl/Wavelet/internal/apps/oauth" - "github.com/Rain-kl/Wavelet/internal/infra/objectstore" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/testhelper" + "Wavelet/OpenFlare/plugins/server/infra/objectstore" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/oauth" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/testhelper" + "github.com/gin-gonic/gin" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/pkg/response" ) const expectedDefaultConfigsCount = 37 diff --git a/internal/apps/admin/task/errs.go b/backend/OpenFlare/plugins/server/admin/task/errs.go similarity index 100% rename from internal/apps/admin/task/errs.go rename to backend/OpenFlare/plugins/server/admin/task/errs.go diff --git a/internal/apps/admin/task/routers.go b/backend/OpenFlare/plugins/server/admin/task/routers.go similarity index 97% rename from internal/apps/admin/task/routers.go rename to backend/OpenFlare/plugins/server/admin/task/routers.go index 8f6bf135..3eb07924 100644 --- a/internal/apps/admin/task/routers.go +++ b/backend/OpenFlare/plugins/server/admin/task/routers.go @@ -11,17 +11,18 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" + + "Wavelet/OpenFlare/plugins/server/admin" + "Wavelet/OpenFlare/plugins/server/infra/task" + "Wavelet/OpenFlare/plugins/server/infra/task/scheduler" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/pkg/logger" - "github.com/Rain-kl/Wavelet/internal/apps/admin" - "github.com/Rain-kl/Wavelet/internal/infra/task" - "github.com/Rain-kl/Wavelet/internal/infra/task/scheduler" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/pkg/logger" "github.com/gin-gonic/gin" "github.com/robfig/cron/v3" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/pkg/response" ) // ListTaskTypes 获取支持的任务类型列表 diff --git a/internal/apps/admin/task/routers_test.go b/backend/OpenFlare/plugins/server/admin/task/routers_test.go similarity index 97% rename from internal/apps/admin/task/routers_test.go rename to backend/OpenFlare/plugins/server/admin/task/routers_test.go index 8ce7d0e3..a351b45b 100644 --- a/internal/apps/admin/task/routers_test.go +++ b/backend/OpenFlare/plugins/server/admin/task/routers_test.go @@ -14,21 +14,22 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" + + "Wavelet/OpenFlare/plugins/server/infra/task" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/oauth" + "Wavelet/OpenFlare/plugins/server/platform/bootstrap" + "Wavelet/OpenFlare/plugins/server/testhelper" + uploadtask "Wavelet/OpenFlare/plugins/server/upload/task" + "Wavelet/OpenFlare/plugins/server/user" - "github.com/Rain-kl/Wavelet/internal/apps/oauth" - uploadtask "github.com/Rain-kl/Wavelet/internal/apps/upload/task" - "github.com/Rain-kl/Wavelet/internal/apps/user" - "github.com/Rain-kl/Wavelet/internal/infra/task" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/platform/bootstrap" - "github.com/Rain-kl/Wavelet/internal/testhelper" "github.com/gin-gonic/gin" "github.com/hibiken/asynq" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/pkg/response" ) const ( diff --git a/internal/apps/admin/template/errs.go b/backend/OpenFlare/plugins/server/admin/template/errs.go similarity index 100% rename from internal/apps/admin/template/errs.go rename to backend/OpenFlare/plugins/server/admin/template/errs.go diff --git a/internal/apps/admin/template/logics.go b/backend/OpenFlare/plugins/server/admin/template/logics.go similarity index 95% rename from internal/apps/admin/template/logics.go rename to backend/OpenFlare/plugins/server/admin/template/logics.go index 9cae21cf..3f77b8e8 100644 --- a/internal/apps/admin/template/logics.go +++ b/backend/OpenFlare/plugins/server/admin/template/logics.go @@ -7,8 +7,8 @@ import ( "context" "errors" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" ) func createTemplate(ctx context.Context, req CreateTemplateRequest) (model.Template, error) { diff --git a/internal/apps/admin/template/routers.go b/backend/OpenFlare/plugins/server/admin/template/routers.go similarity index 99% rename from internal/apps/admin/template/routers.go rename to backend/OpenFlare/plugins/server/admin/template/routers.go index d4958dd6..203ad8ff 100644 --- a/internal/apps/admin/template/routers.go +++ b/backend/OpenFlare/plugins/server/admin/template/routers.go @@ -10,7 +10,7 @@ import ( "github.com/gin-gonic/gin" "gorm.io/gorm" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/pkg/response" ) // CreateTemplateRequest 创建模板请求 diff --git a/internal/apps/admin/template/routers_test.go b/backend/OpenFlare/plugins/server/admin/template/routers_test.go similarity index 97% rename from internal/apps/admin/template/routers_test.go rename to backend/OpenFlare/plugins/server/admin/template/routers_test.go index e615a8a5..fb8b59ff 100644 --- a/internal/apps/admin/template/routers_test.go +++ b/backend/OpenFlare/plugins/server/admin/template/routers_test.go @@ -10,12 +10,13 @@ import ( "net/http/httptest" "testing" - "github.com/Rain-kl/Wavelet/internal/apps/oauth" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/testhelper" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/oauth" + "Wavelet/OpenFlare/plugins/server/testhelper" + "github.com/gin-gonic/gin" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/pkg/response" ) func setupTestRouter(authUser *model.User) *gin.Engine { diff --git a/internal/apps/admin/updater/errs.go b/backend/OpenFlare/plugins/server/admin/updater/errs.go similarity index 100% rename from internal/apps/admin/updater/errs.go rename to backend/OpenFlare/plugins/server/admin/updater/errs.go diff --git a/internal/apps/admin/updater/export.go b/backend/OpenFlare/plugins/server/admin/updater/export.go similarity index 100% rename from internal/apps/admin/updater/export.go rename to backend/OpenFlare/plugins/server/admin/updater/export.go diff --git a/internal/apps/admin/updater/logics.go b/backend/OpenFlare/plugins/server/admin/updater/logics.go similarity index 99% rename from internal/apps/admin/updater/logics.go rename to backend/OpenFlare/plugins/server/admin/updater/logics.go index 9f1a783f..a5ed1933 100644 --- a/internal/apps/admin/updater/logics.go +++ b/backend/OpenFlare/plugins/server/admin/updater/logics.go @@ -22,10 +22,11 @@ import ( "sync" "time" - "github.com/Rain-kl/Wavelet/internal/buildinfo" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/pkg/logger" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/pkg/buildinfo" + "Wavelet/pkg/logger" + "golang.org/x/mod/semver" ) diff --git a/internal/apps/admin/updater/logics_test.go b/backend/OpenFlare/plugins/server/admin/updater/logics_test.go similarity index 100% rename from internal/apps/admin/updater/logics_test.go rename to backend/OpenFlare/plugins/server/admin/updater/logics_test.go diff --git a/internal/apps/admin/updater/restart_unix.go b/backend/OpenFlare/plugins/server/admin/updater/restart_unix.go similarity index 97% rename from internal/apps/admin/updater/restart_unix.go rename to backend/OpenFlare/plugins/server/admin/updater/restart_unix.go index 24d94c6c..d78e2eaf 100644 --- a/internal/apps/admin/updater/restart_unix.go +++ b/backend/OpenFlare/plugins/server/admin/updater/restart_unix.go @@ -12,7 +12,7 @@ import ( "path/filepath" "syscall" - "github.com/Rain-kl/Wavelet/pkg/logger" + "Wavelet/pkg/logger" ) const installedBinaryMode = 0o755 diff --git a/internal/apps/admin/updater/restart_windows.go b/backend/OpenFlare/plugins/server/admin/updater/restart_windows.go similarity index 100% rename from internal/apps/admin/updater/restart_windows.go rename to backend/OpenFlare/plugins/server/admin/updater/restart_windows.go diff --git a/internal/apps/admin/updater/routers.go b/backend/OpenFlare/plugins/server/admin/updater/routers.go similarity index 94% rename from internal/apps/admin/updater/routers.go rename to backend/OpenFlare/plugins/server/admin/updater/routers.go index 91cd999e..64bc7335 100644 --- a/internal/apps/admin/updater/routers.go +++ b/backend/OpenFlare/plugins/server/admin/updater/routers.go @@ -8,11 +8,12 @@ import ( "net/http" "time" - "github.com/Rain-kl/Wavelet/pkg/logger" - "github.com/Rain-kl/Wavelet/pkg/util" + "Wavelet/pkg/logger" + "Wavelet/pkg/util" + "github.com/gin-gonic/gin" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/pkg/response" ) // GetUpdateStatus 获取应用更新状态 diff --git a/internal/apps/admin/user/errs.go b/backend/OpenFlare/plugins/server/admin/user/errs.go similarity index 100% rename from internal/apps/admin/user/errs.go rename to backend/OpenFlare/plugins/server/admin/user/errs.go diff --git a/internal/apps/admin/user/logics.go b/backend/OpenFlare/plugins/server/admin/user/logics.go similarity index 96% rename from internal/apps/admin/user/logics.go rename to backend/OpenFlare/plugins/server/admin/user/logics.go index 20bd6904..39929fb3 100644 --- a/internal/apps/admin/user/logics.go +++ b/backend/OpenFlare/plugins/server/admin/user/logics.go @@ -9,10 +9,10 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/apps/oauth" - "github.com/Rain-kl/Wavelet/internal/infra/persistence/idgen" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/infra/persistence/idgen" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/oauth" + "Wavelet/OpenFlare/plugins/server/repository" ) func listUsers(ctx context.Context, req listUsersRequest) (int64, []model.User, error) { diff --git a/internal/apps/admin/user/routers.go b/backend/OpenFlare/plugins/server/admin/user/routers.go similarity index 98% rename from internal/apps/admin/user/routers.go rename to backend/OpenFlare/plugins/server/admin/user/routers.go index 321bb817..4790b408 100644 --- a/internal/apps/admin/user/routers.go +++ b/backend/OpenFlare/plugins/server/admin/user/routers.go @@ -11,13 +11,14 @@ import ( "strconv" "time" - "github.com/Rain-kl/Wavelet/internal/apps/oauth" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/pkg/logger" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/oauth" + "Wavelet/pkg/logger" + "github.com/gin-gonic/gin" "gorm.io/gorm" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/pkg/response" ) // minPasswordLength 密码最小长度 diff --git a/internal/apps/admin/user/routers_test.go b/backend/OpenFlare/plugins/server/admin/user/routers_test.go similarity index 98% rename from internal/apps/admin/user/routers_test.go rename to backend/OpenFlare/plugins/server/admin/user/routers_test.go index 91e8a880..a8e3af50 100644 --- a/internal/apps/admin/user/routers_test.go +++ b/backend/OpenFlare/plugins/server/admin/user/routers_test.go @@ -12,12 +12,13 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/apps/oauth" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/testhelper" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/oauth" + "Wavelet/OpenFlare/plugins/server/testhelper" + "github.com/gin-gonic/gin" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/pkg/response" ) func setupTestRouter(authUser *model.User) *gin.Engine { diff --git a/internal/apps/cap/errs.go b/backend/OpenFlare/plugins/server/cap/errs.go similarity index 100% rename from internal/apps/cap/errs.go rename to backend/OpenFlare/plugins/server/cap/errs.go diff --git a/internal/apps/cap/manager.go b/backend/OpenFlare/plugins/server/cap/manager.go similarity index 97% rename from internal/apps/cap/manager.go rename to backend/OpenFlare/plugins/server/cap/manager.go index f2eb3bce..6bbb4a27 100644 --- a/internal/apps/cap/manager.go +++ b/backend/OpenFlare/plugins/server/cap/manager.go @@ -13,9 +13,9 @@ import ( "sync" "time" - "github.com/Rain-kl/Wavelet/internal/infra/config" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - pkgcap "github.com/Rain-kl/Wavelet/pkg/cap" + "Wavelet/OpenFlare/plugins/server/infra/config" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + pkgcap "Wavelet/OpenFlare/plugins/server/pkg/cap" ) const ( diff --git a/internal/apps/cap/manager_test.go b/backend/OpenFlare/plugins/server/cap/manager_test.go similarity index 98% rename from internal/apps/cap/manager_test.go rename to backend/OpenFlare/plugins/server/cap/manager_test.go index 4bf61c35..5ab21b8f 100644 --- a/internal/apps/cap/manager_test.go +++ b/backend/OpenFlare/plugins/server/cap/manager_test.go @@ -10,7 +10,7 @@ import ( "testing" "time" - pkgcap "github.com/Rain-kl/Wavelet/pkg/cap" + pkgcap "Wavelet/OpenFlare/plugins/server/pkg/cap" ) func installTestManagerSettings(t *testing.T) func() { diff --git a/internal/apps/cap/middleware.go b/backend/OpenFlare/plugins/server/cap/middleware.go similarity index 93% rename from internal/apps/cap/middleware.go rename to backend/OpenFlare/plugins/server/cap/middleware.go index f462baf7..a5867e01 100644 --- a/internal/apps/cap/middleware.go +++ b/backend/OpenFlare/plugins/server/cap/middleware.go @@ -6,7 +6,7 @@ package cap import ( "github.com/gin-gonic/gin" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/pkg/response" ) // VerifyMiddleware returns a Gin middleware that checks and consumes the X-Cap-Token header. diff --git a/internal/apps/cap/routers.go b/backend/OpenFlare/plugins/server/cap/routers.go similarity index 96% rename from internal/apps/cap/routers.go rename to backend/OpenFlare/plugins/server/cap/routers.go index 30e2afe7..846e4e42 100644 --- a/internal/apps/cap/routers.go +++ b/backend/OpenFlare/plugins/server/cap/routers.go @@ -6,8 +6,9 @@ package cap import ( "net/http" - "github.com/Rain-kl/Wavelet/internal/shared/response" - pkgcap "github.com/Rain-kl/Wavelet/pkg/cap" + pkgcap "Wavelet/OpenFlare/plugins/server/pkg/cap" + "Wavelet/pkg/response" + "github.com/gin-gonic/gin" ) diff --git a/internal/apps/cap/routers_test.go b/backend/OpenFlare/plugins/server/cap/routers_test.go similarity index 94% rename from internal/apps/cap/routers_test.go rename to backend/OpenFlare/plugins/server/cap/routers_test.go index 872d6d42..a2ac2dcc 100644 --- a/internal/apps/cap/routers_test.go +++ b/backend/OpenFlare/plugins/server/cap/routers_test.go @@ -14,12 +14,12 @@ import ( "github.com/gin-gonic/gin" - "github.com/Rain-kl/Wavelet/internal/infra/config" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/shared/response" - "github.com/Rain-kl/Wavelet/internal/testhelper" - pkgcap "github.com/Rain-kl/Wavelet/pkg/cap" + "Wavelet/OpenFlare/plugins/server/infra/config" + "Wavelet/OpenFlare/plugins/server/model" + pkgcap "Wavelet/OpenFlare/plugins/server/pkg/cap" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/testhelper" + "Wavelet/pkg/response" ) func decodeAPIResponse[T any](t *testing.T, body []byte) T { diff --git a/internal/apps/cap/runtime_settings.go b/backend/OpenFlare/plugins/server/cap/runtime_settings.go similarity index 96% rename from internal/apps/cap/runtime_settings.go rename to backend/OpenFlare/plugins/server/cap/runtime_settings.go index 3cd7b2ea..d8e8784e 100644 --- a/internal/apps/cap/runtime_settings.go +++ b/backend/OpenFlare/plugins/server/cap/runtime_settings.go @@ -14,10 +14,10 @@ import ( "golang.org/x/sync/singleflight" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/pkg/util" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/pkg/util" ) const ( diff --git a/internal/apps/cap/runtime_settings_test.go b/backend/OpenFlare/plugins/server/cap/runtime_settings_test.go similarity index 94% rename from internal/apps/cap/runtime_settings_test.go rename to backend/OpenFlare/plugins/server/cap/runtime_settings_test.go index 3d04bf6d..563de0ae 100644 --- a/internal/apps/cap/runtime_settings_test.go +++ b/backend/OpenFlare/plugins/server/cap/runtime_settings_test.go @@ -8,10 +8,10 @@ import ( "testing" "time" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/testhelper" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/testhelper" ) func TestCurrentSettingsLoadsSnapshotOnce(t *testing.T) { diff --git a/internal/apps/cap/testhelper_hook.go b/backend/OpenFlare/plugins/server/cap/testhelper_hook.go similarity index 73% rename from internal/apps/cap/testhelper_hook.go rename to backend/OpenFlare/plugins/server/cap/testhelper_hook.go index 61eaaaf6..219f2583 100644 --- a/internal/apps/cap/testhelper_hook.go +++ b/backend/OpenFlare/plugins/server/cap/testhelper_hook.go @@ -3,7 +3,7 @@ package cap -import "github.com/Rain-kl/Wavelet/internal/testhelper" +import "Wavelet/OpenFlare/plugins/server/testhelper" func init() { testhelper.RegisterCleanup(ResetRuntimeSettingsForTest) diff --git a/internal/apps/config/public_config_cache_test.go b/backend/OpenFlare/plugins/server/config/public_config_cache_test.go similarity index 92% rename from internal/apps/config/public_config_cache_test.go rename to backend/OpenFlare/plugins/server/config/public_config_cache_test.go index 369cfa76..36b9a7e0 100644 --- a/internal/apps/config/public_config_cache_test.go +++ b/backend/OpenFlare/plugins/server/config/public_config_cache_test.go @@ -7,9 +7,9 @@ import ( "context" "testing" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/testhelper" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/testhelper" ) func TestListVisibleSystemConfigsUsesRedisCache(t *testing.T) { diff --git a/internal/apps/config/routers.go b/backend/OpenFlare/plugins/server/config/routers.go similarity index 90% rename from internal/apps/config/routers.go rename to backend/OpenFlare/plugins/server/config/routers.go index 031b0080..2f92f494 100644 --- a/internal/apps/config/routers.go +++ b/backend/OpenFlare/plugins/server/config/routers.go @@ -8,11 +8,12 @@ package config import ( "net/http" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "github.com/gin-gonic/gin" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/pkg/response" ) // GetPublicConfig 获取公共配置 diff --git a/internal/apps/config/routers_test.go b/backend/OpenFlare/plugins/server/config/routers_test.go similarity index 93% rename from internal/apps/config/routers_test.go rename to backend/OpenFlare/plugins/server/config/routers_test.go index d05aaa25..509f9794 100644 --- a/internal/apps/config/routers_test.go +++ b/backend/OpenFlare/plugins/server/config/routers_test.go @@ -10,11 +10,12 @@ import ( "net/http/httptest" "testing" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/testhelper" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/testhelper" + "github.com/gin-gonic/gin" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/pkg/response" ) func TestGetPublicConfigUsesVisibility(t *testing.T) { diff --git a/internal/apps/config/system_config_cache_test.go b/backend/OpenFlare/plugins/server/config/system_config_cache_test.go similarity index 94% rename from internal/apps/config/system_config_cache_test.go rename to backend/OpenFlare/plugins/server/config/system_config_cache_test.go index 560de8af..521640a7 100644 --- a/internal/apps/config/system_config_cache_test.go +++ b/backend/OpenFlare/plugins/server/config/system_config_cache_test.go @@ -11,10 +11,10 @@ import ( "github.com/redis/go-redis/v9" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/testhelper" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/testhelper" ) func TestSystemConfigRAMCacheServesUntilInvalidated(t *testing.T) { diff --git a/internal/apps/health/routers.go b/backend/OpenFlare/plugins/server/health/routers.go similarity index 90% rename from internal/apps/health/routers.go rename to backend/OpenFlare/plugins/server/health/routers.go index d0d26bab..a57e0e88 100644 --- a/internal/apps/health/routers.go +++ b/backend/OpenFlare/plugins/server/health/routers.go @@ -10,7 +10,7 @@ import ( "github.com/gin-gonic/gin" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/pkg/response" ) // Health 健康检查 diff --git a/internal/infra/config/config.go b/backend/OpenFlare/plugins/server/infra/config/config.go similarity index 100% rename from internal/infra/config/config.go rename to backend/OpenFlare/plugins/server/infra/config/config.go diff --git a/internal/infra/config/config_test.go b/backend/OpenFlare/plugins/server/infra/config/config_test.go similarity index 100% rename from internal/infra/config/config_test.go rename to backend/OpenFlare/plugins/server/infra/config/config_test.go diff --git a/internal/infra/config/model.go b/backend/OpenFlare/plugins/server/infra/config/model.go similarity index 100% rename from internal/infra/config/model.go rename to backend/OpenFlare/plugins/server/infra/config/model.go diff --git a/internal/infra/diskcache/cache.go b/backend/OpenFlare/plugins/server/infra/diskcache/cache.go similarity index 92% rename from internal/infra/diskcache/cache.go rename to backend/OpenFlare/plugins/server/infra/diskcache/cache.go index e791fb2d..ab0bd109 100644 --- a/internal/infra/diskcache/cache.go +++ b/backend/OpenFlare/plugins/server/infra/diskcache/cache.go @@ -10,10 +10,10 @@ import ( "sync" "time" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - pkgcache "github.com/Rain-kl/Wavelet/pkg/cache/disk" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + pkgcache "Wavelet/pkg/cache/disk" ) // Status represents the runtime cache statistics. diff --git a/internal/infra/diskcache/cache_test.go b/backend/OpenFlare/plugins/server/infra/diskcache/cache_test.go similarity index 86% rename from internal/infra/diskcache/cache_test.go rename to backend/OpenFlare/plugins/server/infra/diskcache/cache_test.go index 416f0304..967372e5 100644 --- a/internal/infra/diskcache/cache_test.go +++ b/backend/OpenFlare/plugins/server/infra/diskcache/cache_test.go @@ -8,10 +8,10 @@ import ( "os" "testing" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/testhelper" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/testhelper" ) func TestDiskCacheReloadConfig(t *testing.T) { diff --git a/internal/infra/objectstore/config.go b/backend/OpenFlare/plugins/server/infra/objectstore/config.go similarity index 97% rename from internal/infra/objectstore/config.go rename to backend/OpenFlare/plugins/server/infra/objectstore/config.go index 0e90c4d3..e49b94ad 100644 --- a/internal/infra/objectstore/config.go +++ b/backend/OpenFlare/plugins/server/infra/objectstore/config.go @@ -12,9 +12,10 @@ import ( "strings" "time" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "gorm.io/gorm" ) diff --git a/internal/infra/objectstore/http.go b/backend/OpenFlare/plugins/server/infra/objectstore/http.go similarity index 96% rename from internal/infra/objectstore/http.go rename to backend/OpenFlare/plugins/server/infra/objectstore/http.go index fe6698e2..b1555191 100644 --- a/internal/infra/objectstore/http.go +++ b/backend/OpenFlare/plugins/server/infra/objectstore/http.go @@ -10,7 +10,7 @@ import ( "net/url" "time" - "github.com/Rain-kl/Wavelet/pkg/httppool" + "Wavelet/pkg/httppool" ) func getHTTPObject(ctx context.Context, baseURL, key string) (*Object, error) { diff --git a/internal/infra/objectstore/local.go b/backend/OpenFlare/plugins/server/infra/objectstore/local.go similarity index 100% rename from internal/infra/objectstore/local.go rename to backend/OpenFlare/plugins/server/infra/objectstore/local.go diff --git a/internal/infra/objectstore/local_test.go b/backend/OpenFlare/plugins/server/infra/objectstore/local_test.go similarity index 100% rename from internal/infra/objectstore/local_test.go rename to backend/OpenFlare/plugins/server/infra/objectstore/local_test.go diff --git a/internal/infra/objectstore/oss.go b/backend/OpenFlare/plugins/server/infra/objectstore/oss.go similarity index 100% rename from internal/infra/objectstore/oss.go rename to backend/OpenFlare/plugins/server/infra/objectstore/oss.go diff --git a/internal/infra/objectstore/s3.go b/backend/OpenFlare/plugins/server/infra/objectstore/s3.go similarity index 100% rename from internal/infra/objectstore/s3.go rename to backend/OpenFlare/plugins/server/infra/objectstore/s3.go diff --git a/internal/infra/objectstore/storage.go b/backend/OpenFlare/plugins/server/infra/objectstore/storage.go similarity index 96% rename from internal/infra/objectstore/storage.go rename to backend/OpenFlare/plugins/server/infra/objectstore/storage.go index 8822ec7e..13a96065 100644 --- a/internal/infra/objectstore/storage.go +++ b/backend/OpenFlare/plugins/server/infra/objectstore/storage.go @@ -13,10 +13,11 @@ import ( "sync" "time" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/pkg/util" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/pkg/util" + "gorm.io/gorm" ) diff --git a/internal/infra/objectstore/storage_test.go b/backend/OpenFlare/plugins/server/infra/objectstore/storage_test.go similarity index 98% rename from internal/infra/objectstore/storage_test.go rename to backend/OpenFlare/plugins/server/infra/objectstore/storage_test.go index f2881b82..46fcbae9 100644 --- a/internal/infra/objectstore/storage_test.go +++ b/backend/OpenFlare/plugins/server/infra/objectstore/storage_test.go @@ -11,7 +11,8 @@ import ( "testing" "time" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "github.com/alicebob/miniredis/v2" "github.com/redis/go-redis/v9" ) diff --git a/internal/infra/objectstore/webdav.go b/backend/OpenFlare/plugins/server/infra/objectstore/webdav.go similarity index 98% rename from internal/infra/objectstore/webdav.go rename to backend/OpenFlare/plugins/server/infra/objectstore/webdav.go index 5d8e3e65..3dd4d01b 100644 --- a/internal/infra/objectstore/webdav.go +++ b/backend/OpenFlare/plugins/server/infra/objectstore/webdav.go @@ -11,7 +11,8 @@ import ( "path" "strings" - "github.com/Rain-kl/Wavelet/pkg/httppool" + "Wavelet/pkg/httppool" + "github.com/studio-b12/gowebdav" ) diff --git a/internal/infra/persistence/clickhouse.go b/backend/OpenFlare/plugins/server/infra/persistence/clickhouse.go similarity index 98% rename from internal/infra/persistence/clickhouse.go rename to backend/OpenFlare/plugins/server/infra/persistence/clickhouse.go index 81426917..5e7c4651 100644 --- a/internal/infra/persistence/clickhouse.go +++ b/backend/OpenFlare/plugins/server/infra/persistence/clickhouse.go @@ -10,9 +10,10 @@ import ( "log" "time" + "Wavelet/OpenFlare/plugins/server/infra/config" + "github.com/ClickHouse/clickhouse-go/v2" "github.com/ClickHouse/clickhouse-go/v2/lib/driver" - "github.com/Rain-kl/Wavelet/internal/infra/config" ) const ( diff --git a/internal/infra/persistence/errs.go b/backend/OpenFlare/plugins/server/infra/persistence/errs.go similarity index 100% rename from internal/infra/persistence/errs.go rename to backend/OpenFlare/plugins/server/infra/persistence/errs.go diff --git a/internal/infra/persistence/idgen/snowflake.go b/backend/OpenFlare/plugins/server/infra/persistence/idgen/snowflake.go similarity index 95% rename from internal/infra/persistence/idgen/snowflake.go rename to backend/OpenFlare/plugins/server/infra/persistence/idgen/snowflake.go index e828a578..530bd253 100644 --- a/internal/infra/persistence/idgen/snowflake.go +++ b/backend/OpenFlare/plugins/server/infra/persistence/idgen/snowflake.go @@ -9,7 +9,8 @@ import ( "fmt" "log" - "github.com/Rain-kl/Wavelet/internal/infra/config" + "Wavelet/OpenFlare/plugins/server/infra/config" + "github.com/bwmarrin/snowflake" ) diff --git a/internal/infra/persistence/idgen/snowflake_test.go b/backend/OpenFlare/plugins/server/infra/persistence/idgen/snowflake_test.go similarity index 100% rename from internal/infra/persistence/idgen/snowflake_test.go rename to backend/OpenFlare/plugins/server/infra/persistence/idgen/snowflake_test.go diff --git a/internal/infra/persistence/migrator/clickhouse.go b/backend/OpenFlare/plugins/server/infra/persistence/migrator/clickhouse.go similarity index 98% rename from internal/infra/persistence/migrator/clickhouse.go rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/clickhouse.go index 170034fd..11a2a730 100644 --- a/internal/infra/persistence/migrator/clickhouse.go +++ b/backend/OpenFlare/plugins/server/infra/persistence/migrator/clickhouse.go @@ -12,8 +12,9 @@ import ( "log" "time" + "Wavelet/OpenFlare/plugins/server/infra/config" + "github.com/ClickHouse/clickhouse-go/v2" - "github.com/Rain-kl/Wavelet/internal/infra/config" "github.com/pressly/goose/v3" ) diff --git a/internal/infra/persistence/migrator/clickhouse_test.go b/backend/OpenFlare/plugins/server/infra/persistence/migrator/clickhouse_test.go similarity index 96% rename from internal/infra/persistence/migrator/clickhouse_test.go rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/clickhouse_test.go index 19361d0f..dc9d4cfd 100644 --- a/internal/infra/persistence/migrator/clickhouse_test.go +++ b/backend/OpenFlare/plugins/server/infra/persistence/migrator/clickhouse_test.go @@ -6,7 +6,8 @@ package migrator import ( "testing" - "github.com/Rain-kl/Wavelet/internal/infra/config" + "Wavelet/OpenFlare/plugins/server/infra/config" + "github.com/pressly/goose/v3" ) diff --git a/internal/infra/persistence/migrator/goose/clickhouse/202606190001_create_user_access_logs.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202606190001_create_user_access_logs.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/clickhouse/202606190001_create_user_access_logs.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202606190001_create_user_access_logs.sql diff --git a/internal/infra/persistence/migrator/goose/clickhouse/202606200001_create_node_access_logs.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202606200001_create_node_access_logs.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/clickhouse/202606200001_create_node_access_logs.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202606200001_create_node_access_logs.sql diff --git a/internal/infra/persistence/migrator/goose/clickhouse/202606200002_create_node_observability_tables.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202606200002_create_node_observability_tables.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/clickhouse/202606200002_create_node_observability_tables.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202606200002_create_node_observability_tables.sql diff --git a/internal/infra/persistence/migrator/goose/clickhouse/202607020001_optimize_analytics_tables.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607020001_optimize_analytics_tables.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/clickhouse/202607020001_optimize_analytics_tables.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607020001_optimize_analytics_tables.sql diff --git a/internal/infra/persistence/migrator/goose/clickhouse/202607020002_create_node_traffic_hourly_mv.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607020002_create_node_traffic_hourly_mv.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/clickhouse/202607020002_create_node_traffic_hourly_mv.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607020002_create_node_traffic_hourly_mv.sql diff --git a/internal/infra/persistence/migrator/goose/clickhouse/202607100001_create_node_metric_openresty_hourly.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607100001_create_node_metric_openresty_hourly.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/clickhouse/202607100001_create_node_metric_openresty_hourly.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607100001_create_node_metric_openresty_hourly.sql diff --git a/internal/infra/persistence/migrator/goose/clickhouse/202607100002_node_traffic_hourly_ttl_uv.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607100002_node_traffic_hourly_ttl_uv.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/clickhouse/202607100002_node_traffic_hourly_ttl_uv.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607100002_node_traffic_hourly_ttl_uv.sql diff --git a/internal/infra/persistence/migrator/goose/clickhouse/202607100003_backfill_metric_openresty_hourly.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607100003_backfill_metric_openresty_hourly.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/clickhouse/202607100003_backfill_metric_openresty_hourly.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607100003_backfill_metric_openresty_hourly.sql diff --git a/internal/infra/persistence/migrator/goose/clickhouse/202607120001_add_bytes_sent_to_node_access_logs.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607120001_add_bytes_sent_to_node_access_logs.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/clickhouse/202607120001_add_bytes_sent_to_node_access_logs.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607120001_add_bytes_sent_to_node_access_logs.sql diff --git a/internal/infra/persistence/migrator/goose/clickhouse/202607180001_access_log_request_length.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607180001_access_log_request_length.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/clickhouse/202607180001_access_log_request_length.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607180001_access_log_request_length.sql diff --git a/internal/infra/persistence/migrator/goose/clickhouse/202607180002_edge_health_access_log_hourly_drop_legacy.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607180002_edge_health_access_log_hourly_drop_legacy.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/clickhouse/202607180002_edge_health_access_log_hourly_drop_legacy.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607180002_edge_health_access_log_hourly_drop_legacy.sql diff --git a/internal/infra/persistence/migrator/goose/clickhouse/202607180003_backfill_access_log_hourly.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607180003_backfill_access_log_hourly.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/clickhouse/202607180003_backfill_access_log_hourly.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607180003_backfill_access_log_hourly.sql diff --git a/internal/infra/persistence/migrator/goose/clickhouse/202607180004_access_log_user_agent.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607180004_access_log_user_agent.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/clickhouse/202607180004_access_log_user_agent.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607180004_access_log_user_agent.sql diff --git a/internal/infra/persistence/migrator/goose/clickhouse/202607180005_access_log_cache_status.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607180005_access_log_cache_status.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/clickhouse/202607180005_access_log_cache_status.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/clickhouse/202607180005_access_log_cache_status.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606050001_bridge_legacy.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606050001_bridge_legacy.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606050001_bridge_legacy.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606050001_bridge_legacy.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606090001_initial_schema.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606090001_initial_schema.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606090001_initial_schema.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606090001_initial_schema.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606100001_create_schedules.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606100001_create_schedules.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606100001_create_schedules.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606100001_create_schedules.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606100002_access_token_is_admin.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606100002_access_token_is_admin.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606100002_access_token_is_admin.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606100002_access_token_is_admin.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606110001_remove_access_token_last_used_at.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606110001_remove_access_token_last_used_at.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606110001_remove_access_token_last_used_at.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606110001_remove_access_token_last_used_at.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606110002_alter_schedules_id_auto_increment.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606110002_alter_schedules_id_auto_increment.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606110002_alter_schedules_id_auto_increment.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606110002_alter_schedules_id_auto_increment.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606110003_rename_tables_to_w_prefix.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606110003_rename_tables_to_w_prefix.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606110003_rename_tables_to_w_prefix.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606110003_rename_tables_to_w_prefix.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606110004_add_file_access_whitelist_config.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606110004_add_file_access_whitelist_config.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606110004_add_file_access_whitelist_config.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606110004_add_file_access_whitelist_config.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606110005_add_disk_cache_configs.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606110005_add_disk_cache_configs.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606110005_add_disk_cache_configs.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606110005_add_disk_cache_configs.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606120001_add_login_session_ttl_config.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606120001_add_login_session_ttl_config.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606120001_add_login_session_ttl_config.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606120001_add_login_session_ttl_config.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606120002_add_update_upstream_repository_config.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606120002_add_update_upstream_repository_config.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606120002_add_update_upstream_repository_config.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606120002_add_update_upstream_repository_config.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606130001_add_upload_access_mode.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606130001_add_upload_access_mode.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606130001_add_upload_access_mode.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606130001_add_upload_access_mode.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606130002_expand_system_config_value.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606130002_expand_system_config_value.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606130002_expand_system_config_value.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606130002_expand_system_config_value.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606130003_add_storage_config.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606130003_add_storage_config.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606130003_add_storage_config.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606130003_add_storage_config.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606140001_create_push_tables.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606140001_create_push_tables.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606140001_create_push_tables.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606140001_create_push_tables.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606140003_add_system_user.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606140003_add_system_user.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606140003_add_system_user.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606140003_add_system_user.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606140004_create_push_channels.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606140004_create_push_channels.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606140004_create_push_channels.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606140004_create_push_channels.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606140005_update_system_cleanup_schedule.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606140005_update_system_cleanup_schedule.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606140005_update_system_cleanup_schedule.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606140005_update_system_cleanup_schedule.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606150001_add_task_type_to_push_events.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606150001_add_task_type_to_push_events.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606150001_add_task_type_to_push_events.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606150001_add_task_type_to_push_events.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606160001_remove_push_config.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606160001_remove_push_config.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606160001_remove_push_config.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606160001_remove_push_config.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606170001_add_upload_composite_indexes.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606170001_add_upload_composite_indexes.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606170001_add_upload_composite_indexes.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606170001_add_upload_composite_indexes.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606170002_create_upload_stats_table.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606170002_create_upload_stats_table.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606170002_create_upload_stats_table.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606170002_create_upload_stats_table.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606170003_backfill_upload_stats.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606170003_backfill_upload_stats.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606170003_backfill_upload_stats.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606170003_backfill_upload_stats.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606180001_drop_upload_storage_driver.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606180001_drop_upload_storage_driver.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606180001_drop_upload_storage_driver.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606180001_drop_upload_storage_driver.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606190001_create_of_options.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190001_create_of_options.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606190001_create_of_options.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190001_create_of_options.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606190002_create_of_origins.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190002_create_of_origins.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606190002_create_of_origins.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190002_create_of_origins.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606190003_create_of_apply_logs.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190003_create_of_apply_logs.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606190003_create_of_apply_logs.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190003_create_of_apply_logs.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606190004_create_of_proxy_routes.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190004_create_of_proxy_routes.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606190004_create_of_proxy_routes.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190004_create_of_proxy_routes.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606190005_create_of_nodes.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190005_create_of_nodes.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606190005_create_of_nodes.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190005_create_of_nodes.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606190006_create_of_waf_tables.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190006_create_of_waf_tables.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606190006_create_of_waf_tables.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190006_create_of_waf_tables.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606190007_create_of_tls_tables.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190007_create_of_tls_tables.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606190007_create_of_tls_tables.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190007_create_of_tls_tables.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606190008_create_of_config_versions.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190008_create_of_config_versions.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606190008_create_of_config_versions.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190008_create_of_config_versions.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606190009_create_of_pages_tables.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190009_create_of_pages_tables.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606190009_create_of_pages_tables.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190009_create_of_pages_tables.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606190010_create_of_observability_tables.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190010_create_of_observability_tables.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606190010_create_of_observability_tables.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190010_create_of_observability_tables.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606190011_add_of_node_access_logs_composite_index.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190011_add_of_node_access_logs_composite_index.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606190011_add_of_node_access_logs_composite_index.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190011_add_of_node_access_logs_composite_index.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606190012_create_of_node_obs_frpc.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190012_create_of_node_obs_frpc.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606190012_create_of_node_obs_frpc.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190012_create_of_node_obs_frpc.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606190013_seed_openflare_schedules.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190013_seed_openflare_schedules.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606190013_seed_openflare_schedules.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190013_seed_openflare_schedules.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606190014_add_pages_deployment_upload_id.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190014_add_pages_deployment_upload_id.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606190014_add_pages_deployment_upload_id.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606190014_add_pages_deployment_upload_id.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606200001_rebrand_openflare_defaults.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606200001_rebrand_openflare_defaults.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606200001_rebrand_openflare_defaults.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606200001_rebrand_openflare_defaults.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606200002_update_security_defaults.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606200002_update_security_defaults.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606200002_update_security_defaults.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606200002_update_security_defaults.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606200003_remove_legacy_rate_limit_options.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606200003_remove_legacy_rate_limit_options.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606200003_remove_legacy_rate_limit_options.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606200003_remove_legacy_rate_limit_options.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606200004_drop_of_node_access_logs.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606200004_drop_of_node_access_logs.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606200004_drop_of_node_access_logs.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606200004_drop_of_node_access_logs.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606200005_drop_of_node_observability_timeseries.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606200005_drop_of_node_observability_timeseries.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606200005_drop_of_node_observability_timeseries.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606200005_drop_of_node_observability_timeseries.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606200006_migrate_legacy_data.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606200006_migrate_legacy_data.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606200006_migrate_legacy_data.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606200006_migrate_legacy_data.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606200007_sync_of_waf_binding_sequence.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606200007_sync_of_waf_binding_sequence.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606200007_sync_of_waf_binding_sequence.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606200007_sync_of_waf_binding_sequence.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606220001_remove_legacy_auth_options.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606220001_remove_legacy_auth_options.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606220001_remove_legacy_auth_options.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606220001_remove_legacy_auth_options.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606220002_remove_notice_option.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606220002_remove_notice_option.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606220002_remove_notice_option.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606220002_remove_notice_option.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606220003_add_frps_web_ui_config.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606220003_add_frps_web_ui_config.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606220003_add_frps_web_ui_config.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606220003_add_frps_web_ui_config.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606220004_migrate_of_options_to_system_configs.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606220004_migrate_of_options_to_system_configs.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606220004_migrate_of_options_to_system_configs.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606220004_migrate_of_options_to_system_configs.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606220005_drop_of_options.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606220005_drop_of_options.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606220005_drop_of_options.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606220005_drop_of_options.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606270001_make_version_primary_key.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606270001_make_version_primary_key.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606270001_make_version_primary_key.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606270001_make_version_primary_key.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606300001_sync_all_legacy_sequences.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606300001_sync_all_legacy_sequences.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606300001_sync_all_legacy_sequences.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202606300001_sync_all_legacy_sequences.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202607120001_create_zone_domain_tables.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607120001_create_zone_domain_tables.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202607120001_create_zone_domain_tables.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607120001_create_zone_domain_tables.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202607120002_import_zone_domains.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607120002_import_zone_domains.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202607120002_import_zone_domains.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607120002_import_zone_domains.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202607130001_drop_legacy_route_domain_columns.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607130001_drop_legacy_route_domain_columns.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202607130001_drop_legacy_route_domain_columns.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607130001_drop_legacy_route_domain_columns.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202607140001_drop_unused_remark_columns.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607140001_drop_unused_remark_columns.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202607140001_drop_unused_remark_columns.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607140001_drop_unused_remark_columns.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202607150001_orchestrate_waf_rules.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607150001_orchestrate_waf_rules.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202607150001_orchestrate_waf_rules.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607150001_orchestrate_waf_rules.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202607150002_reset_waf_rule_graphs.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607150002_reset_waf_rule_graphs.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202607150002_reset_waf_rule_graphs.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607150002_reset_waf_rule_graphs.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202607150003_drop_legacy_waf_rule_fields.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607150003_drop_legacy_waf_rule_fields.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202607150003_drop_legacy_waf_rule_fields.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607150003_drop_legacy_waf_rule_fields.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202607170001_add_pages_system_configs.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607170001_add_pages_system_configs.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202607170001_add_pages_system_configs.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607170001_add_pages_system_configs.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202607180001_update_agent_heartbeat_offline_defaults.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607180001_update_agent_heartbeat_offline_defaults.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202607180001_update_agent_heartbeat_offline_defaults.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607180001_update_agent_heartbeat_offline_defaults.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202607190001_add_openresty_default_rate_limits.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607190001_add_openresty_default_rate_limits.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202607190001_add_openresty_default_rate_limits.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607190001_add_openresty_default_rate_limits.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202607190002_add_pages_source_runtime.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607190002_add_pages_source_runtime.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202607190002_add_pages_source_runtime.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607190002_add_pages_source_runtime.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202607190003_seed_pages_source_scan.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607190003_seed_pages_source_scan.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202607190003_seed_pages_source_scan.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607190003_seed_pages_source_scan.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202607200001_add_openresty_default_limit_req_per_ip.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607200001_add_openresty_default_limit_req_per_ip.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202607200001_add_openresty_default_limit_req_per_ip.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202607200001_add_openresty_default_limit_req_per_ip.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202608040001_create_cloudflare_pointing.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608040001_create_cloudflare_pointing.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202608040001_create_cloudflare_pointing.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608040001_create_cloudflare_pointing.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202608060001_add_origin_error_page_options.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608060001_add_origin_error_page_options.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202608060001_add_origin_error_page_options.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608060001_add_origin_error_page_options.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202608060002_add_origin_error_page_get_only.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608060002_add_origin_error_page_get_only.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202608060002_add_origin_error_page_get_only.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608060002_add_origin_error_page_get_only.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202608070001_add_sw_offline_options.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608070001_add_sw_offline_options.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202608070001_add_sw_offline_options.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608070001_add_sw_offline_options.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202608070002_add_sw_offline_domains.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608070002_add_sw_offline_domains.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202608070002_add_sw_offline_domains.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608070002_add_sw_offline_domains.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202608080001_create_log_tables.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608080001_create_log_tables.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202608080001_create_log_tables.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608080001_create_log_tables.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202608080002_log_retention_configs.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608080002_log_retention_configs.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202608080002_log_retention_configs.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608080002_log_retention_configs.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202608080003_drop_database_cleanup_schedule.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608080003_drop_database_cleanup_schedule.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202608080003_drop_database_cleanup_schedule.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608080003_drop_database_cleanup_schedule.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202608090001_metric_retention_config.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608090001_metric_retention_config.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202608090001_metric_retention_config.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608090001_metric_retention_config.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202608090002_update_system_cleanup_schedule.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608090002_update_system_cleanup_schedule.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202608090002_update_system_cleanup_schedule.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608090002_update_system_cleanup_schedule.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202608090003_add_log_indexes.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608090003_add_log_indexes.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202608090003_add_log_indexes.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/postgres/202608090003_add_log_indexes.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606050001_bridge_legacy.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606050001_bridge_legacy.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606050001_bridge_legacy.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606050001_bridge_legacy.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606090001_initial_schema.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606090001_initial_schema.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606090001_initial_schema.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606090001_initial_schema.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606100001_create_schedules.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606100001_create_schedules.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606100001_create_schedules.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606100001_create_schedules.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606100002_access_token_is_admin.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606100002_access_token_is_admin.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606100002_access_token_is_admin.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606100002_access_token_is_admin.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606110001_remove_access_token_last_used_at.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606110001_remove_access_token_last_used_at.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606110001_remove_access_token_last_used_at.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606110001_remove_access_token_last_used_at.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606110002_alter_schedules_id_auto_increment.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606110002_alter_schedules_id_auto_increment.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606110002_alter_schedules_id_auto_increment.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606110002_alter_schedules_id_auto_increment.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606110003_rename_tables_to_w_prefix.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606110003_rename_tables_to_w_prefix.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606110003_rename_tables_to_w_prefix.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606110003_rename_tables_to_w_prefix.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606110004_add_file_access_whitelist_config.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606110004_add_file_access_whitelist_config.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606110004_add_file_access_whitelist_config.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606110004_add_file_access_whitelist_config.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606110005_add_disk_cache_configs.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606110005_add_disk_cache_configs.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606110005_add_disk_cache_configs.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606110005_add_disk_cache_configs.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606120001_add_login_session_ttl_config.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606120001_add_login_session_ttl_config.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606120001_add_login_session_ttl_config.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606120001_add_login_session_ttl_config.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606120002_add_update_upstream_repository_config.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606120002_add_update_upstream_repository_config.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606120002_add_update_upstream_repository_config.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606120002_add_update_upstream_repository_config.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606130001_add_upload_access_mode.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606130001_add_upload_access_mode.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606130001_add_upload_access_mode.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606130001_add_upload_access_mode.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606130002_expand_system_config_value.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606130002_expand_system_config_value.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606130002_expand_system_config_value.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606130002_expand_system_config_value.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606130003_add_storage_config.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606130003_add_storage_config.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606130003_add_storage_config.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606130003_add_storage_config.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606140001_create_push_tables.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606140001_create_push_tables.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606140001_create_push_tables.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606140001_create_push_tables.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606140003_add_system_user.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606140003_add_system_user.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606140003_add_system_user.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606140003_add_system_user.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606140004_create_push_channels.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606140004_create_push_channels.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606140004_create_push_channels.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606140004_create_push_channels.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606140005_update_system_cleanup_schedule.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606140005_update_system_cleanup_schedule.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606140005_update_system_cleanup_schedule.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606140005_update_system_cleanup_schedule.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606150001_add_task_type_to_push_events.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606150001_add_task_type_to_push_events.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606150001_add_task_type_to_push_events.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606150001_add_task_type_to_push_events.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606160001_remove_push_config.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606160001_remove_push_config.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606160001_remove_push_config.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606160001_remove_push_config.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606170001_add_upload_composite_indexes.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606170001_add_upload_composite_indexes.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606170001_add_upload_composite_indexes.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606170001_add_upload_composite_indexes.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606170002_create_upload_stats_table.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606170002_create_upload_stats_table.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606170002_create_upload_stats_table.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606170002_create_upload_stats_table.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606170003_backfill_upload_stats.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606170003_backfill_upload_stats.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606170003_backfill_upload_stats.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606170003_backfill_upload_stats.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606180001_drop_upload_storage_driver.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606180001_drop_upload_storage_driver.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606180001_drop_upload_storage_driver.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606180001_drop_upload_storage_driver.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606190001_create_of_options.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190001_create_of_options.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606190001_create_of_options.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190001_create_of_options.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606190002_create_of_origins.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190002_create_of_origins.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606190002_create_of_origins.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190002_create_of_origins.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606190003_create_of_apply_logs.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190003_create_of_apply_logs.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606190003_create_of_apply_logs.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190003_create_of_apply_logs.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606190004_create_of_proxy_routes.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190004_create_of_proxy_routes.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606190004_create_of_proxy_routes.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190004_create_of_proxy_routes.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606190005_create_of_nodes.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190005_create_of_nodes.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606190005_create_of_nodes.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190005_create_of_nodes.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606190006_create_of_waf_tables.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190006_create_of_waf_tables.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606190006_create_of_waf_tables.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190006_create_of_waf_tables.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606190007_create_of_tls_tables.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190007_create_of_tls_tables.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606190007_create_of_tls_tables.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190007_create_of_tls_tables.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606190008_create_of_config_versions.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190008_create_of_config_versions.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606190008_create_of_config_versions.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190008_create_of_config_versions.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606190009_create_of_pages_tables.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190009_create_of_pages_tables.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606190009_create_of_pages_tables.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190009_create_of_pages_tables.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606190010_create_of_observability_tables.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190010_create_of_observability_tables.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606190010_create_of_observability_tables.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190010_create_of_observability_tables.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606190011_add_of_node_access_logs_composite_index.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190011_add_of_node_access_logs_composite_index.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606190011_add_of_node_access_logs_composite_index.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190011_add_of_node_access_logs_composite_index.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606190012_create_of_node_obs_frpc.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190012_create_of_node_obs_frpc.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606190012_create_of_node_obs_frpc.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190012_create_of_node_obs_frpc.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606190013_seed_openflare_schedules.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190013_seed_openflare_schedules.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606190013_seed_openflare_schedules.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190013_seed_openflare_schedules.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606190014_add_pages_deployment_upload_id.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190014_add_pages_deployment_upload_id.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606190014_add_pages_deployment_upload_id.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606190014_add_pages_deployment_upload_id.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606200001_rebrand_openflare_defaults.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606200001_rebrand_openflare_defaults.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606200001_rebrand_openflare_defaults.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606200001_rebrand_openflare_defaults.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606200002_update_security_defaults.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606200002_update_security_defaults.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606200002_update_security_defaults.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606200002_update_security_defaults.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606200003_remove_legacy_rate_limit_options.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606200003_remove_legacy_rate_limit_options.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606200003_remove_legacy_rate_limit_options.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606200003_remove_legacy_rate_limit_options.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606200004_drop_of_node_access_logs.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606200004_drop_of_node_access_logs.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606200004_drop_of_node_access_logs.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606200004_drop_of_node_access_logs.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606200005_drop_of_node_observability_timeseries.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606200005_drop_of_node_observability_timeseries.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606200005_drop_of_node_observability_timeseries.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606200005_drop_of_node_observability_timeseries.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606200006_migrate_legacy_data.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606200006_migrate_legacy_data.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606200006_migrate_legacy_data.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606200006_migrate_legacy_data.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606200007_sync_of_waf_binding_sequence.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606200007_sync_of_waf_binding_sequence.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606200007_sync_of_waf_binding_sequence.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606200007_sync_of_waf_binding_sequence.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606220001_remove_legacy_auth_options.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606220001_remove_legacy_auth_options.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606220001_remove_legacy_auth_options.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606220001_remove_legacy_auth_options.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606220002_remove_notice_option.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606220002_remove_notice_option.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606220002_remove_notice_option.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606220002_remove_notice_option.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606220003_add_frps_web_ui_config.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606220003_add_frps_web_ui_config.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606220003_add_frps_web_ui_config.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606220003_add_frps_web_ui_config.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606220004_migrate_of_options_to_system_configs.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606220004_migrate_of_options_to_system_configs.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606220004_migrate_of_options_to_system_configs.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606220004_migrate_of_options_to_system_configs.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606220005_drop_of_options.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606220005_drop_of_options.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606220005_drop_of_options.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606220005_drop_of_options.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606270001_make_version_primary_key.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606270001_make_version_primary_key.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606270001_make_version_primary_key.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606270001_make_version_primary_key.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606300001_sync_all_legacy_sequences.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606300001_sync_all_legacy_sequences.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606300001_sync_all_legacy_sequences.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202606300001_sync_all_legacy_sequences.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202607120001_create_zone_domain_tables.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607120001_create_zone_domain_tables.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202607120001_create_zone_domain_tables.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607120001_create_zone_domain_tables.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202607120002_import_zone_domains.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607120002_import_zone_domains.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202607120002_import_zone_domains.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607120002_import_zone_domains.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202607130001_drop_legacy_route_domain_columns.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607130001_drop_legacy_route_domain_columns.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202607130001_drop_legacy_route_domain_columns.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607130001_drop_legacy_route_domain_columns.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202607140001_drop_unused_remark_columns.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607140001_drop_unused_remark_columns.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202607140001_drop_unused_remark_columns.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607140001_drop_unused_remark_columns.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202607150001_orchestrate_waf_rules.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607150001_orchestrate_waf_rules.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202607150001_orchestrate_waf_rules.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607150001_orchestrate_waf_rules.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202607150002_reset_waf_rule_graphs.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607150002_reset_waf_rule_graphs.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202607150002_reset_waf_rule_graphs.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607150002_reset_waf_rule_graphs.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202607150003_drop_legacy_waf_rule_fields.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607150003_drop_legacy_waf_rule_fields.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202607150003_drop_legacy_waf_rule_fields.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607150003_drop_legacy_waf_rule_fields.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202607170001_add_pages_system_configs.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607170001_add_pages_system_configs.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202607170001_add_pages_system_configs.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607170001_add_pages_system_configs.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202607180001_update_agent_heartbeat_offline_defaults.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607180001_update_agent_heartbeat_offline_defaults.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202607180001_update_agent_heartbeat_offline_defaults.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607180001_update_agent_heartbeat_offline_defaults.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202607190001_add_openresty_default_rate_limits.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607190001_add_openresty_default_rate_limits.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202607190001_add_openresty_default_rate_limits.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607190001_add_openresty_default_rate_limits.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202607190002_add_pages_source_runtime.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607190002_add_pages_source_runtime.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202607190002_add_pages_source_runtime.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607190002_add_pages_source_runtime.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202607190003_seed_pages_source_scan.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607190003_seed_pages_source_scan.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202607190003_seed_pages_source_scan.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607190003_seed_pages_source_scan.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202607200001_add_openresty_default_limit_req_per_ip.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607200001_add_openresty_default_limit_req_per_ip.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202607200001_add_openresty_default_limit_req_per_ip.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202607200001_add_openresty_default_limit_req_per_ip.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202608040001_create_cloudflare_pointing.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608040001_create_cloudflare_pointing.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202608040001_create_cloudflare_pointing.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608040001_create_cloudflare_pointing.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202608060001_add_origin_error_page_options.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608060001_add_origin_error_page_options.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202608060001_add_origin_error_page_options.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608060001_add_origin_error_page_options.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202608060002_add_origin_error_page_get_only.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608060002_add_origin_error_page_get_only.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202608060002_add_origin_error_page_get_only.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608060002_add_origin_error_page_get_only.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202608070001_add_sw_offline_options.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608070001_add_sw_offline_options.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202608070001_add_sw_offline_options.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608070001_add_sw_offline_options.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202608070002_add_sw_offline_domains.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608070002_add_sw_offline_domains.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202608070002_add_sw_offline_domains.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608070002_add_sw_offline_domains.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202608080001_create_log_tables.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608080001_create_log_tables.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202608080001_create_log_tables.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608080001_create_log_tables.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202608080002_log_retention_configs.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608080002_log_retention_configs.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202608080002_log_retention_configs.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608080002_log_retention_configs.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202608080003_drop_database_cleanup_schedule.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608080003_drop_database_cleanup_schedule.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202608080003_drop_database_cleanup_schedule.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608080003_drop_database_cleanup_schedule.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202608090001_metric_retention_config.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608090001_metric_retention_config.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202608090001_metric_retention_config.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608090001_metric_retention_config.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202608090002_update_system_cleanup_schedule.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608090002_update_system_cleanup_schedule.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202608090002_update_system_cleanup_schedule.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608090002_update_system_cleanup_schedule.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202608090003_add_log_indexes.sql b/backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608090003_add_log_indexes.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202608090003_add_log_indexes.sql rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/goose/sqlite/202608090003_add_log_indexes.sql diff --git a/backend/OpenFlare/plugins/server/infra/persistence/migrator/legacy_dump_test.go b/backend/OpenFlare/plugins/server/infra/persistence/migrator/legacy_dump_test.go new file mode 100644 index 00000000..d72aa134 --- /dev/null +++ b/backend/OpenFlare/plugins/server/infra/persistence/migrator/legacy_dump_test.go @@ -0,0 +1,140 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package migrator + +import ( + "context" + "database/sql" + "fmt" + "os" + "strings" + "testing" + + "Wavelet/OpenFlare/plugins/server/openflare/zone" + + "github.com/glebarez/sqlite" + "github.com/pressly/goose/v3" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +// 记账表随迁移路径不同而不同(历史链用 goose_db_version,插件化后用 w_schema_versions), +// 比对 schema 时必须排除,否则 A/B 两路必然假性不一致。 +var bookkeepingTables = []string{"goose_db_version", "w_schema_versions"} + +// TestDumpLegacySchema 把当前 embed 内的完整历史链应用到临时 sqlite 库并导出 schema, +// 作为 Cordis 改造前后 schema 一致性的基线(A 路)与新架构产出(B 路)的唯一事实来源。 +// +// OF_DUMP_SCHEMA= 导出表/索引定义;OF_DUMP_VERSIONS= 导出已应用版本序列。 +func TestDumpLegacySchema(t *testing.T) { + dumpPath := os.Getenv("OF_DUMP_SCHEMA") + if dumpPath == "" && os.Getenv("OF_DUMP_VERSIONS") == "" { + t.Skip("OF_DUMP_SCHEMA / OF_DUMP_VERSIONS not set") + } + + conn, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + sqlDB, err := conn.DB() + require.NoError(t, err) + // :memory: 连接池仅一条连接可见,否则 goose 与 dump 会看到不同的空库。 + sqlDB.SetMaxOpenConns(1) + + goose.SetBaseFS(migrationFS) + require.NoError(t, goose.SetDialect(dialectSqlite)) + + // 与 Migrate() 保持同一顺序:SQL 到 zone 导入标记 → Go 侧导入 → 其余 SQL。 + require.NoError(t, goose.UpTo(sqlDB, "goose/sqlite", zoneImportSQLVersion)) + require.NoError(t, runZoneImport(sqlDB)) + require.NoError(t, goose.Up(sqlDB, "goose/sqlite")) + + if dumpPath != "" { + schema := dumpSchema(t, sqlDB) + require.NoError(t, os.WriteFile(dumpPath, []byte(schema), 0o600)) + } + if vPath := os.Getenv("OF_DUMP_VERSIONS"); vPath != "" { + versions := dumpVersions(t, sqlDB) + require.NoError(t, os.WriteFile(vPath, []byte(versions), 0o600)) + } +} + +// runZoneImport 在空库上执行 Go 侧 zone 导入,验证升级窗口钩子本身可运行(空库应为零导入)。 +func runZoneImport(sqlDB *sql.DB) error { + ctx := context.Background() + tx, err := sqlDB.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin zone import: %w", err) + } + report, err := zone.ImportLegacyTx(ctx, tx, false) + if err != nil { + _ = tx.Rollback() + return report.LogAndReturn(err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit zone import: %w", err) + } + return nil +} + +func dumpSchema(t *testing.T, sqlDB *sql.DB) string { + t.Helper() + query := `SELECT type, name, COALESCE(sql, '') FROM sqlite_master + WHERE type IN ('table', 'index') AND name NOT LIKE 'sqlite_%' + ORDER BY type, name` + rows, err := sqlDB.Query(query) + require.NoError(t, err) + defer func() { _ = rows.Close() }() + + var b strings.Builder + for rows.Next() { + var objType, name, ddl string + require.NoError(t, rows.Scan(&objType, &name, &ddl)) + if isBookkeeping(name) { + continue + } + fmt.Fprintf(&b, "-- %s %s\n%s;\n", objType, name, strings.TrimSpace(ddl)) + } + require.NoError(t, rows.Err()) + return b.String() + fmt.Sprintf("-- total objects (excl. bookkeeping): %d\n", countObjects(t, sqlDB)) +} + +func countObjects(t *testing.T, sqlDB *sql.DB) int { + t.Helper() + query := `SELECT COUNT(*) FROM sqlite_master WHERE type IN ('table','index') + AND name NOT LIKE 'sqlite_%'` + var n int + require.NoError(t, sqlDB.QueryRow(query).Scan(&n)) + for _, tbl := range bookkeepingTables { + var has int + require.NoError(t, sqlDB.QueryRow( + "SELECT COUNT(*) FROM sqlite_master WHERE name = ?", tbl).Scan(&has)) + n -= has + } + return n +} + +func dumpVersions(t *testing.T, sqlDB *sql.DB) string { + t.Helper() + rows, err := sqlDB.Query( + "SELECT version_id FROM goose_db_version WHERE is_applied = 1 ORDER BY version_id") + require.NoError(t, err) + defer func() { _ = rows.Close() }() + + var b strings.Builder + for rows.Next() { + var v int64 + require.NoError(t, rows.Scan(&v)) + fmt.Fprintf(&b, "%d\n", v) + } + require.NoError(t, rows.Err()) + return b.String() +} + +func isBookkeeping(name string) bool { + for _, tbl := range bookkeepingTables { + if name == tbl { + return true + } + } + return false +} diff --git a/internal/infra/persistence/migrator/migrator.go b/backend/OpenFlare/plugins/server/infra/persistence/migrator/migrator.go similarity index 96% rename from internal/infra/persistence/migrator/migrator.go rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/migrator.go index a05957bd..53707d0e 100644 --- a/internal/infra/persistence/migrator/migrator.go +++ b/backend/OpenFlare/plugins/server/infra/persistence/migrator/migrator.go @@ -12,10 +12,11 @@ import ( "fmt" "log" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/zone" - "github.com/Rain-kl/Wavelet/internal/infra/config" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/infra/config" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/openflare/zone" + "Wavelet/OpenFlare/plugins/server/repository" + "github.com/pressly/goose/v3" ) diff --git a/internal/infra/persistence/migrator/migrator_test.go b/backend/OpenFlare/plugins/server/infra/persistence/migrator/migrator_test.go similarity index 97% rename from internal/infra/persistence/migrator/migrator_test.go rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/migrator_test.go index cd55aded..b8b8a1c8 100644 --- a/internal/infra/persistence/migrator/migrator_test.go +++ b/backend/OpenFlare/plugins/server/infra/persistence/migrator/migrator_test.go @@ -8,10 +8,11 @@ import ( "strings" "testing" - "github.com/Rain-kl/Wavelet/internal/infra/config" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/infra/config" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "github.com/alicebob/miniredis/v2" "github.com/glebarez/sqlite" "github.com/redis/go-redis/v9" diff --git a/internal/infra/persistence/migrator/pages_source_migration_test.go b/backend/OpenFlare/plugins/server/infra/persistence/migrator/pages_source_migration_test.go similarity index 99% rename from internal/infra/persistence/migrator/pages_source_migration_test.go rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/pages_source_migration_test.go index 7bdbb577..e44c2f2b 100644 --- a/internal/infra/persistence/migrator/pages_source_migration_test.go +++ b/backend/OpenFlare/plugins/server/infra/persistence/migrator/pages_source_migration_test.go @@ -11,7 +11,8 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/model" + "Wavelet/OpenFlare/plugins/server/model" + "github.com/glebarez/sqlite" "github.com/pressly/goose/v3" "github.com/stretchr/testify/assert" diff --git a/internal/infra/persistence/migrator/pages_source_scan_migration_test.go b/backend/OpenFlare/plugins/server/infra/persistence/migrator/pages_source_scan_migration_test.go similarity index 99% rename from internal/infra/persistence/migrator/pages_source_scan_migration_test.go rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/pages_source_scan_migration_test.go index 1436c33d..e26ce032 100644 --- a/internal/infra/persistence/migrator/pages_source_scan_migration_test.go +++ b/backend/OpenFlare/plugins/server/infra/persistence/migrator/pages_source_scan_migration_test.go @@ -11,7 +11,8 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/model" + "Wavelet/OpenFlare/plugins/server/model" + "github.com/glebarez/sqlite" "github.com/pressly/goose/v3" "github.com/stretchr/testify/assert" diff --git a/internal/infra/persistence/migrator/system_cleanup_migration_test.go b/backend/OpenFlare/plugins/server/infra/persistence/migrator/system_cleanup_migration_test.go similarity index 98% rename from internal/infra/persistence/migrator/system_cleanup_migration_test.go rename to backend/OpenFlare/plugins/server/infra/persistence/migrator/system_cleanup_migration_test.go index 1e180430..a5d8bdfc 100644 --- a/internal/infra/persistence/migrator/system_cleanup_migration_test.go +++ b/backend/OpenFlare/plugins/server/infra/persistence/migrator/system_cleanup_migration_test.go @@ -11,7 +11,8 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/model" + "Wavelet/OpenFlare/plugins/server/model" + "github.com/glebarez/sqlite" "github.com/pressly/goose/v3" "github.com/stretchr/testify/assert" diff --git a/internal/infra/persistence/postgres.go b/backend/OpenFlare/plugins/server/infra/persistence/postgres.go similarity index 99% rename from internal/infra/persistence/postgres.go rename to backend/OpenFlare/plugins/server/infra/persistence/postgres.go index 78760855..3629c07c 100644 --- a/internal/infra/persistence/postgres.go +++ b/backend/OpenFlare/plugins/server/infra/persistence/postgres.go @@ -12,7 +12,8 @@ import ( "strconv" "time" - "github.com/Rain-kl/Wavelet/internal/infra/config" + "Wavelet/OpenFlare/plugins/server/infra/config" + "github.com/glebarez/sqlite" "go.opentelemetry.io/otel/attribute" "gorm.io/driver/postgres" diff --git a/internal/infra/persistence/postgres_logger.go b/backend/OpenFlare/plugins/server/infra/persistence/postgres_logger.go similarity index 98% rename from internal/infra/persistence/postgres_logger.go rename to backend/OpenFlare/plugins/server/infra/persistence/postgres_logger.go index d7398daf..2c8370ef 100644 --- a/internal/infra/persistence/postgres_logger.go +++ b/backend/OpenFlare/plugins/server/infra/persistence/postgres_logger.go @@ -11,7 +11,8 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/pkg/logger" + "Wavelet/pkg/logger" + "gorm.io/gorm" gormLogger "gorm.io/gorm/logger" ) diff --git a/internal/infra/persistence/postgres_logger_test.go b/backend/OpenFlare/plugins/server/infra/persistence/postgres_logger_test.go similarity index 100% rename from internal/infra/persistence/postgres_logger_test.go rename to backend/OpenFlare/plugins/server/infra/persistence/postgres_logger_test.go diff --git a/internal/infra/persistence/postgres_tracing_test.go b/backend/OpenFlare/plugins/server/infra/persistence/postgres_tracing_test.go similarity index 100% rename from internal/infra/persistence/postgres_tracing_test.go rename to backend/OpenFlare/plugins/server/infra/persistence/postgres_tracing_test.go diff --git a/internal/infra/persistence/redis.go b/backend/OpenFlare/plugins/server/infra/persistence/redis.go similarity index 99% rename from internal/infra/persistence/redis.go rename to backend/OpenFlare/plugins/server/infra/persistence/redis.go index 78f3e28b..c7dcdb05 100644 --- a/internal/infra/persistence/redis.go +++ b/backend/OpenFlare/plugins/server/infra/persistence/redis.go @@ -13,7 +13,8 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/infra/config" + "Wavelet/OpenFlare/plugins/server/infra/config" + "github.com/redis/go-redis/extra/redisotel/v9" "github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9/maintnotifications" diff --git a/internal/infra/persistence/redis_test.go b/backend/OpenFlare/plugins/server/infra/persistence/redis_test.go similarity index 100% rename from internal/infra/persistence/redis_test.go rename to backend/OpenFlare/plugins/server/infra/persistence/redis_test.go diff --git a/internal/infra/task/constants.go b/backend/OpenFlare/plugins/server/infra/task/constants.go similarity index 100% rename from internal/infra/task/constants.go rename to backend/OpenFlare/plugins/server/infra/task/constants.go diff --git a/internal/infra/task/errs.go b/backend/OpenFlare/plugins/server/infra/task/errs.go similarity index 100% rename from internal/infra/task/errs.go rename to backend/OpenFlare/plugins/server/infra/task/errs.go diff --git a/internal/infra/task/executor.go b/backend/OpenFlare/plugins/server/infra/task/executor.go similarity index 98% rename from internal/infra/task/executor.go rename to backend/OpenFlare/plugins/server/infra/task/executor.go index 89ef029f..1f530c76 100644 --- a/internal/infra/task/executor.go +++ b/backend/OpenFlare/plugins/server/infra/task/executor.go @@ -11,12 +11,13 @@ import ( "fmt" "time" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" + + "Wavelet/OpenFlare/plugins/server/infra/persistence/idgen" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/pkg/logger" + otel_trace "Wavelet/pkg/trace" - "github.com/Rain-kl/Wavelet/internal/infra/persistence/idgen" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/pkg/logger" - otel_trace "github.com/Rain-kl/Wavelet/pkg/trace" "github.com/hibiken/asynq" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" diff --git a/internal/infra/task/executor_test.go b/backend/OpenFlare/plugins/server/infra/task/executor_test.go similarity index 98% rename from internal/infra/task/executor_test.go rename to backend/OpenFlare/plugins/server/infra/task/executor_test.go index 44efc23e..c89e1f8d 100644 --- a/internal/infra/task/executor_test.go +++ b/backend/OpenFlare/plugins/server/infra/task/executor_test.go @@ -11,11 +11,12 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" + + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/testhelper" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/testhelper" "github.com/hibiken/asynq" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/internal/infra/task/handler.go b/backend/OpenFlare/plugins/server/infra/task/handler.go similarity index 100% rename from internal/infra/task/handler.go rename to backend/OpenFlare/plugins/server/infra/task/handler.go diff --git a/internal/infra/task/handlers/register.go b/backend/OpenFlare/plugins/server/infra/task/handlers/register.go similarity index 82% rename from internal/infra/task/handlers/register.go rename to backend/OpenFlare/plugins/server/infra/task/handlers/register.go index 2da26e4c..621950b5 100644 --- a/internal/infra/task/handlers/register.go +++ b/backend/OpenFlare/plugins/server/infra/task/handlers/register.go @@ -6,15 +6,15 @@ package handlers import ( - "github.com/Rain-kl/Wavelet/internal/apps/admin/push" - "github.com/Rain-kl/Wavelet/internal/apps/openflare" - cf "github.com/Rain-kl/Wavelet/internal/apps/openflare/cloudflare" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/pages" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/tasks" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/tls" - "github.com/Rain-kl/Wavelet/internal/apps/upload" - "github.com/Rain-kl/Wavelet/internal/apps/user" - "github.com/Rain-kl/Wavelet/internal/infra/task" + "Wavelet/OpenFlare/plugins/server/admin/push" + "Wavelet/OpenFlare/plugins/server/infra/task" + "Wavelet/OpenFlare/plugins/server/openflare" + cf "Wavelet/OpenFlare/plugins/server/openflare/cloudflare" + "Wavelet/OpenFlare/plugins/server/openflare/pages" + "Wavelet/OpenFlare/plugins/server/openflare/tasks" + "Wavelet/OpenFlare/plugins/server/openflare/tls" + "Wavelet/OpenFlare/plugins/server/upload" + "Wavelet/OpenFlare/plugins/server/user" ) // Register registers all built-in task handlers and their metadata. diff --git a/internal/infra/task/meta.go b/backend/OpenFlare/plugins/server/infra/task/meta.go similarity index 100% rename from internal/infra/task/meta.go rename to backend/OpenFlare/plugins/server/infra/task/meta.go diff --git a/internal/infra/task/meta_test.go b/backend/OpenFlare/plugins/server/infra/task/meta_test.go similarity index 91% rename from internal/infra/task/meta_test.go rename to backend/OpenFlare/plugins/server/infra/task/meta_test.go index c92eb099..4ff4c522 100644 --- a/internal/infra/task/meta_test.go +++ b/backend/OpenFlare/plugins/server/infra/task/meta_test.go @@ -6,8 +6,8 @@ package task_test import ( "testing" - "github.com/Rain-kl/Wavelet/internal/infra/task" - taskhandlers "github.com/Rain-kl/Wavelet/internal/infra/task/handlers" + "Wavelet/OpenFlare/plugins/server/infra/task" + taskhandlers "Wavelet/OpenFlare/plugins/server/infra/task/handlers" ) func TestDuplicateTaskMeta(t *testing.T) { diff --git a/internal/infra/task/permanent_error.go b/backend/OpenFlare/plugins/server/infra/task/permanent_error.go similarity index 100% rename from internal/infra/task/permanent_error.go rename to backend/OpenFlare/plugins/server/infra/task/permanent_error.go diff --git a/internal/infra/task/permanent_error_test.go b/backend/OpenFlare/plugins/server/infra/task/permanent_error_test.go similarity index 100% rename from internal/infra/task/permanent_error_test.go rename to backend/OpenFlare/plugins/server/infra/task/permanent_error_test.go diff --git a/internal/infra/task/scheduler/errs.go b/backend/OpenFlare/plugins/server/infra/task/scheduler/errs.go similarity index 100% rename from internal/infra/task/scheduler/errs.go rename to backend/OpenFlare/plugins/server/infra/task/scheduler/errs.go diff --git a/internal/infra/task/scheduler/scheduler.go b/backend/OpenFlare/plugins/server/infra/task/scheduler/scheduler.go similarity index 94% rename from internal/infra/task/scheduler/scheduler.go rename to backend/OpenFlare/plugins/server/infra/task/scheduler/scheduler.go index 5c8e17c1..49bb3fbe 100644 --- a/internal/infra/task/scheduler/scheduler.go +++ b/backend/OpenFlare/plugins/server/infra/task/scheduler/scheduler.go @@ -11,11 +11,11 @@ import ( "syscall" "time" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" - "github.com/Rain-kl/Wavelet/internal/infra/task" - "github.com/Rain-kl/Wavelet/internal/platform/bootstrap" - "github.com/Rain-kl/Wavelet/pkg/logger" + "Wavelet/OpenFlare/plugins/server/infra/task" + "Wavelet/OpenFlare/plugins/server/platform/bootstrap" + "Wavelet/pkg/logger" "github.com/hibiken/asynq" ) diff --git a/internal/infra/task/scheduler/scheduler_test.go b/backend/OpenFlare/plugins/server/infra/task/scheduler/scheduler_test.go similarity index 100% rename from internal/infra/task/scheduler/scheduler_test.go rename to backend/OpenFlare/plugins/server/infra/task/scheduler/scheduler_test.go diff --git a/internal/infra/task/utils.go b/backend/OpenFlare/plugins/server/infra/task/utils.go similarity index 98% rename from internal/infra/task/utils.go rename to backend/OpenFlare/plugins/server/infra/task/utils.go index 5c409aae..5bc2e8ba 100644 --- a/internal/infra/task/utils.go +++ b/backend/OpenFlare/plugins/server/infra/task/utils.go @@ -5,7 +5,8 @@ package task import ( - "github.com/Rain-kl/Wavelet/internal/infra/config" + "Wavelet/OpenFlare/plugins/server/infra/config" + "github.com/hibiken/asynq" "github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9/maintnotifications" diff --git a/internal/infra/task/utils_test.go b/backend/OpenFlare/plugins/server/infra/task/utils_test.go similarity index 97% rename from internal/infra/task/utils_test.go rename to backend/OpenFlare/plugins/server/infra/task/utils_test.go index b16ab71d..b4c69566 100644 --- a/internal/infra/task/utils_test.go +++ b/backend/OpenFlare/plugins/server/infra/task/utils_test.go @@ -6,7 +6,8 @@ package task import ( "testing" - "github.com/Rain-kl/Wavelet/internal/infra/config" + "Wavelet/OpenFlare/plugins/server/infra/config" + "github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9/maintnotifications" ) diff --git a/internal/infra/task/worker/middlewares.go b/backend/OpenFlare/plugins/server/infra/task/worker/middlewares.go similarity index 100% rename from internal/infra/task/worker/middlewares.go rename to backend/OpenFlare/plugins/server/infra/task/worker/middlewares.go diff --git a/internal/infra/task/worker/worker.go b/backend/OpenFlare/plugins/server/infra/task/worker/worker.go similarity index 90% rename from internal/infra/task/worker/worker.go rename to backend/OpenFlare/plugins/server/infra/task/worker/worker.go index c53858db..7b2688d2 100644 --- a/internal/infra/task/worker/worker.go +++ b/backend/OpenFlare/plugins/server/infra/task/worker/worker.go @@ -7,9 +7,10 @@ package worker import ( "time" - "github.com/Rain-kl/Wavelet/internal/infra/config" - "github.com/Rain-kl/Wavelet/internal/infra/task" - "github.com/Rain-kl/Wavelet/internal/platform/bootstrap" + "Wavelet/OpenFlare/plugins/server/infra/config" + "Wavelet/OpenFlare/plugins/server/infra/task" + "Wavelet/OpenFlare/plugins/server/platform/bootstrap" + "github.com/hibiken/asynq" ) diff --git a/internal/integration/githubrelease/client.go b/backend/OpenFlare/plugins/server/integration/githubrelease/client.go similarity index 100% rename from internal/integration/githubrelease/client.go rename to backend/OpenFlare/plugins/server/integration/githubrelease/client.go diff --git a/internal/integration/githubrelease/client_test.go b/backend/OpenFlare/plugins/server/integration/githubrelease/client_test.go similarity index 100% rename from internal/integration/githubrelease/client_test.go rename to backend/OpenFlare/plugins/server/integration/githubrelease/client_test.go diff --git a/internal/integration/githubrelease/transport.go b/backend/OpenFlare/plugins/server/integration/githubrelease/transport.go similarity index 99% rename from internal/integration/githubrelease/transport.go rename to backend/OpenFlare/plugins/server/integration/githubrelease/transport.go index 7ea75e4e..e95607df 100644 --- a/internal/integration/githubrelease/transport.go +++ b/backend/OpenFlare/plugins/server/integration/githubrelease/transport.go @@ -17,7 +17,7 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/pkg/httppool" + "Wavelet/pkg/httppool" ) const ( diff --git a/internal/listener/admin_login.go b/backend/OpenFlare/plugins/server/listener/admin_login.go similarity index 96% rename from internal/listener/admin_login.go rename to backend/OpenFlare/plugins/server/listener/admin_login.go index 454e0eb7..03dc7d5d 100644 --- a/internal/listener/admin_login.go +++ b/backend/OpenFlare/plugins/server/listener/admin_login.go @@ -9,7 +9,7 @@ package listener import ( "context" - "github.com/Rain-kl/Wavelet/internal/model" + "Wavelet/OpenFlare/plugins/server/model" ) // AdminLoggedIn is emitted when an administrator successfully authenticates. diff --git a/internal/model/access_token.go b/backend/OpenFlare/plugins/server/model/access_token.go similarity index 100% rename from internal/model/access_token.go rename to backend/OpenFlare/plugins/server/model/access_token.go diff --git a/internal/model/analytics/filter.go b/backend/OpenFlare/plugins/server/model/analytics/filter.go similarity index 100% rename from internal/model/analytics/filter.go rename to backend/OpenFlare/plugins/server/model/analytics/filter.go diff --git a/internal/model/analytics/node_access_log.go b/backend/OpenFlare/plugins/server/model/analytics/node_access_log.go similarity index 100% rename from internal/model/analytics/node_access_log.go rename to backend/OpenFlare/plugins/server/model/analytics/node_access_log.go diff --git a/internal/model/analytics/node_access_log_stats.go b/backend/OpenFlare/plugins/server/model/analytics/node_access_log_stats.go similarity index 100% rename from internal/model/analytics/node_access_log_stats.go rename to backend/OpenFlare/plugins/server/model/analytics/node_access_log_stats.go diff --git a/internal/model/analytics/node_observability.go b/backend/OpenFlare/plugins/server/model/analytics/node_observability.go similarity index 100% rename from internal/model/analytics/node_observability.go rename to backend/OpenFlare/plugins/server/model/analytics/node_observability.go diff --git a/internal/model/analytics/user_access_log.go b/backend/OpenFlare/plugins/server/model/analytics/user_access_log.go similarity index 100% rename from internal/model/analytics/user_access_log.go rename to backend/OpenFlare/plugins/server/model/analytics/user_access_log.go diff --git a/internal/model/analytics/user_agent.go b/backend/OpenFlare/plugins/server/model/analytics/user_agent.go similarity index 100% rename from internal/model/analytics/user_agent.go rename to backend/OpenFlare/plugins/server/model/analytics/user_agent.go diff --git a/internal/model/auth_source.go b/backend/OpenFlare/plugins/server/model/auth_source.go similarity index 100% rename from internal/model/auth_source.go rename to backend/OpenFlare/plugins/server/model/auth_source.go diff --git a/internal/model/errs.go b/backend/OpenFlare/plugins/server/model/errs.go similarity index 100% rename from internal/model/errs.go rename to backend/OpenFlare/plugins/server/model/errs.go diff --git a/internal/model/openflare_access_log.go b/backend/OpenFlare/plugins/server/model/openflare_access_log.go similarity index 100% rename from internal/model/openflare_access_log.go rename to backend/OpenFlare/plugins/server/model/openflare_access_log.go diff --git a/internal/model/openflare_acme_account.go b/backend/OpenFlare/plugins/server/model/openflare_acme_account.go similarity index 100% rename from internal/model/openflare_acme_account.go rename to backend/OpenFlare/plugins/server/model/openflare_acme_account.go diff --git a/internal/model/openflare_apply_log.go b/backend/OpenFlare/plugins/server/model/openflare_apply_log.go similarity index 100% rename from internal/model/openflare_apply_log.go rename to backend/OpenFlare/plugins/server/model/openflare_apply_log.go diff --git a/internal/model/openflare_cloudflare.go b/backend/OpenFlare/plugins/server/model/openflare_cloudflare.go similarity index 100% rename from internal/model/openflare_cloudflare.go rename to backend/OpenFlare/plugins/server/model/openflare_cloudflare.go diff --git a/internal/model/openflare_config_version.go b/backend/OpenFlare/plugins/server/model/openflare_config_version.go similarity index 100% rename from internal/model/openflare_config_version.go rename to backend/OpenFlare/plugins/server/model/openflare_config_version.go diff --git a/internal/model/openflare_dns_account.go b/backend/OpenFlare/plugins/server/model/openflare_dns_account.go similarity index 100% rename from internal/model/openflare_dns_account.go rename to backend/OpenFlare/plugins/server/model/openflare_dns_account.go diff --git a/internal/model/openflare_node.go b/backend/OpenFlare/plugins/server/model/openflare_node.go similarity index 100% rename from internal/model/openflare_node.go rename to backend/OpenFlare/plugins/server/model/openflare_node.go diff --git a/internal/model/openflare_observability.go b/backend/OpenFlare/plugins/server/model/openflare_observability.go similarity index 100% rename from internal/model/openflare_observability.go rename to backend/OpenFlare/plugins/server/model/openflare_observability.go diff --git a/internal/model/openflare_option.go b/backend/OpenFlare/plugins/server/model/openflare_option.go similarity index 100% rename from internal/model/openflare_option.go rename to backend/OpenFlare/plugins/server/model/openflare_option.go diff --git a/internal/model/openflare_origin.go b/backend/OpenFlare/plugins/server/model/openflare_origin.go similarity index 100% rename from internal/model/openflare_origin.go rename to backend/OpenFlare/plugins/server/model/openflare_origin.go diff --git a/internal/model/openflare_pages.go b/backend/OpenFlare/plugins/server/model/openflare_pages.go similarity index 100% rename from internal/model/openflare_pages.go rename to backend/OpenFlare/plugins/server/model/openflare_pages.go diff --git a/internal/model/openflare_pages_cleanup.go b/backend/OpenFlare/plugins/server/model/openflare_pages_cleanup.go similarity index 100% rename from internal/model/openflare_pages_cleanup.go rename to backend/OpenFlare/plugins/server/model/openflare_pages_cleanup.go diff --git a/internal/model/openflare_pages_source.go b/backend/OpenFlare/plugins/server/model/openflare_pages_source.go similarity index 100% rename from internal/model/openflare_pages_source.go rename to backend/OpenFlare/plugins/server/model/openflare_pages_source.go diff --git a/internal/model/openflare_pages_source_test.go b/backend/OpenFlare/plugins/server/model/openflare_pages_source_test.go similarity index 100% rename from internal/model/openflare_pages_source_test.go rename to backend/OpenFlare/plugins/server/model/openflare_pages_source_test.go diff --git a/internal/model/openflare_proxy_route.go b/backend/OpenFlare/plugins/server/model/openflare_proxy_route.go similarity index 100% rename from internal/model/openflare_proxy_route.go rename to backend/OpenFlare/plugins/server/model/openflare_proxy_route.go diff --git a/internal/model/openflare_tls.go b/backend/OpenFlare/plugins/server/model/openflare_tls.go similarity index 100% rename from internal/model/openflare_tls.go rename to backend/OpenFlare/plugins/server/model/openflare_tls.go diff --git a/internal/model/openflare_waf.go b/backend/OpenFlare/plugins/server/model/openflare_waf.go similarity index 100% rename from internal/model/openflare_waf.go rename to backend/OpenFlare/plugins/server/model/openflare_waf.go diff --git a/internal/model/openflare_zone.go b/backend/OpenFlare/plugins/server/model/openflare_zone.go similarity index 100% rename from internal/model/openflare_zone.go rename to backend/OpenFlare/plugins/server/model/openflare_zone.go diff --git a/internal/model/push_channel.go b/backend/OpenFlare/plugins/server/model/push_channel.go similarity index 100% rename from internal/model/push_channel.go rename to backend/OpenFlare/plugins/server/model/push_channel.go diff --git a/internal/model/push_event.go b/backend/OpenFlare/plugins/server/model/push_event.go similarity index 100% rename from internal/model/push_event.go rename to backend/OpenFlare/plugins/server/model/push_event.go diff --git a/internal/model/push_history.go b/backend/OpenFlare/plugins/server/model/push_history.go similarity index 100% rename from internal/model/push_history.go rename to backend/OpenFlare/plugins/server/model/push_history.go diff --git a/internal/model/schedule.go b/backend/OpenFlare/plugins/server/model/schedule.go similarity index 100% rename from internal/model/schedule.go rename to backend/OpenFlare/plugins/server/model/schedule.go diff --git a/internal/model/system_configs.go b/backend/OpenFlare/plugins/server/model/system_configs.go similarity index 100% rename from internal/model/system_configs.go rename to backend/OpenFlare/plugins/server/model/system_configs.go diff --git a/internal/model/task_execution.go b/backend/OpenFlare/plugins/server/model/task_execution.go similarity index 100% rename from internal/model/task_execution.go rename to backend/OpenFlare/plugins/server/model/task_execution.go diff --git a/internal/model/templates.go b/backend/OpenFlare/plugins/server/model/templates.go similarity index 100% rename from internal/model/templates.go rename to backend/OpenFlare/plugins/server/model/templates.go diff --git a/internal/model/upload_stats.go b/backend/OpenFlare/plugins/server/model/upload_stats.go similarity index 100% rename from internal/model/upload_stats.go rename to backend/OpenFlare/plugins/server/model/upload_stats.go diff --git a/internal/model/uploads.go b/backend/OpenFlare/plugins/server/model/uploads.go similarity index 100% rename from internal/model/uploads.go rename to backend/OpenFlare/plugins/server/model/uploads.go diff --git a/internal/model/users.go b/backend/OpenFlare/plugins/server/model/users.go similarity index 97% rename from internal/model/users.go rename to backend/OpenFlare/plugins/server/model/users.go index 9c61bf2e..ae4fdaca 100644 --- a/internal/model/users.go +++ b/backend/OpenFlare/plugins/server/model/users.go @@ -10,8 +10,8 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/shared" - "github.com/Rain-kl/Wavelet/pkg/util" + "Wavelet/OpenFlare/plugins/server/shared" + "Wavelet/pkg/util" ) // OAuthUserInfo 用户信息结构(同时支持 OIDC ID Token claims 和 UserEndpoint 响应) diff --git a/internal/apps/oauth/audit.go b/backend/OpenFlare/plugins/server/oauth/audit.go similarity index 91% rename from internal/apps/oauth/audit.go rename to backend/OpenFlare/plugins/server/oauth/audit.go index 5f5fcede..1ace8aa3 100644 --- a/internal/apps/oauth/audit.go +++ b/backend/OpenFlare/plugins/server/oauth/audit.go @@ -9,8 +9,9 @@ import ( "context" "encoding/json" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/pkg/logger" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/pkg/logger" + "github.com/gin-gonic/gin" ) diff --git a/internal/apps/oauth/auth_source_resolver.go b/backend/OpenFlare/plugins/server/oauth/auth_source_resolver.go similarity index 97% rename from internal/apps/oauth/auth_source_resolver.go rename to backend/OpenFlare/plugins/server/oauth/auth_source_resolver.go index 306ad5c2..4b528516 100644 --- a/internal/apps/oauth/auth_source_resolver.go +++ b/backend/OpenFlare/plugins/server/oauth/auth_source_resolver.go @@ -9,8 +9,9 @@ import ( "slices" "strings" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "github.com/coreos/go-oidc/v3/oidc" "golang.org/x/oauth2" ) diff --git a/internal/apps/oauth/cache.go b/backend/OpenFlare/plugins/server/oauth/cache.go similarity index 97% rename from internal/apps/oauth/cache.go rename to backend/OpenFlare/plugins/server/oauth/cache.go index b8d7d348..b1a235e2 100644 --- a/internal/apps/oauth/cache.go +++ b/backend/OpenFlare/plugins/server/oauth/cache.go @@ -11,10 +11,10 @@ import ( "sync" "time" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/pkg/cache/ram" - "github.com/Rain-kl/Wavelet/pkg/util" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/pkg/cache/ram" + "Wavelet/pkg/util" ) const ( diff --git a/internal/apps/oauth/cache_test.go b/backend/OpenFlare/plugins/server/oauth/cache_test.go similarity index 98% rename from internal/apps/oauth/cache_test.go rename to backend/OpenFlare/plugins/server/oauth/cache_test.go index cddcc3fe..6c6a8ac7 100644 --- a/internal/apps/oauth/cache_test.go +++ b/backend/OpenFlare/plugins/server/oauth/cache_test.go @@ -12,8 +12,8 @@ import ( "github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9/maintnotifications" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" ) func setupOauthCacheTest(t *testing.T) (*miniredis.Miniredis, func()) { diff --git a/internal/apps/oauth/constants.go b/backend/OpenFlare/plugins/server/oauth/constants.go similarity index 100% rename from internal/apps/oauth/constants.go rename to backend/OpenFlare/plugins/server/oauth/constants.go diff --git a/internal/apps/oauth/errs.go b/backend/OpenFlare/plugins/server/oauth/errs.go similarity index 100% rename from internal/apps/oauth/errs.go rename to backend/OpenFlare/plugins/server/oauth/errs.go diff --git a/internal/apps/oauth/gin_context.go b/backend/OpenFlare/plugins/server/oauth/gin_context.go similarity index 100% rename from internal/apps/oauth/gin_context.go rename to backend/OpenFlare/plugins/server/oauth/gin_context.go diff --git a/internal/apps/oauth/handler_authorize.go b/backend/OpenFlare/plugins/server/oauth/handler_authorize.go similarity index 96% rename from internal/apps/oauth/handler_authorize.go rename to backend/OpenFlare/plugins/server/oauth/handler_authorize.go index 067abc1f..d761bccb 100644 --- a/internal/apps/oauth/handler_authorize.go +++ b/backend/OpenFlare/plugins/server/oauth/handler_authorize.go @@ -10,10 +10,11 @@ import ( "net/http" "strings" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/shared" - "github.com/Rain-kl/Wavelet/internal/shared/response" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/shared" + "Wavelet/pkg/response" + "github.com/coreos/go-oidc/v3/oidc" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" diff --git a/internal/apps/oauth/handler_callback.go b/backend/OpenFlare/plugins/server/oauth/handler_callback.go similarity index 95% rename from internal/apps/oauth/handler_callback.go rename to backend/OpenFlare/plugins/server/oauth/handler_callback.go index adb8b22a..87be6550 100644 --- a/internal/apps/oauth/handler_callback.go +++ b/backend/OpenFlare/plugins/server/oauth/handler_callback.go @@ -10,13 +10,14 @@ import ( "net/http" "time" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/listener" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/shared" - "github.com/Rain-kl/Wavelet/internal/shared/response" - "github.com/Rain-kl/Wavelet/pkg/logger" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/listener" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/shared" + "Wavelet/pkg/logger" + "Wavelet/pkg/response" + "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" "gorm.io/gorm" diff --git a/internal/apps/oauth/handler_external_accounts.go b/backend/OpenFlare/plugins/server/oauth/handler_external_accounts.go similarity index 93% rename from internal/apps/oauth/handler_external_accounts.go rename to backend/OpenFlare/plugins/server/oauth/handler_external_accounts.go index aa922c1d..406d240d 100644 --- a/internal/apps/oauth/handler_external_accounts.go +++ b/backend/OpenFlare/plugins/server/oauth/handler_external_accounts.go @@ -8,10 +8,11 @@ import ( "strconv" "strings" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" + + "Wavelet/OpenFlare/plugins/server/shared" + "Wavelet/pkg/response" - "github.com/Rain-kl/Wavelet/internal/shared" - "github.com/Rain-kl/Wavelet/internal/shared/response" "github.com/gin-gonic/gin" ) diff --git a/internal/apps/oauth/handler_sources.go b/backend/OpenFlare/plugins/server/oauth/handler_sources.go similarity index 91% rename from internal/apps/oauth/handler_sources.go rename to backend/OpenFlare/plugins/server/oauth/handler_sources.go index 2cefa790..15b10a20 100644 --- a/internal/apps/oauth/handler_sources.go +++ b/backend/OpenFlare/plugins/server/oauth/handler_sources.go @@ -6,7 +6,8 @@ package oauth import ( "net/http" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/pkg/response" + "github.com/gin-gonic/gin" ) diff --git a/internal/apps/oauth/middlewares.go b/backend/OpenFlare/plugins/server/oauth/middlewares.go similarity index 94% rename from internal/apps/oauth/middlewares.go rename to backend/OpenFlare/plugins/server/oauth/middlewares.go index 6a20fa8c..74db58ef 100644 --- a/internal/apps/oauth/middlewares.go +++ b/backend/OpenFlare/plugins/server/oauth/middlewares.go @@ -8,12 +8,13 @@ import ( "context" "errors" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/shared" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/shared" + "Wavelet/pkg/response" + + otel_trace "Wavelet/pkg/trace" - otel_trace "github.com/Rain-kl/Wavelet/pkg/trace" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" ) diff --git a/internal/apps/oauth/oauth_test.go b/backend/OpenFlare/plugins/server/oauth/oauth_test.go similarity index 99% rename from internal/apps/oauth/oauth_test.go rename to backend/OpenFlare/plugins/server/oauth/oauth_test.go index d36183ff..6fc7a2be 100644 --- a/internal/apps/oauth/oauth_test.go +++ b/backend/OpenFlare/plugins/server/oauth/oauth_test.go @@ -32,11 +32,11 @@ import ( "gorm.io/driver/sqlite" "gorm.io/gorm" - "github.com/Rain-kl/Wavelet/internal/infra/config" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/testhelper" + "Wavelet/OpenFlare/plugins/server/infra/config" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/testhelper" ) // ----------------------------------------------------------------------------- diff --git a/internal/apps/oauth/oauth_types.go b/backend/OpenFlare/plugins/server/oauth/oauth_types.go similarity index 100% rename from internal/apps/oauth/oauth_types.go rename to backend/OpenFlare/plugins/server/oauth/oauth_types.go diff --git a/internal/apps/oauth/oauth_userinfo.go b/backend/OpenFlare/plugins/server/oauth/oauth_userinfo.go similarity index 97% rename from internal/apps/oauth/oauth_userinfo.go rename to backend/OpenFlare/plugins/server/oauth/oauth_userinfo.go index c8b9c657..b9eaae56 100644 --- a/internal/apps/oauth/oauth_userinfo.go +++ b/backend/OpenFlare/plugins/server/oauth/oauth_userinfo.go @@ -9,8 +9,9 @@ import ( "fmt" "strings" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "github.com/coreos/go-oidc/v3/oidc" "golang.org/x/oauth2" ) diff --git a/internal/apps/oauth/provider_cache.go b/backend/OpenFlare/plugins/server/oauth/provider_cache.go similarity index 100% rename from internal/apps/oauth/provider_cache.go rename to backend/OpenFlare/plugins/server/oauth/provider_cache.go diff --git a/internal/apps/oauth/routers.go b/backend/OpenFlare/plugins/server/oauth/routers.go similarity index 96% rename from internal/apps/oauth/routers.go rename to backend/OpenFlare/plugins/server/oauth/routers.go index a5a44799..d98daeea 100644 --- a/internal/apps/oauth/routers.go +++ b/backend/OpenFlare/plugins/server/oauth/routers.go @@ -7,12 +7,13 @@ package oauth import ( "net/http" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/pkg/logger" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/pkg/logger" + "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/pkg/response" ) // BasicUserInfo 用户基本信息结构体 diff --git a/internal/apps/oauth/session.go b/backend/OpenFlare/plugins/server/oauth/session.go similarity index 96% rename from internal/apps/oauth/session.go rename to backend/OpenFlare/plugins/server/oauth/session.go index 0aeb1486..c57eccc2 100644 --- a/internal/apps/oauth/session.go +++ b/backend/OpenFlare/plugins/server/oauth/session.go @@ -8,7 +8,8 @@ import ( "net/http" "strings" - "github.com/Rain-kl/Wavelet/internal/infra/config" + "Wavelet/OpenFlare/plugins/server/infra/config" + "github.com/gin-contrib/sessions" ) diff --git a/internal/apps/oauth/session_context.go b/backend/OpenFlare/plugins/server/oauth/session_context.go similarity index 93% rename from internal/apps/oauth/session_context.go rename to backend/OpenFlare/plugins/server/oauth/session_context.go index 83076b17..df0bce44 100644 --- a/internal/apps/oauth/session_context.go +++ b/backend/OpenFlare/plugins/server/oauth/session_context.go @@ -8,9 +8,10 @@ import ( "crypto/sha256" "encoding/hex" - "github.com/Rain-kl/Wavelet/internal/infra/config" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/infra/config" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" "github.com/google/uuid" diff --git a/internal/apps/openflare/agent/access_log_region.go b/backend/OpenFlare/plugins/server/openflare/agent/access_log_region.go similarity index 98% rename from internal/apps/openflare/agent/access_log_region.go rename to backend/OpenFlare/plugins/server/openflare/agent/access_log_region.go index d3b183be..27a0bb7d 100644 --- a/internal/apps/openflare/agent/access_log_region.go +++ b/backend/OpenFlare/plugins/server/openflare/agent/access_log_region.go @@ -12,7 +12,7 @@ import ( "strings" "sync" - pkggeoip "github.com/Rain-kl/Wavelet/pkg/geoip" + pkggeoip "Wavelet/OpenFlare/share/geoip" ) // 共享一个 GeoIP 服务实例:mmdb 打开(mmap + 解析元数据)成本不低,缺文件时还会 diff --git a/internal/apps/openflare/agent/auth_cache.go b/backend/OpenFlare/plugins/server/openflare/agent/auth_cache.go similarity index 97% rename from internal/apps/openflare/agent/auth_cache.go rename to backend/OpenFlare/plugins/server/openflare/agent/auth_cache.go index a52061ae..26abad62 100644 --- a/internal/apps/openflare/agent/auth_cache.go +++ b/backend/OpenFlare/plugins/server/openflare/agent/auth_cache.go @@ -10,9 +10,10 @@ import ( "sync" "time" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" + + "Wavelet/OpenFlare/plugins/server/model" - "github.com/Rain-kl/Wavelet/internal/model" "gorm.io/gorm" ) diff --git a/internal/apps/openflare/agent/config.go b/backend/OpenFlare/plugins/server/openflare/agent/config.go similarity index 92% rename from internal/apps/openflare/agent/config.go rename to backend/OpenFlare/plugins/server/openflare/agent/config.go index 1955fb6c..fdd7e8df 100644 --- a/internal/apps/openflare/agent/config.go +++ b/backend/OpenFlare/plugins/server/openflare/agent/config.go @@ -9,10 +9,11 @@ import ( "errors" "strings" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" + + "Wavelet/OpenFlare/plugins/server/openflare/pages" + openrestyrender "Wavelet/OpenFlare/share/render/openresty" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/pages" - openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty" "gorm.io/gorm" ) diff --git a/internal/apps/openflare/agent/config_test.go b/backend/OpenFlare/plugins/server/openflare/agent/config_test.go similarity index 95% rename from internal/apps/openflare/agent/config_test.go rename to backend/OpenFlare/plugins/server/openflare/agent/config_test.go index 8da277ce..3a11d935 100644 --- a/internal/apps/openflare/agent/config_test.go +++ b/backend/OpenFlare/plugins/server/openflare/agent/config_test.go @@ -6,7 +6,7 @@ package agent import ( "testing" - openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty" + openrestyrender "Wavelet/OpenFlare/share/render/openresty" ) func TestIsRuntimeGeneratedSupportFile(t *testing.T) { diff --git a/internal/apps/openflare/agent/errs.go b/backend/OpenFlare/plugins/server/openflare/agent/errs.go similarity index 100% rename from internal/apps/openflare/agent/errs.go rename to backend/OpenFlare/plugins/server/openflare/agent/errs.go diff --git a/internal/apps/openflare/agent/helpers.go b/backend/OpenFlare/plugins/server/openflare/agent/helpers.go similarity index 98% rename from internal/apps/openflare/agent/helpers.go rename to backend/OpenFlare/plugins/server/openflare/agent/helpers.go index 187f382c..509684e6 100644 --- a/internal/apps/openflare/agent/helpers.go +++ b/backend/OpenFlare/plugins/server/openflare/agent/helpers.go @@ -11,9 +11,9 @@ import ( "strings" "time" - ofgeoip "github.com/Rain-kl/Wavelet/internal/apps/openflare/geoip" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/model" + ofgeoip "Wavelet/OpenFlare/plugins/server/openflare/geoip" + "Wavelet/OpenFlare/plugins/server/repository" ) const ( diff --git a/internal/apps/openflare/agent/helpers_test.go b/backend/OpenFlare/plugins/server/openflare/agent/helpers_test.go similarity index 95% rename from internal/apps/openflare/agent/helpers_test.go rename to backend/OpenFlare/plugins/server/openflare/agent/helpers_test.go index 28006533..6430f0cd 100644 --- a/internal/apps/openflare/agent/helpers_test.go +++ b/backend/OpenFlare/plugins/server/openflare/agent/helpers_test.go @@ -8,9 +8,9 @@ import ( "net" "testing" - ofgeoip "github.com/Rain-kl/Wavelet/internal/apps/openflare/geoip" - "github.com/Rain-kl/Wavelet/internal/model" - pkggeoip "github.com/Rain-kl/Wavelet/pkg/geoip" + "Wavelet/OpenFlare/plugins/server/model" + ofgeoip "Wavelet/OpenFlare/plugins/server/openflare/geoip" + pkggeoip "Wavelet/OpenFlare/share/geoip" ) type fakeGeoIPProvider struct { diff --git a/internal/apps/openflare/agent/logics.go b/backend/OpenFlare/plugins/server/openflare/agent/logics.go similarity index 95% rename from internal/apps/openflare/agent/logics.go rename to backend/OpenFlare/plugins/server/openflare/agent/logics.go index 7967963d..8c62b8a7 100644 --- a/internal/apps/openflare/agent/logics.go +++ b/backend/OpenFlare/plugins/server/openflare/agent/logics.go @@ -9,13 +9,13 @@ import ( "strings" "time" - cf "github.com/Rain-kl/Wavelet/internal/apps/openflare/cloudflare" - "github.com/Rain-kl/Wavelet/internal/repository" + cf "Wavelet/OpenFlare/plugins/server/openflare/cloudflare" + "Wavelet/OpenFlare/plugins/server/repository" - ofgeoip "github.com/Rain-kl/Wavelet/internal/apps/openflare/geoip" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/node" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/pkg/logger" + "Wavelet/OpenFlare/plugins/server/model" + ofgeoip "Wavelet/OpenFlare/plugins/server/openflare/geoip" + "Wavelet/OpenFlare/plugins/server/openflare/node" + "Wavelet/pkg/logger" ) // RegisterWithAccessToken registers an agent on a reserved node token. diff --git a/internal/apps/openflare/agent/middleware.go b/backend/OpenFlare/plugins/server/openflare/agent/middleware.go similarity index 93% rename from internal/apps/openflare/agent/middleware.go rename to backend/OpenFlare/plugins/server/openflare/agent/middleware.go index 9a076fec..23a0d786 100644 --- a/internal/apps/openflare/agent/middleware.go +++ b/backend/OpenFlare/plugins/server/openflare/agent/middleware.go @@ -6,8 +6,9 @@ package agent import ( "strings" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/pkg/response" + "github.com/gin-gonic/gin" ) diff --git a/internal/apps/openflare/agent/middleware_test.go b/backend/OpenFlare/plugins/server/openflare/agent/middleware_test.go similarity index 95% rename from internal/apps/openflare/agent/middleware_test.go rename to backend/OpenFlare/plugins/server/openflare/agent/middleware_test.go index eb29a4da..6e80254c 100644 --- a/internal/apps/openflare/agent/middleware_test.go +++ b/backend/OpenFlare/plugins/server/openflare/agent/middleware_test.go @@ -11,11 +11,12 @@ import ( "testing" "time" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/shared/response" - "github.com/Rain-kl/Wavelet/internal/testhelper" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/testhelper" + "Wavelet/pkg/response" + "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" "github.com/stretchr/testify/assert" diff --git a/internal/apps/openflare/agent/observability.go b/backend/OpenFlare/plugins/server/openflare/agent/observability.go similarity index 98% rename from internal/apps/openflare/agent/observability.go rename to backend/OpenFlare/plugins/server/openflare/agent/observability.go index 8677188f..02c0b6fe 100644 --- a/internal/apps/openflare/agent/observability.go +++ b/backend/OpenFlare/plugins/server/openflare/agent/observability.go @@ -9,9 +9,10 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" + + "Wavelet/OpenFlare/plugins/server/model" - "github.com/Rain-kl/Wavelet/internal/model" "go.uber.org/zap" ) diff --git a/internal/apps/openflare/agent/observability_test.go b/backend/OpenFlare/plugins/server/openflare/agent/observability_test.go similarity index 100% rename from internal/apps/openflare/agent/observability_test.go rename to backend/OpenFlare/plugins/server/openflare/agent/observability_test.go diff --git a/internal/apps/openflare/agent/protocol_alias.go b/backend/OpenFlare/plugins/server/openflare/agent/protocol_alias.go similarity index 97% rename from internal/apps/openflare/agent/protocol_alias.go rename to backend/OpenFlare/plugins/server/openflare/agent/protocol_alias.go index 7c10acb8..884725ae 100644 --- a/internal/apps/openflare/agent/protocol_alias.go +++ b/backend/OpenFlare/plugins/server/openflare/agent/protocol_alias.go @@ -3,7 +3,7 @@ package agent -import pkgprotocol "github.com/Rain-kl/Wavelet/pkg/protocol" +import pkgprotocol "Wavelet/OpenFlare/share/protocol" // NodePayload is the data sent by an agent on registration or heartbeat. type NodePayload = pkgprotocol.NodePayload diff --git a/internal/apps/openflare/agent/routers.go b/backend/OpenFlare/plugins/server/openflare/agent/routers.go similarity index 97% rename from internal/apps/openflare/agent/routers.go rename to backend/OpenFlare/plugins/server/openflare/agent/routers.go index 7ea38574..52408a42 100644 --- a/internal/apps/openflare/agent/routers.go +++ b/backend/OpenFlare/plugins/server/openflare/agent/routers.go @@ -7,11 +7,12 @@ import ( "net/http" "strconv" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/pages" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/websocket" - "github.com/Rain-kl/Wavelet/internal/shared/response" - "github.com/Rain-kl/Wavelet/pkg/protocol" + "Wavelet/OpenFlare/plugins/server/openflare/apiutil" + "Wavelet/OpenFlare/plugins/server/openflare/pages" + "Wavelet/OpenFlare/plugins/server/openflare/websocket" + "Wavelet/OpenFlare/share/protocol" + "Wavelet/pkg/response" + "github.com/gin-gonic/gin" ) diff --git a/internal/apps/openflare/agent/types.go b/backend/OpenFlare/plugins/server/openflare/agent/types.go similarity index 96% rename from internal/apps/openflare/agent/types.go rename to backend/OpenFlare/plugins/server/openflare/agent/types.go index 52c76203..3a9b88e9 100644 --- a/internal/apps/openflare/agent/types.go +++ b/backend/OpenFlare/plugins/server/openflare/agent/types.go @@ -6,7 +6,7 @@ package agent import ( "time" - "github.com/Rain-kl/Wavelet/internal/model" + "Wavelet/OpenFlare/plugins/server/model" ) const ( diff --git a/internal/apps/openflare/agent/waf_ip_group.go b/backend/OpenFlare/plugins/server/openflare/agent/waf_ip_group.go similarity index 96% rename from internal/apps/openflare/agent/waf_ip_group.go rename to backend/OpenFlare/plugins/server/openflare/agent/waf_ip_group.go index a13886a7..a479d53a 100644 --- a/internal/apps/openflare/agent/waf_ip_group.go +++ b/backend/OpenFlare/plugins/server/openflare/agent/waf_ip_group.go @@ -16,11 +16,11 @@ import ( "strconv" "strings" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/pkg/protocol" - openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/share/protocol" + openrestyrender "Wavelet/OpenFlare/share/render/openresty" ) type activeConfigSnapshot struct { diff --git a/internal/apps/openflare/agent/waf_ip_group_test.go b/backend/OpenFlare/plugins/server/openflare/agent/waf_ip_group_test.go similarity index 97% rename from internal/apps/openflare/agent/waf_ip_group_test.go rename to backend/OpenFlare/plugins/server/openflare/agent/waf_ip_group_test.go index 327cabe3..6ad45fc9 100644 --- a/internal/apps/openflare/agent/waf_ip_group_test.go +++ b/backend/OpenFlare/plugins/server/openflare/agent/waf_ip_group_test.go @@ -10,11 +10,12 @@ import ( "strings" "testing" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" + + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/share/protocol" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/pkg/protocol" "github.com/glebarez/sqlite" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/internal/apps/openflare/agent/ws_status.go b/backend/OpenFlare/plugins/server/openflare/agent/ws_status.go similarity index 93% rename from internal/apps/openflare/agent/ws_status.go rename to backend/OpenFlare/plugins/server/openflare/agent/ws_status.go index 81478fc5..826767e7 100644 --- a/internal/apps/openflare/agent/ws_status.go +++ b/backend/OpenFlare/plugins/server/openflare/agent/ws_status.go @@ -8,9 +8,9 @@ import ( "encoding/json" "log/slog" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" - ofws "github.com/Rain-kl/Wavelet/internal/apps/openflare/websocket" + ofws "Wavelet/OpenFlare/plugins/server/openflare/websocket" ) // HandleWSStatus processes an agent websocket status payload (replaces HTTP heartbeat in WS mode). diff --git a/internal/apps/openflare/apiutil/bind.go b/backend/OpenFlare/plugins/server/openflare/apiutil/bind.go similarity index 95% rename from internal/apps/openflare/apiutil/bind.go rename to backend/OpenFlare/plugins/server/openflare/apiutil/bind.go index 52da29ee..78d1e640 100644 --- a/internal/apps/openflare/apiutil/bind.go +++ b/backend/OpenFlare/plugins/server/openflare/apiutil/bind.go @@ -7,7 +7,8 @@ package apiutil import ( "strconv" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/pkg/response" + "github.com/gin-gonic/gin" ) diff --git a/internal/apps/openflare/apiutil/errors.go b/backend/OpenFlare/plugins/server/openflare/apiutil/errors.go similarity index 92% rename from internal/apps/openflare/apiutil/errors.go rename to backend/OpenFlare/plugins/server/openflare/apiutil/errors.go index 3a1dc0ee..67b88515 100644 --- a/internal/apps/openflare/apiutil/errors.go +++ b/backend/OpenFlare/plugins/server/openflare/apiutil/errors.go @@ -6,7 +6,8 @@ package apiutil import ( "errors" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/pkg/response" + "github.com/gin-gonic/gin" "gorm.io/gorm" ) diff --git a/internal/apps/openflare/apiutil/middleware.go b/backend/OpenFlare/plugins/server/openflare/apiutil/middleware.go similarity index 53% rename from internal/apps/openflare/apiutil/middleware.go rename to backend/OpenFlare/plugins/server/openflare/apiutil/middleware.go index 1d3c351a..f05acdbd 100644 --- a/internal/apps/openflare/apiutil/middleware.go +++ b/backend/OpenFlare/plugins/server/openflare/apiutil/middleware.go @@ -4,14 +4,16 @@ package apiutil import ( - "github.com/Rain-kl/Wavelet/internal/apps/admin" - "github.com/Rain-kl/Wavelet/internal/apps/oauth" - "github.com/gin-gonic/gin" + "Wavelet/OpenFlare/plugins/server/admin" + "Wavelet/OpenFlare/plugins/server/oauth" ) // AdminMiddlewares returns Wavelet-standard middlewares for OpenFlare console routes. // OpenFlare no longer distinguishes Admin vs Root tiers; all management endpoints share // the same gate: user.IsAdmin for session users, token_admin for Access Token callers. -func AdminMiddlewares() []gin.HandlerFunc { - return []gin.HandlerFunc{oauth.LoginRequired(), admin.LoginAdminRequired()} +// +// 返回 []any 而非 []gin.HandlerFunc:内核 RouterExtension.Use 收 ...any, +// 而 Go 不允许把 []T 直接展开成 ...any。 +func AdminMiddlewares() []any { + return []any{oauth.LoginRequired(), admin.LoginAdminRequired()} } diff --git a/internal/apps/openflare/apiutil/middleware_test.go b/backend/OpenFlare/plugins/server/openflare/apiutil/middleware_test.go similarity index 80% rename from internal/apps/openflare/apiutil/middleware_test.go rename to backend/OpenFlare/plugins/server/openflare/apiutil/middleware_test.go index 44133f91..dea11f63 100644 --- a/internal/apps/openflare/apiutil/middleware_test.go +++ b/backend/OpenFlare/plugins/server/openflare/apiutil/middleware_test.go @@ -9,14 +9,15 @@ import ( "net/http/httptest" "testing" - "github.com/Rain-kl/Wavelet/internal/apps/admin" - "github.com/Rain-kl/Wavelet/internal/apps/oauth" - "github.com/Rain-kl/Wavelet/internal/infra/config" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/infra/persistence/idgen" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/shared/response" - "github.com/Rain-kl/Wavelet/internal/testhelper" + "Wavelet/OpenFlare/plugins/server/admin" + "Wavelet/OpenFlare/plugins/server/infra/config" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/infra/persistence/idgen" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/oauth" + "Wavelet/OpenFlare/plugins/server/testhelper" + "Wavelet/pkg/response" + "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/cookie" "github.com/gin-gonic/gin" @@ -43,7 +44,8 @@ func setupAdminMiddlewareTest(t *testing.T) (*gin.Engine, *gorm.DB, func()) { store := cookie.NewStore([]byte("test_admin_middleware_session_secret")) store.Options(oauth.GetSessionOptions(3600)) engine := testhelper.NewTestGinEngine(sessions.Sessions(sessionCookieName, store)) - protected := engine.Group("/protected", AdminMiddlewares()...) + mws := ginHandlerMiddlewares(AdminMiddlewares()...) + protected := engine.Group("/protected", mws...) protected.GET("", func(c *gin.Context) { c.JSON(http.StatusOK, response.OK(gin.H{"ok": true})) }) @@ -156,3 +158,20 @@ func TestAdminRequiredAdminWithTokenAdmin(t *testing.T) { resp := decodeResponse(t, rec) assert.Empty(t, resp.ErrorMsg) } + +// ginHandlerMiddlewares 把内核形态的 []any 中间件还原为 gin 切片, +// 便于在纯 gin 测试引擎上验证 AdminMiddlewares 的门禁行为。 +func ginHandlerMiddlewares(middlewares ...any) []gin.HandlerFunc { + handlers := make([]gin.HandlerFunc, 0, len(middlewares)) + for _, m := range middlewares { + switch h := m.(type) { + case gin.HandlerFunc: + handlers = append(handlers, h) + case func(*gin.Context): + handlers = append(handlers, gin.HandlerFunc(h)) + default: + panic("unexpected middleware type") + } + } + return handlers +} diff --git a/backend/OpenFlare/plugins/server/openflare/apiutil/routes.go b/backend/OpenFlare/plugins/server/openflare/apiutil/routes.go new file mode 100644 index 00000000..eff75c78 --- /dev/null +++ b/backend/OpenFlare/plugins/server/openflare/apiutil/routes.go @@ -0,0 +1,26 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package apiutil + +import ( + "Wavelet/core" + + "github.com/gin-gonic/gin" +) + +// RegisterCollection registers a collection endpoint on both "" and "/" so requests +// work with or without a trailing slash. +// +// 尾部斜杠变体必须用 HandleRaw 注册:RouterExtension.Handle 会经 cleanPath 归一化 +// 掉尾部斜杠,而部署关闭了 gin 的 RedirectTrailingSlash,缺一条即 404。 +func RegisterCollection(route core.RouterExtension, method string, handlers ...gin.HandlerFunc) { + hs := make([]any, len(handlers)) + for i, h := range handlers { + hs[i] = h + } + route.Handle(method, "", hs...) + if route.BasePath() != "" { + route.HandleRaw(method, "/", hs...) + } +} diff --git a/internal/apps/openflare/apply_log/errs.go b/backend/OpenFlare/plugins/server/openflare/apply_log/errs.go similarity index 100% rename from internal/apps/openflare/apply_log/errs.go rename to backend/OpenFlare/plugins/server/openflare/apply_log/errs.go diff --git a/internal/apps/openflare/apply_log/logics.go b/backend/OpenFlare/plugins/server/openflare/apply_log/logics.go similarity index 96% rename from internal/apps/openflare/apply_log/logics.go rename to backend/OpenFlare/plugins/server/openflare/apply_log/logics.go index a97507e0..ad244f2b 100644 --- a/internal/apps/openflare/apply_log/logics.go +++ b/backend/OpenFlare/plugins/server/openflare/apply_log/logics.go @@ -9,9 +9,9 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" - "github.com/Rain-kl/Wavelet/internal/model" + "Wavelet/OpenFlare/plugins/server/model" ) const ( diff --git a/internal/apps/openflare/apply_log/logics_test.go b/backend/OpenFlare/plugins/server/openflare/apply_log/logics_test.go similarity index 94% rename from internal/apps/openflare/apply_log/logics_test.go rename to backend/OpenFlare/plugins/server/openflare/apply_log/logics_test.go index 06b38722..31691065 100644 --- a/internal/apps/openflare/apply_log/logics_test.go +++ b/backend/OpenFlare/plugins/server/openflare/apply_log/logics_test.go @@ -8,10 +8,11 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" + + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" "github.com/glebarez/sqlite" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/internal/apps/openflare/apply_log/routers.go b/backend/OpenFlare/plugins/server/openflare/apply_log/routers.go similarity index 95% rename from internal/apps/openflare/apply_log/routers.go rename to backend/OpenFlare/plugins/server/openflare/apply_log/routers.go index 68a36068..f5afd99e 100644 --- a/internal/apps/openflare/apply_log/routers.go +++ b/backend/OpenFlare/plugins/server/openflare/apply_log/routers.go @@ -7,8 +7,9 @@ import ( "net/http" "strconv" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/OpenFlare/plugins/server/openflare/apiutil" + "Wavelet/pkg/response" + "github.com/gin-gonic/gin" ) diff --git a/internal/apps/openflare/async_tasks.go b/backend/OpenFlare/plugins/server/openflare/async_tasks.go similarity index 94% rename from internal/apps/openflare/async_tasks.go rename to backend/OpenFlare/plugins/server/openflare/async_tasks.go index 7a4944d3..73b734f6 100644 --- a/internal/apps/openflare/async_tasks.go +++ b/backend/OpenFlare/plugins/server/openflare/async_tasks.go @@ -10,12 +10,12 @@ import ( "sync" "time" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/tasks" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/uptimekuma" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/waf" - "github.com/Rain-kl/Wavelet/internal/infra/task" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/infra/task" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/openflare/tasks" + "Wavelet/OpenFlare/plugins/server/openflare/uptimekuma" + "Wavelet/OpenFlare/plugins/server/openflare/waf" + "Wavelet/OpenFlare/plugins/server/repository" ) const ( diff --git a/internal/apps/openflare/async_tasks_test.go b/backend/OpenFlare/plugins/server/openflare/async_tasks_test.go similarity index 84% rename from internal/apps/openflare/async_tasks_test.go rename to backend/OpenFlare/plugins/server/openflare/async_tasks_test.go index dc99212f..d89b8fa7 100644 --- a/internal/apps/openflare/async_tasks_test.go +++ b/backend/OpenFlare/plugins/server/openflare/async_tasks_test.go @@ -7,9 +7,10 @@ import ( "context" "testing" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "github.com/glebarez/sqlite" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/internal/apps/openflare/chwriter/dedup.go b/backend/OpenFlare/plugins/server/openflare/chwriter/dedup.go similarity index 100% rename from internal/apps/openflare/chwriter/dedup.go rename to backend/OpenFlare/plugins/server/openflare/chwriter/dedup.go diff --git a/internal/apps/openflare/chwriter/dedup_test.go b/backend/OpenFlare/plugins/server/openflare/chwriter/dedup_test.go similarity index 96% rename from internal/apps/openflare/chwriter/dedup_test.go rename to backend/OpenFlare/plugins/server/openflare/chwriter/dedup_test.go index 8b817e78..bedcaa0f 100644 --- a/internal/apps/openflare/chwriter/dedup_test.go +++ b/backend/OpenFlare/plugins/server/openflare/chwriter/dedup_test.go @@ -10,8 +10,8 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/infra/persistence/batchwriter" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" + analyticsmodel "Wavelet/OpenFlare/plugins/server/model/analytics" + "Wavelet/pkg/batchwriter" ) func TestDedupSetMarkIfNew(t *testing.T) { diff --git a/internal/apps/openflare/chwriter/live_ch_test.go b/backend/OpenFlare/plugins/server/openflare/chwriter/live_ch_test.go similarity index 90% rename from internal/apps/openflare/chwriter/live_ch_test.go rename to backend/OpenFlare/plugins/server/openflare/chwriter/live_ch_test.go index e418abb2..c7ba45a7 100644 --- a/internal/apps/openflare/chwriter/live_ch_test.go +++ b/backend/OpenFlare/plugins/server/openflare/chwriter/live_ch_test.go @@ -10,11 +10,11 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/chwriter" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/openflare/chwriter" ) // Run with Docker ClickHouse + config.yaml: diff --git a/internal/apps/openflare/chwriter/writer.go b/backend/OpenFlare/plugins/server/openflare/chwriter/writer.go similarity index 97% rename from internal/apps/openflare/chwriter/writer.go rename to backend/OpenFlare/plugins/server/openflare/chwriter/writer.go index 620318c8..32ca321e 100644 --- a/internal/apps/openflare/chwriter/writer.go +++ b/backend/OpenFlare/plugins/server/openflare/chwriter/writer.go @@ -11,11 +11,11 @@ import ( "sync" "time" - "github.com/Rain-kl/Wavelet/internal/infra/persistence/batchwriter" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" - "github.com/Rain-kl/Wavelet/internal/platform/lifecycle" - "github.com/Rain-kl/Wavelet/internal/repository/logstore" - "github.com/Rain-kl/Wavelet/pkg/logger" + analyticsmodel "Wavelet/OpenFlare/plugins/server/model/analytics" + "Wavelet/OpenFlare/plugins/server/platform/lifecycle" + "Wavelet/OpenFlare/plugins/server/repository/logstore" + "Wavelet/pkg/batchwriter" + "Wavelet/pkg/logger" ) const ( diff --git a/internal/apps/openflare/cloudflare/client.go b/backend/OpenFlare/plugins/server/openflare/cloudflare/client.go similarity index 99% rename from internal/apps/openflare/cloudflare/client.go rename to backend/OpenFlare/plugins/server/openflare/cloudflare/client.go index 0d141cc1..d3665ae0 100644 --- a/internal/apps/openflare/cloudflare/client.go +++ b/backend/OpenFlare/plugins/server/openflare/cloudflare/client.go @@ -17,7 +17,7 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/pkg/httppool" + "Wavelet/pkg/httppool" ) const ( diff --git a/internal/apps/openflare/cloudflare/client_test.go b/backend/OpenFlare/plugins/server/openflare/cloudflare/client_test.go similarity index 100% rename from internal/apps/openflare/cloudflare/client_test.go rename to backend/OpenFlare/plugins/server/openflare/cloudflare/client_test.go diff --git a/internal/apps/openflare/cloudflare/errs.go b/backend/OpenFlare/plugins/server/openflare/cloudflare/errs.go similarity index 100% rename from internal/apps/openflare/cloudflare/errs.go rename to backend/OpenFlare/plugins/server/openflare/cloudflare/errs.go diff --git a/internal/apps/openflare/cloudflare/logics.go b/backend/OpenFlare/plugins/server/openflare/cloudflare/logics.go similarity index 98% rename from internal/apps/openflare/cloudflare/logics.go rename to backend/OpenFlare/plugins/server/openflare/cloudflare/logics.go index 8d14935a..bee6dee3 100644 --- a/internal/apps/openflare/cloudflare/logics.go +++ b/backend/OpenFlare/plugins/server/openflare/cloudflare/logics.go @@ -11,10 +11,11 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/credential" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/pkg/logger" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/openflare/credential" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/pkg/logger" + "gorm.io/gorm" ) diff --git a/internal/apps/openflare/cloudflare/reconcile.go b/backend/OpenFlare/plugins/server/openflare/cloudflare/reconcile.go similarity index 96% rename from internal/apps/openflare/cloudflare/reconcile.go rename to backend/OpenFlare/plugins/server/openflare/cloudflare/reconcile.go index 5f0a331e..86142b35 100644 --- a/internal/apps/openflare/cloudflare/reconcile.go +++ b/backend/OpenFlare/plugins/server/openflare/cloudflare/reconcile.go @@ -11,9 +11,10 @@ import ( "sync" "time" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/pkg/logger" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/pkg/logger" + "gorm.io/gorm" ) diff --git a/internal/apps/openflare/cloudflare/reconcile_test.go b/backend/OpenFlare/plugins/server/openflare/cloudflare/reconcile_test.go similarity index 97% rename from internal/apps/openflare/cloudflare/reconcile_test.go rename to backend/OpenFlare/plugins/server/openflare/cloudflare/reconcile_test.go index 50325bd6..e16e28cd 100644 --- a/internal/apps/openflare/cloudflare/reconcile_test.go +++ b/backend/OpenFlare/plugins/server/openflare/cloudflare/reconcile_test.go @@ -8,10 +8,11 @@ import ( "errors" "testing" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/credential" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/openflare/credential" + "Wavelet/OpenFlare/plugins/server/repository" + "github.com/glebarez/sqlite" "gorm.io/gorm" ) diff --git a/internal/apps/openflare/cloudflare/routers.go b/backend/OpenFlare/plugins/server/openflare/cloudflare/routers.go similarity index 98% rename from internal/apps/openflare/cloudflare/routers.go rename to backend/OpenFlare/plugins/server/openflare/cloudflare/routers.go index 08c59c57..b02c52c9 100644 --- a/internal/apps/openflare/cloudflare/routers.go +++ b/backend/OpenFlare/plugins/server/openflare/cloudflare/routers.go @@ -7,8 +7,9 @@ import ( "errors" "net/http" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/OpenFlare/plugins/server/openflare/apiutil" + "Wavelet/pkg/response" + "github.com/gin-gonic/gin" "gorm.io/gorm" ) diff --git a/internal/apps/openflare/cloudflare/routers_test.go b/backend/OpenFlare/plugins/server/openflare/cloudflare/routers_test.go similarity index 93% rename from internal/apps/openflare/cloudflare/routers_test.go rename to backend/OpenFlare/plugins/server/openflare/cloudflare/routers_test.go index d945de53..68c92585 100644 --- a/internal/apps/openflare/cloudflare/routers_test.go +++ b/backend/OpenFlare/plugins/server/openflare/cloudflare/routers_test.go @@ -9,9 +9,10 @@ import ( "strings" "testing" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/shared/response" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/pkg/response" + "github.com/gin-gonic/gin" ) diff --git a/internal/apps/openflare/cloudflare/tasks.go b/backend/OpenFlare/plugins/server/openflare/cloudflare/tasks.go similarity index 98% rename from internal/apps/openflare/cloudflare/tasks.go rename to backend/OpenFlare/plugins/server/openflare/cloudflare/tasks.go index 828b79d4..0c03c222 100644 --- a/internal/apps/openflare/cloudflare/tasks.go +++ b/backend/OpenFlare/plugins/server/openflare/cloudflare/tasks.go @@ -12,9 +12,10 @@ import ( "io" "strings" - "github.com/Rain-kl/Wavelet/internal/infra/task" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/infra/task" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "gorm.io/gorm" ) diff --git a/internal/apps/openflare/cloudflare/tasks_test.go b/backend/OpenFlare/plugins/server/openflare/cloudflare/tasks_test.go similarity index 100% rename from internal/apps/openflare/cloudflare/tasks_test.go rename to backend/OpenFlare/plugins/server/openflare/cloudflare/tasks_test.go diff --git a/internal/apps/openflare/cloudflare/types.go b/backend/OpenFlare/plugins/server/openflare/cloudflare/types.go similarity index 100% rename from internal/apps/openflare/cloudflare/types.go rename to backend/OpenFlare/plugins/server/openflare/cloudflare/types.go diff --git a/internal/apps/openflare/config_version/certificate_snapshot_test.go b/backend/OpenFlare/plugins/server/openflare/config_version/certificate_snapshot_test.go similarity index 94% rename from internal/apps/openflare/config_version/certificate_snapshot_test.go rename to backend/OpenFlare/plugins/server/openflare/config_version/certificate_snapshot_test.go index ea270101..4180b107 100644 --- a/internal/apps/openflare/config_version/certificate_snapshot_test.go +++ b/backend/OpenFlare/plugins/server/openflare/config_version/certificate_snapshot_test.go @@ -15,12 +15,13 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" + + "Wavelet/OpenFlare/plugins/server/infra/config" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + oftls "Wavelet/OpenFlare/plugins/server/openflare/tls" - oftls "github.com/Rain-kl/Wavelet/internal/apps/openflare/tls" - "github.com/Rain-kl/Wavelet/internal/infra/config" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/internal/apps/openflare/config_version/errs.go b/backend/OpenFlare/plugins/server/openflare/config_version/errs.go similarity index 100% rename from internal/apps/openflare/config_version/errs.go rename to backend/OpenFlare/plugins/server/openflare/config_version/errs.go diff --git a/internal/apps/openflare/config_version/helpers.go b/backend/OpenFlare/plugins/server/openflare/config_version/helpers.go similarity index 98% rename from internal/apps/openflare/config_version/helpers.go rename to backend/OpenFlare/plugins/server/openflare/config_version/helpers.go index 1c776471..2b277d55 100644 --- a/internal/apps/openflare/config_version/helpers.go +++ b/backend/OpenFlare/plugins/server/openflare/config_version/helpers.go @@ -12,9 +12,9 @@ import ( "strconv" "strings" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" - "github.com/Rain-kl/Wavelet/internal/model" + "Wavelet/OpenFlare/plugins/server/model" ) type customHeaderInput struct { diff --git a/internal/apps/openflare/config_version/logics.go b/backend/OpenFlare/plugins/server/openflare/config_version/logics.go similarity index 98% rename from internal/apps/openflare/config_version/logics.go rename to backend/OpenFlare/plugins/server/openflare/config_version/logics.go index e7828b14..9c6ea5c2 100644 --- a/internal/apps/openflare/config_version/logics.go +++ b/backend/OpenFlare/plugins/server/openflare/config_version/logics.go @@ -14,12 +14,13 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" + + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/openflare/websocket" + pkgprotocol "Wavelet/OpenFlare/share/protocol" + openrestyrender "Wavelet/OpenFlare/share/render/openresty" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/websocket" - "github.com/Rain-kl/Wavelet/internal/model" - pkgprotocol "github.com/Rain-kl/Wavelet/pkg/protocol" - openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty" "gorm.io/gorm" ) diff --git a/internal/apps/openflare/config_version/logics_test.go b/backend/OpenFlare/plugins/server/openflare/config_version/logics_test.go similarity index 96% rename from internal/apps/openflare/config_version/logics_test.go rename to backend/OpenFlare/plugins/server/openflare/config_version/logics_test.go index ebff6042..f1204b56 100644 --- a/internal/apps/openflare/config_version/logics_test.go +++ b/backend/OpenFlare/plugins/server/openflare/config_version/logics_test.go @@ -10,13 +10,14 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" + + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/openflare/waf" + openrestyrender "Wavelet/OpenFlare/share/render/openresty" + "Wavelet/pkg/cache/ram" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/waf" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/pkg/cache/ram" - openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty" "github.com/glebarez/sqlite" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/internal/apps/openflare/config_version/origin_error_page_snapshot_test.go b/backend/OpenFlare/plugins/server/openflare/config_version/origin_error_page_snapshot_test.go similarity index 96% rename from internal/apps/openflare/config_version/origin_error_page_snapshot_test.go rename to backend/OpenFlare/plugins/server/openflare/config_version/origin_error_page_snapshot_test.go index 94f9a26e..c31deb28 100644 --- a/internal/apps/openflare/config_version/origin_error_page_snapshot_test.go +++ b/backend/OpenFlare/plugins/server/openflare/config_version/origin_error_page_snapshot_test.go @@ -8,9 +8,10 @@ import ( "encoding/json" "testing" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/pkg/cache/ram" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/pkg/cache/ram" + "github.com/glebarez/sqlite" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/internal/apps/openflare/config_version/pages_snapshot.go b/backend/OpenFlare/plugins/server/openflare/config_version/pages_snapshot.go similarity index 95% rename from internal/apps/openflare/config_version/pages_snapshot.go rename to backend/OpenFlare/plugins/server/openflare/config_version/pages_snapshot.go index 2f270193..587f7957 100644 --- a/internal/apps/openflare/config_version/pages_snapshot.go +++ b/backend/OpenFlare/plugins/server/openflare/config_version/pages_snapshot.go @@ -10,11 +10,12 @@ import ( "path" "strings" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" + + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/share/pagesarchive" + openrestyrender "Wavelet/OpenFlare/share/render/openresty" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/pkg/pagesarchive" - openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty" "gorm.io/gorm" ) diff --git a/internal/apps/openflare/config_version/pages_snapshot_test.go b/backend/OpenFlare/plugins/server/openflare/config_version/pages_snapshot_test.go similarity index 93% rename from internal/apps/openflare/config_version/pages_snapshot_test.go rename to backend/OpenFlare/plugins/server/openflare/config_version/pages_snapshot_test.go index 7d3fa496..a737fd1e 100644 --- a/internal/apps/openflare/config_version/pages_snapshot_test.go +++ b/backend/OpenFlare/plugins/server/openflare/config_version/pages_snapshot_test.go @@ -8,11 +8,12 @@ import ( "encoding/json" "testing" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" + + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + openrestyrender "Wavelet/OpenFlare/share/render/openresty" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/gorm" diff --git a/internal/apps/openflare/config_version/proxy_cache_path_test.go b/backend/OpenFlare/plugins/server/openflare/config_version/proxy_cache_path_test.go similarity index 90% rename from internal/apps/openflare/config_version/proxy_cache_path_test.go rename to backend/OpenFlare/plugins/server/openflare/config_version/proxy_cache_path_test.go index 4d3cbdc4..a4de73c0 100644 --- a/internal/apps/openflare/config_version/proxy_cache_path_test.go +++ b/backend/OpenFlare/plugins/server/openflare/config_version/proxy_cache_path_test.go @@ -6,7 +6,8 @@ package config_version import ( "testing" - openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty" + openrestyrender "Wavelet/OpenFlare/share/render/openresty" + "github.com/stretchr/testify/assert" ) diff --git a/internal/apps/openflare/config_version/renderer.go b/backend/OpenFlare/plugins/server/openflare/config_version/renderer.go similarity index 95% rename from internal/apps/openflare/config_version/renderer.go rename to backend/OpenFlare/plugins/server/openflare/config_version/renderer.go index e3112ea7..6ba97691 100644 --- a/internal/apps/openflare/config_version/renderer.go +++ b/backend/OpenFlare/plugins/server/openflare/config_version/renderer.go @@ -4,7 +4,7 @@ package config_version import ( - openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty" + openrestyrender "Wavelet/OpenFlare/share/render/openresty" ) // SupportFile is a rendered configuration support artifact. diff --git a/internal/apps/openflare/config_version/routers.go b/backend/OpenFlare/plugins/server/openflare/config_version/routers.go similarity index 98% rename from internal/apps/openflare/config_version/routers.go rename to backend/OpenFlare/plugins/server/openflare/config_version/routers.go index 786fbc7d..53f3cf61 100644 --- a/internal/apps/openflare/config_version/routers.go +++ b/backend/OpenFlare/plugins/server/openflare/config_version/routers.go @@ -6,8 +6,9 @@ package config_version import ( "net/http" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/OpenFlare/plugins/server/openflare/apiutil" + "Wavelet/pkg/response" + "github.com/gin-gonic/gin" ) diff --git a/internal/apps/openflare/config_version/snapshot.go b/backend/OpenFlare/plugins/server/openflare/config_version/snapshot.go similarity index 98% rename from internal/apps/openflare/config_version/snapshot.go rename to backend/OpenFlare/plugins/server/openflare/config_version/snapshot.go index 093ad477..9774db90 100644 --- a/internal/apps/openflare/config_version/snapshot.go +++ b/backend/OpenFlare/plugins/server/openflare/config_version/snapshot.go @@ -13,12 +13,13 @@ import ( "strconv" "strings" - oftls "github.com/Rain-kl/Wavelet/internal/apps/openflare/tls" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/waf" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/pkg/protocol" - openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty" + "Wavelet/OpenFlare/plugins/server/model" + oftls "Wavelet/OpenFlare/plugins/server/openflare/tls" + "Wavelet/OpenFlare/plugins/server/openflare/waf" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/share/protocol" + openrestyrender "Wavelet/OpenFlare/share/render/openresty" + "gorm.io/gorm" ) diff --git a/internal/apps/openflare/config_version/waf_graph_snapshot_test.go b/backend/OpenFlare/plugins/server/openflare/config_version/waf_graph_snapshot_test.go similarity index 96% rename from internal/apps/openflare/config_version/waf_graph_snapshot_test.go rename to backend/OpenFlare/plugins/server/openflare/config_version/waf_graph_snapshot_test.go index c97e6b23..1dcff16f 100644 --- a/internal/apps/openflare/config_version/waf_graph_snapshot_test.go +++ b/backend/OpenFlare/plugins/server/openflare/config_version/waf_graph_snapshot_test.go @@ -9,11 +9,12 @@ import ( "strings" "testing" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" + + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/openflare/waf" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/waf" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/internal/apps/openflare/credential/sensitive.go b/backend/OpenFlare/plugins/server/openflare/credential/sensitive.go similarity index 94% rename from internal/apps/openflare/credential/sensitive.go rename to backend/OpenFlare/plugins/server/openflare/credential/sensitive.go index e7917123..a4380e14 100644 --- a/internal/apps/openflare/credential/sensitive.go +++ b/backend/OpenFlare/plugins/server/openflare/credential/sensitive.go @@ -10,8 +10,8 @@ import ( "errors" "strings" - "github.com/Rain-kl/Wavelet/internal/infra/config" - "github.com/Rain-kl/Wavelet/pkg/util" + "Wavelet/OpenFlare/plugins/server/infra/config" + "Wavelet/pkg/util" ) // Prefix identifies values encrypted with the current credential format. diff --git a/internal/apps/openflare/credential/sensitive_test.go b/backend/OpenFlare/plugins/server/openflare/credential/sensitive_test.go similarity index 97% rename from internal/apps/openflare/credential/sensitive_test.go rename to backend/OpenFlare/plugins/server/openflare/credential/sensitive_test.go index 3a38a55f..96f1a902 100644 --- a/internal/apps/openflare/credential/sensitive_test.go +++ b/backend/OpenFlare/plugins/server/openflare/credential/sensitive_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/Rain-kl/Wavelet/internal/infra/config" + "Wavelet/OpenFlare/plugins/server/infra/config" ) func TestSealAndOpenSensitiveValue(t *testing.T) { diff --git a/internal/apps/openflare/dashboard/cache.go b/backend/OpenFlare/plugins/server/openflare/dashboard/cache.go similarity index 100% rename from internal/apps/openflare/dashboard/cache.go rename to backend/OpenFlare/plugins/server/openflare/dashboard/cache.go diff --git a/internal/apps/openflare/dashboard/helpers.go b/backend/OpenFlare/plugins/server/openflare/dashboard/helpers.go similarity index 92% rename from internal/apps/openflare/dashboard/helpers.go rename to backend/OpenFlare/plugins/server/openflare/dashboard/helpers.go index a3d524ff..2f9bf19b 100644 --- a/internal/apps/openflare/dashboard/helpers.go +++ b/backend/OpenFlare/plugins/server/openflare/dashboard/helpers.go @@ -8,8 +8,8 @@ import ( "strings" "time" - ofws "github.com/Rain-kl/Wavelet/internal/apps/openflare/websocket" - "github.com/Rain-kl/Wavelet/internal/model" + "Wavelet/OpenFlare/plugins/server/model" + ofws "Wavelet/OpenFlare/plugins/server/openflare/websocket" ) const ( diff --git a/internal/apps/openflare/dashboard/logics.go b/backend/OpenFlare/plugins/server/openflare/dashboard/logics.go similarity index 98% rename from internal/apps/openflare/dashboard/logics.go rename to backend/OpenFlare/plugins/server/openflare/dashboard/logics.go index 2afae163..e98b4ad0 100644 --- a/internal/apps/openflare/dashboard/logics.go +++ b/backend/OpenFlare/plugins/server/openflare/dashboard/logics.go @@ -8,10 +8,10 @@ import ( "sort" "time" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/observability" - "github.com/Rain-kl/Wavelet/internal/model" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/openflare/observability" ) // Summary is the dashboard node summary section. diff --git a/internal/apps/openflare/dashboard/logics_test.go b/backend/OpenFlare/plugins/server/openflare/dashboard/logics_test.go similarity index 96% rename from internal/apps/openflare/dashboard/logics_test.go rename to backend/OpenFlare/plugins/server/openflare/dashboard/logics_test.go index 970405e1..35b70c1a 100644 --- a/internal/apps/openflare/dashboard/logics_test.go +++ b/backend/OpenFlare/plugins/server/openflare/dashboard/logics_test.go @@ -8,11 +8,12 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/testhelper" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/testhelper" + + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" "github.com/glebarez/sqlite" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/internal/apps/openflare/dashboard/routers.go b/backend/OpenFlare/plugins/server/openflare/dashboard/routers.go similarity index 89% rename from internal/apps/openflare/dashboard/routers.go rename to backend/OpenFlare/plugins/server/openflare/dashboard/routers.go index 99191c3c..e00debe0 100644 --- a/internal/apps/openflare/dashboard/routers.go +++ b/backend/OpenFlare/plugins/server/openflare/dashboard/routers.go @@ -6,8 +6,9 @@ package dashboard import ( "net/http" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/OpenFlare/plugins/server/openflare/apiutil" + "Wavelet/pkg/response" + "github.com/gin-gonic/gin" ) diff --git a/internal/apps/openflare/flared/errs.go b/backend/OpenFlare/plugins/server/openflare/flared/errs.go similarity index 100% rename from internal/apps/openflare/flared/errs.go rename to backend/OpenFlare/plugins/server/openflare/flared/errs.go diff --git a/internal/apps/openflare/flared/helpers.go b/backend/OpenFlare/plugins/server/openflare/flared/helpers.go similarity index 95% rename from internal/apps/openflare/flared/helpers.go rename to backend/OpenFlare/plugins/server/openflare/flared/helpers.go index 3843b211..ecdfb19c 100644 --- a/internal/apps/openflare/flared/helpers.go +++ b/backend/OpenFlare/plugins/server/openflare/flared/helpers.go @@ -10,10 +10,10 @@ import ( "strconv" "strings" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/relay" - "github.com/Rain-kl/Wavelet/internal/model" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/openflare/relay" ) const ( diff --git a/internal/apps/openflare/flared/logics.go b/backend/OpenFlare/plugins/server/openflare/flared/logics.go similarity index 97% rename from internal/apps/openflare/flared/logics.go rename to backend/OpenFlare/plugins/server/openflare/flared/logics.go index 3e7c3a87..e193e925 100644 --- a/internal/apps/openflare/flared/logics.go +++ b/backend/OpenFlare/plugins/server/openflare/flared/logics.go @@ -10,10 +10,11 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" + + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/openflare/agent" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/agent" - "github.com/Rain-kl/Wavelet/internal/model" "gorm.io/gorm" ) diff --git a/internal/apps/openflare/flared/middleware.go b/backend/OpenFlare/plugins/server/openflare/flared/middleware.go similarity index 86% rename from internal/apps/openflare/flared/middleware.go rename to backend/OpenFlare/plugins/server/openflare/flared/middleware.go index 6c0c6c96..2e95f3d5 100644 --- a/internal/apps/openflare/flared/middleware.go +++ b/backend/OpenFlare/plugins/server/openflare/flared/middleware.go @@ -6,9 +6,10 @@ package flared import ( "strings" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/agent" + "Wavelet/OpenFlare/plugins/server/openflare/agent" + + "Wavelet/pkg/response" - "github.com/Rain-kl/Wavelet/internal/shared/response" "github.com/gin-gonic/gin" ) diff --git a/internal/apps/openflare/flared/middleware_test.go b/backend/OpenFlare/plugins/server/openflare/flared/middleware_test.go similarity index 93% rename from internal/apps/openflare/flared/middleware_test.go rename to backend/OpenFlare/plugins/server/openflare/flared/middleware_test.go index 4baeb020..8e98feaf 100644 --- a/internal/apps/openflare/flared/middleware_test.go +++ b/backend/OpenFlare/plugins/server/openflare/flared/middleware_test.go @@ -9,11 +9,12 @@ import ( "net/http/httptest" "testing" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" + + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/pkg/response" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/shared/response" "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" "github.com/stretchr/testify/assert" diff --git a/internal/apps/openflare/flared/observability.go b/backend/OpenFlare/plugins/server/openflare/flared/observability.go similarity index 95% rename from internal/apps/openflare/flared/observability.go rename to backend/OpenFlare/plugins/server/openflare/flared/observability.go index 0a5a28b6..b6b965c6 100644 --- a/internal/apps/openflare/flared/observability.go +++ b/backend/OpenFlare/plugins/server/openflare/flared/observability.go @@ -9,7 +9,8 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/agent" + "Wavelet/OpenFlare/plugins/server/openflare/agent" + "go.uber.org/zap" ) diff --git a/internal/apps/openflare/flared/observability_test.go b/backend/OpenFlare/plugins/server/openflare/flared/observability_test.go similarity index 88% rename from internal/apps/openflare/flared/observability_test.go rename to backend/OpenFlare/plugins/server/openflare/flared/observability_test.go index 4bc6e7de..8ae8d1b5 100644 --- a/internal/apps/openflare/flared/observability_test.go +++ b/backend/OpenFlare/plugins/server/openflare/flared/observability_test.go @@ -7,11 +7,12 @@ import ( "context" "testing" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" + + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/openflare/agent" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/agent" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" "github.com/glebarez/sqlite" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/internal/apps/openflare/flared/protocol_alias.go b/backend/OpenFlare/plugins/server/openflare/flared/protocol_alias.go similarity index 94% rename from internal/apps/openflare/flared/protocol_alias.go rename to backend/OpenFlare/plugins/server/openflare/flared/protocol_alias.go index 4d3a3c49..91bf201c 100644 --- a/internal/apps/openflare/flared/protocol_alias.go +++ b/backend/OpenFlare/plugins/server/openflare/flared/protocol_alias.go @@ -3,7 +3,7 @@ package flared -import pkgprotocol "github.com/Rain-kl/Wavelet/pkg/protocol" +import pkgprotocol "Wavelet/OpenFlare/share/protocol" // HeartbeatPayload is an alias for FlaredHeartbeatPayload. type HeartbeatPayload = pkgprotocol.FlaredHeartbeatPayload diff --git a/internal/apps/openflare/flared/routers.go b/backend/OpenFlare/plugins/server/openflare/flared/routers.go similarity index 94% rename from internal/apps/openflare/flared/routers.go rename to backend/OpenFlare/plugins/server/openflare/flared/routers.go index 05f460ac..e6df41b3 100644 --- a/internal/apps/openflare/flared/routers.go +++ b/backend/OpenFlare/plugins/server/openflare/flared/routers.go @@ -6,10 +6,11 @@ package flared import ( "net/http" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil" - ofws "github.com/Rain-kl/Wavelet/internal/apps/openflare/websocket" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/openflare/apiutil" + ofws "Wavelet/OpenFlare/plugins/server/openflare/websocket" + "Wavelet/pkg/response" + "github.com/gin-gonic/gin" ) diff --git a/internal/apps/openflare/geoip/data/GeoLite2-Country.mmdb b/backend/OpenFlare/plugins/server/openflare/geoip/data/GeoLite2-Country.mmdb similarity index 100% rename from internal/apps/openflare/geoip/data/GeoLite2-Country.mmdb rename to backend/OpenFlare/plugins/server/openflare/geoip/data/GeoLite2-Country.mmdb diff --git a/internal/apps/openflare/geoip/data/data.go b/backend/OpenFlare/plugins/server/openflare/geoip/data/data.go similarity index 100% rename from internal/apps/openflare/geoip/data/data.go rename to backend/OpenFlare/plugins/server/openflare/geoip/data/data.go diff --git a/internal/apps/openflare/geoip/data/data_test.go b/backend/OpenFlare/plugins/server/openflare/geoip/data/data_test.go similarity index 100% rename from internal/apps/openflare/geoip/data/data_test.go rename to backend/OpenFlare/plugins/server/openflare/geoip/data/data_test.go diff --git a/internal/apps/openflare/geoip/lookup.go b/backend/OpenFlare/plugins/server/openflare/geoip/lookup.go similarity index 97% rename from internal/apps/openflare/geoip/lookup.go rename to backend/OpenFlare/plugins/server/openflare/geoip/lookup.go index 3ce1f190..490f722e 100644 --- a/internal/apps/openflare/geoip/lookup.go +++ b/backend/OpenFlare/plugins/server/openflare/geoip/lookup.go @@ -9,7 +9,7 @@ import ( "net" "strings" - pkggeoip "github.com/Rain-kl/Wavelet/pkg/geoip" + pkggeoip "Wavelet/OpenFlare/share/geoip" ) // LookupView is the legacy OpenFlare GeoIP lookup response shape. diff --git a/internal/apps/openflare/geoip/lookup_test.go b/backend/OpenFlare/plugins/server/openflare/geoip/lookup_test.go similarity index 97% rename from internal/apps/openflare/geoip/lookup_test.go rename to backend/OpenFlare/plugins/server/openflare/geoip/lookup_test.go index 42cad359..486d9c08 100644 --- a/internal/apps/openflare/geoip/lookup_test.go +++ b/backend/OpenFlare/plugins/server/openflare/geoip/lookup_test.go @@ -7,7 +7,7 @@ import ( "net" "testing" - pkggeoip "github.com/Rain-kl/Wavelet/pkg/geoip" + pkggeoip "Wavelet/OpenFlare/share/geoip" ) type fakeLookupProvider struct{} diff --git a/internal/apps/openflare/geoip/node.go b/backend/OpenFlare/plugins/server/openflare/geoip/node.go similarity index 92% rename from internal/apps/openflare/geoip/node.go rename to backend/OpenFlare/plugins/server/openflare/geoip/node.go index 7f31cfbd..e72440eb 100644 --- a/internal/apps/openflare/geoip/node.go +++ b/backend/OpenFlare/plugins/server/openflare/geoip/node.go @@ -8,9 +8,9 @@ import ( "net" "strings" - "github.com/Rain-kl/Wavelet/internal/model" - pkggeoip "github.com/Rain-kl/Wavelet/pkg/geoip" - "github.com/Rain-kl/Wavelet/pkg/logger" + "Wavelet/OpenFlare/plugins/server/model" + pkggeoip "Wavelet/OpenFlare/share/geoip" + "Wavelet/pkg/logger" ) // ApplyNodeGeoFromIP resolves geographic metadata for node when geo is not manually locked. diff --git a/internal/apps/openflare/geoip/runtime.go b/backend/OpenFlare/plugins/server/openflare/geoip/runtime.go similarity index 91% rename from internal/apps/openflare/geoip/runtime.go rename to backend/OpenFlare/plugins/server/openflare/geoip/runtime.go index 42f019ea..3bfaf9c5 100644 --- a/internal/apps/openflare/geoip/runtime.go +++ b/backend/OpenFlare/plugins/server/openflare/geoip/runtime.go @@ -11,11 +11,11 @@ import ( "strings" "sync" - geodata "github.com/Rain-kl/Wavelet/internal/apps/openflare/geoip/data" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - pkggeoip "github.com/Rain-kl/Wavelet/pkg/geoip" - "github.com/Rain-kl/Wavelet/pkg/logger" + "Wavelet/OpenFlare/plugins/server/model" + geodata "Wavelet/OpenFlare/plugins/server/openflare/geoip/data" + "Wavelet/OpenFlare/plugins/server/repository" + pkggeoip "Wavelet/OpenFlare/share/geoip" + "Wavelet/pkg/logger" ) const ( diff --git a/internal/apps/openflare/geoip/runtime_test.go b/backend/OpenFlare/plugins/server/openflare/geoip/runtime_test.go similarity index 88% rename from internal/apps/openflare/geoip/runtime_test.go rename to backend/OpenFlare/plugins/server/openflare/geoip/runtime_test.go index 0b66f181..12801a60 100644 --- a/internal/apps/openflare/geoip/runtime_test.go +++ b/backend/OpenFlare/plugins/server/openflare/geoip/runtime_test.go @@ -7,9 +7,10 @@ import ( "context" "testing" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - pkggeoip "github.com/Rain-kl/Wavelet/pkg/geoip" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + pkggeoip "Wavelet/OpenFlare/share/geoip" + "github.com/glebarez/sqlite" "gorm.io/gorm" ) diff --git a/internal/apps/openflare/integration/agent_protocol_test.go b/backend/OpenFlare/plugins/server/openflare/integration/agent_protocol_test.go similarity index 94% rename from internal/apps/openflare/integration/agent_protocol_test.go rename to backend/OpenFlare/plugins/server/openflare/integration/agent_protocol_test.go index 7ca20501..c32dbb97 100644 --- a/internal/apps/openflare/integration/agent_protocol_test.go +++ b/backend/OpenFlare/plugins/server/openflare/integration/agent_protocol_test.go @@ -8,13 +8,14 @@ import ( "net/http" "testing" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" + + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/openflare/agent" + ofnode "Wavelet/OpenFlare/plugins/server/openflare/node" + "Wavelet/OpenFlare/plugins/server/testhelper" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/agent" - ofnode "github.com/Rain-kl/Wavelet/internal/apps/openflare/node" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/testhelper" "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" "github.com/stretchr/testify/assert" diff --git a/internal/apps/openflare/integration/auth_option_test.go b/backend/OpenFlare/plugins/server/openflare/integration/auth_option_test.go similarity index 94% rename from internal/apps/openflare/integration/auth_option_test.go rename to backend/OpenFlare/plugins/server/openflare/integration/auth_option_test.go index b99d813b..42babd45 100644 --- a/internal/apps/openflare/integration/auth_option_test.go +++ b/backend/OpenFlare/plugins/server/openflare/integration/auth_option_test.go @@ -10,14 +10,15 @@ import ( "net/http/httptest" "testing" - "github.com/Rain-kl/Wavelet/internal/apps/admin" - "github.com/Rain-kl/Wavelet/internal/apps/cap" - "github.com/Rain-kl/Wavelet/internal/apps/oauth" - "github.com/Rain-kl/Wavelet/internal/infra/config" - "github.com/Rain-kl/Wavelet/internal/infra/persistence/idgen" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/testhelper" + "Wavelet/OpenFlare/plugins/server/admin" + "Wavelet/OpenFlare/plugins/server/cap" + "Wavelet/OpenFlare/plugins/server/infra/config" + "Wavelet/OpenFlare/plugins/server/infra/persistence/idgen" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/oauth" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/testhelper" + "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/cookie" "github.com/gin-gonic/gin" diff --git a/internal/apps/openflare/integration/core_chain_test.go b/backend/OpenFlare/plugins/server/openflare/integration/core_chain_test.go similarity index 97% rename from internal/apps/openflare/integration/core_chain_test.go rename to backend/OpenFlare/plugins/server/openflare/integration/core_chain_test.go index 8c3f1cb1..9e6acf54 100644 --- a/internal/apps/openflare/integration/core_chain_test.go +++ b/backend/OpenFlare/plugins/server/openflare/integration/core_chain_test.go @@ -9,10 +9,11 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/agent" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/testhelper" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/openflare/agent" + "Wavelet/OpenFlare/plugins/server/testhelper" + "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" "github.com/stretchr/testify/assert" diff --git a/internal/apps/openflare/integration/helpers_test.go b/backend/OpenFlare/plugins/server/openflare/integration/helpers_test.go similarity index 60% rename from internal/apps/openflare/integration/helpers_test.go rename to backend/OpenFlare/plugins/server/openflare/integration/helpers_test.go index 140a44dc..4a945046 100644 --- a/internal/apps/openflare/integration/helpers_test.go +++ b/backend/OpenFlare/plugins/server/openflare/integration/helpers_test.go @@ -5,14 +5,18 @@ package integration import ( "bytes" + "context" "encoding/json" "net/http" "net/http/httptest" "testing" - v1 "github.com/Rain-kl/Wavelet/internal/router/v1" - ofrouter "github.com/Rain-kl/Wavelet/internal/router/v1/openflare" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/OpenFlare/plugins/server" + "Wavelet/OpenFlare/plugins/server/infra/config" + ofrouter "Wavelet/OpenFlare/plugins/server/router/v1/openflare" + "Wavelet/core" + "Wavelet/pkg/response" + "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) @@ -57,10 +61,37 @@ func unmarshalAPISlice(t *testing.T, data any) []any { return result } +// mountOpenFlareTestRoutes 复刻 driver_http 的挂载方式:先由 server 插件经内核 +// 路由注册表声明路由,再把每条 (方法, 路径, 中间件+处理链) 挂到测试引擎上。 func mountOpenFlareTestRoutes(engine *gin.Engine) { - api := engine.Group("/api") - apiV1 := api.Group("/v1") - v1.RegisterV1Routes(apiV1, api) + // 与生产 config.example.yaml 一致:插件按 app.api_prefix 组装路径。 + if config.Config.App.APIPrefix == "" { + config.Config.App.APIPrefix = "/api" + } + + ctx := core.NewContext(context.Background()) + if err := (&server.Plugin{}).Apply(ctx); err != nil { + panic(err) + } + for _, rd := range ctx.Router().Routes() { + chain := make([]gin.HandlerFunc, 0, len(rd.Middlewares)+len(rd.Handlers)) + for _, item := range append(append([]any{}, rd.Middlewares...), rd.Handlers...) { + chain = append(chain, toGinHandler(item)) + } + engine.Handle(rd.Method, rd.Path, chain...) + } +} + +// toGinHandler 与 driver_http 接受的处理函数形态保持一致。 +func toGinHandler(item any) gin.HandlerFunc { + switch fn := item.(type) { + case gin.HandlerFunc: + return fn + case func(*gin.Context): + return gin.HandlerFunc(fn) + default: + panic("unexpected handler type") + } } func apiPath(subpath string) string { diff --git a/internal/apps/openflare/integration/security_test.go b/backend/OpenFlare/plugins/server/openflare/integration/security_test.go similarity index 98% rename from internal/apps/openflare/integration/security_test.go rename to backend/OpenFlare/plugins/server/openflare/integration/security_test.go index 6f9ad370..35ff08d7 100644 --- a/internal/apps/openflare/integration/security_test.go +++ b/backend/OpenFlare/plugins/server/openflare/integration/security_test.go @@ -16,10 +16,11 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/infra/config" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/testhelper" + "Wavelet/OpenFlare/plugins/server/infra/config" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/testhelper" + "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" "github.com/stretchr/testify/assert" diff --git a/internal/apps/openflare/node/errs.go b/backend/OpenFlare/plugins/server/openflare/node/errs.go similarity index 100% rename from internal/apps/openflare/node/errs.go rename to backend/OpenFlare/plugins/server/openflare/node/errs.go diff --git a/internal/apps/openflare/node/helpers.go b/backend/OpenFlare/plugins/server/openflare/node/helpers.go similarity index 98% rename from internal/apps/openflare/node/helpers.go rename to backend/OpenFlare/plugins/server/openflare/node/helpers.go index b44098ee..0f557932 100644 --- a/internal/apps/openflare/node/helpers.go +++ b/backend/OpenFlare/plugins/server/openflare/node/helpers.go @@ -18,9 +18,9 @@ import ( "strings" "time" - ofws "github.com/Rain-kl/Wavelet/internal/apps/openflare/websocket" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/pkg/util" + "Wavelet/OpenFlare/plugins/server/model" + ofws "Wavelet/OpenFlare/plugins/server/openflare/websocket" + "Wavelet/pkg/util" ) const ( diff --git a/internal/apps/openflare/node/logics.go b/backend/OpenFlare/plugins/server/openflare/node/logics.go similarity index 97% rename from internal/apps/openflare/node/logics.go rename to backend/OpenFlare/plugins/server/openflare/node/logics.go index f88cb47d..30db9082 100644 --- a/internal/apps/openflare/node/logics.go +++ b/backend/OpenFlare/plugins/server/openflare/node/logics.go @@ -10,12 +10,13 @@ import ( "strings" "time" - cf "github.com/Rain-kl/Wavelet/internal/apps/openflare/cloudflare" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/observability" - ofws "github.com/Rain-kl/Wavelet/internal/apps/openflare/websocket" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/pkg/logger" + "Wavelet/OpenFlare/plugins/server/model" + cf "Wavelet/OpenFlare/plugins/server/openflare/cloudflare" + "Wavelet/OpenFlare/plugins/server/openflare/observability" + ofws "Wavelet/OpenFlare/plugins/server/openflare/websocket" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/pkg/logger" + "gorm.io/gorm" ) diff --git a/internal/apps/openflare/node/logics_test.go b/backend/OpenFlare/plugins/server/openflare/node/logics_test.go similarity index 97% rename from internal/apps/openflare/node/logics_test.go rename to backend/OpenFlare/plugins/server/openflare/node/logics_test.go index 03ec0f17..a09ede06 100644 --- a/internal/apps/openflare/node/logics_test.go +++ b/backend/OpenFlare/plugins/server/openflare/node/logics_test.go @@ -11,11 +11,12 @@ import ( "testing" "time" - cf "github.com/Rain-kl/Wavelet/internal/apps/openflare/cloudflare" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/testhelper" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + cf "Wavelet/OpenFlare/plugins/server/openflare/cloudflare" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/testhelper" + "github.com/glebarez/sqlite" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/internal/apps/openflare/node/routers.go b/backend/OpenFlare/plugins/server/openflare/node/routers.go similarity index 98% rename from internal/apps/openflare/node/routers.go rename to backend/OpenFlare/plugins/server/openflare/node/routers.go index 7ca318d7..16fcd8b7 100644 --- a/internal/apps/openflare/node/routers.go +++ b/backend/OpenFlare/plugins/server/openflare/node/routers.go @@ -9,8 +9,9 @@ import ( "io" "net/http" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/OpenFlare/plugins/server/openflare/apiutil" + "Wavelet/pkg/response" + "github.com/gin-gonic/gin" ) diff --git a/internal/apps/openflare/observability/access_log_ip_summary_test.go b/backend/OpenFlare/plugins/server/openflare/observability/access_log_ip_summary_test.go similarity index 100% rename from internal/apps/openflare/observability/access_log_ip_summary_test.go rename to backend/OpenFlare/plugins/server/openflare/observability/access_log_ip_summary_test.go diff --git a/internal/apps/openflare/observability/access_log_logics.go b/backend/OpenFlare/plugins/server/openflare/observability/access_log_logics.go similarity index 99% rename from internal/apps/openflare/observability/access_log_logics.go rename to backend/OpenFlare/plugins/server/openflare/observability/access_log_logics.go index be9b2c8e..d363932a 100644 --- a/internal/apps/openflare/observability/access_log_logics.go +++ b/backend/OpenFlare/plugins/server/openflare/observability/access_log_logics.go @@ -11,11 +11,11 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" - "github.com/Rain-kl/Wavelet/internal/model" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" - "github.com/Rain-kl/Wavelet/pkg/logger" + "Wavelet/OpenFlare/plugins/server/model" + analyticsmodel "Wavelet/OpenFlare/plugins/server/model/analytics" + "Wavelet/pkg/logger" ) const ( diff --git a/internal/apps/openflare/observability/analytics.go b/backend/OpenFlare/plugins/server/openflare/observability/analytics.go similarity index 99% rename from internal/apps/openflare/observability/analytics.go rename to backend/OpenFlare/plugins/server/openflare/observability/analytics.go index 6299b772..97a1cbd5 100644 --- a/internal/apps/openflare/observability/analytics.go +++ b/backend/OpenFlare/plugins/server/openflare/observability/analytics.go @@ -9,9 +9,9 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" - "github.com/Rain-kl/Wavelet/internal/model" + "Wavelet/OpenFlare/plugins/server/model" ) const observabilityTrendBuckets = 24 diff --git a/internal/apps/openflare/observability/analytics_test.go b/backend/OpenFlare/plugins/server/openflare/observability/analytics_test.go similarity index 99% rename from internal/apps/openflare/observability/analytics_test.go rename to backend/OpenFlare/plugins/server/openflare/observability/analytics_test.go index 4b0e5d84..cadf9207 100644 --- a/internal/apps/openflare/observability/analytics_test.go +++ b/backend/OpenFlare/plugins/server/openflare/observability/analytics_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/model" + "Wavelet/OpenFlare/plugins/server/model" ) func TestBuildTrafficTrendPointsFromHourlyBucketsByHour(t *testing.T) { diff --git a/internal/apps/openflare/observability/errs.go b/backend/OpenFlare/plugins/server/openflare/observability/errs.go similarity index 100% rename from internal/apps/openflare/observability/errs.go rename to backend/OpenFlare/plugins/server/openflare/observability/errs.go diff --git a/internal/apps/openflare/observability/node_logics.go b/backend/OpenFlare/plugins/server/openflare/observability/node_logics.go similarity index 98% rename from internal/apps/openflare/observability/node_logics.go rename to backend/OpenFlare/plugins/server/openflare/observability/node_logics.go index b586519c..66236b2d 100644 --- a/internal/apps/openflare/observability/node_logics.go +++ b/backend/OpenFlare/plugins/server/openflare/observability/node_logics.go @@ -10,9 +10,10 @@ import ( "sync" "time" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" + + "Wavelet/OpenFlare/plugins/server/model" - "github.com/Rain-kl/Wavelet/internal/model" "gorm.io/gorm" ) diff --git a/internal/apps/openflare/observability/query_params_test.go b/backend/OpenFlare/plugins/server/openflare/observability/query_params_test.go similarity index 100% rename from internal/apps/openflare/observability/query_params_test.go rename to backend/OpenFlare/plugins/server/openflare/observability/query_params_test.go diff --git a/internal/apps/openflare/observability/routers.go b/backend/OpenFlare/plugins/server/openflare/observability/routers.go similarity index 99% rename from internal/apps/openflare/observability/routers.go rename to backend/OpenFlare/plugins/server/openflare/observability/routers.go index 673d1205..66ba9de7 100644 --- a/internal/apps/openflare/observability/routers.go +++ b/backend/OpenFlare/plugins/server/openflare/observability/routers.go @@ -8,8 +8,9 @@ import ( "net/http" "strconv" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/OpenFlare/plugins/server/openflare/apiutil" + "Wavelet/pkg/response" + "github.com/gin-gonic/gin" ) diff --git a/internal/apps/openflare/option/errs.go b/backend/OpenFlare/plugins/server/openflare/option/errs.go similarity index 100% rename from internal/apps/openflare/option/errs.go rename to backend/OpenFlare/plugins/server/openflare/option/errs.go diff --git a/internal/apps/openflare/option/logics.go b/backend/OpenFlare/plugins/server/openflare/option/logics.go similarity index 95% rename from internal/apps/openflare/option/logics.go rename to backend/OpenFlare/plugins/server/openflare/option/logics.go index 3795c02a..c8163452 100644 --- a/internal/apps/openflare/option/logics.go +++ b/backend/OpenFlare/plugins/server/openflare/option/logics.go @@ -9,11 +9,11 @@ import ( "fmt" "strings" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/geoip" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/uptimekuma" - "github.com/Rain-kl/Wavelet/internal/buildinfo" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/openflare/geoip" + "Wavelet/OpenFlare/plugins/server/openflare/uptimekuma" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/pkg/buildinfo" ) type publicAuthSourceView struct { diff --git a/internal/apps/openflare/option/logics_test.go b/backend/OpenFlare/plugins/server/openflare/option/logics_test.go similarity index 95% rename from internal/apps/openflare/option/logics_test.go rename to backend/OpenFlare/plugins/server/openflare/option/logics_test.go index 2e9e849d..71636f05 100644 --- a/internal/apps/openflare/option/logics_test.go +++ b/backend/OpenFlare/plugins/server/openflare/option/logics_test.go @@ -7,9 +7,10 @@ import ( "context" "testing" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "github.com/glebarez/sqlite" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/internal/apps/openflare/option/openresty_validators.go b/backend/OpenFlare/plugins/server/openflare/option/openresty_validators.go similarity index 98% rename from internal/apps/openflare/option/openresty_validators.go rename to backend/OpenFlare/plugins/server/openflare/option/openresty_validators.go index d4b703ff..2212fad8 100644 --- a/internal/apps/openflare/option/openresty_validators.go +++ b/backend/OpenFlare/plugins/server/openflare/option/openresty_validators.go @@ -10,8 +10,8 @@ import ( "strconv" "strings" - "github.com/Rain-kl/Wavelet/internal/model" - openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty" + "Wavelet/OpenFlare/plugins/server/model" + openrestyrender "Wavelet/OpenFlare/share/render/openresty" ) const ( diff --git a/internal/apps/openflare/option/openresty_validators_test.go b/backend/OpenFlare/plugins/server/openflare/option/openresty_validators_test.go similarity index 98% rename from internal/apps/openflare/option/openresty_validators_test.go rename to backend/OpenFlare/plugins/server/openflare/option/openresty_validators_test.go index c8d9767e..d7df373c 100644 --- a/internal/apps/openflare/option/openresty_validators_test.go +++ b/backend/OpenFlare/plugins/server/openflare/option/openresty_validators_test.go @@ -8,7 +8,8 @@ import ( "strings" "testing" - "github.com/Rain-kl/Wavelet/internal/model" + "Wavelet/OpenFlare/plugins/server/model" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/internal/apps/openflare/option/routers.go b/backend/OpenFlare/plugins/server/openflare/option/routers.go similarity index 96% rename from internal/apps/openflare/option/routers.go rename to backend/OpenFlare/plugins/server/openflare/option/routers.go index 349e7a96..28c8f4e7 100644 --- a/internal/apps/openflare/option/routers.go +++ b/backend/OpenFlare/plugins/server/openflare/option/routers.go @@ -6,9 +6,10 @@ package option import ( "net/http" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/openflare/apiutil" + "Wavelet/pkg/response" + "github.com/gin-gonic/gin" ) diff --git a/internal/apps/openflare/option/validate.go b/backend/OpenFlare/plugins/server/openflare/option/validate.go similarity index 97% rename from internal/apps/openflare/option/validate.go rename to backend/OpenFlare/plugins/server/openflare/option/validate.go index f48c2b15..6f2a5880 100644 --- a/internal/apps/openflare/option/validate.go +++ b/backend/OpenFlare/plugins/server/openflare/option/validate.go @@ -11,9 +11,9 @@ import ( "strconv" "strings" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/geoip" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/openflare/geoip" + "Wavelet/OpenFlare/plugins/server/repository" ) const maxOpenRestyGzipCompLevel = 9 diff --git a/internal/apps/openflare/origin/errs.go b/backend/OpenFlare/plugins/server/openflare/origin/errs.go similarity index 100% rename from internal/apps/openflare/origin/errs.go rename to backend/OpenFlare/plugins/server/openflare/origin/errs.go diff --git a/internal/apps/openflare/origin/helpers.go b/backend/OpenFlare/plugins/server/openflare/origin/helpers.go similarity index 100% rename from internal/apps/openflare/origin/helpers.go rename to backend/OpenFlare/plugins/server/openflare/origin/helpers.go diff --git a/internal/apps/openflare/origin/logics.go b/backend/OpenFlare/plugins/server/openflare/origin/logics.go similarity index 98% rename from internal/apps/openflare/origin/logics.go rename to backend/OpenFlare/plugins/server/openflare/origin/logics.go index d1c19ccd..f9af2f43 100644 --- a/internal/apps/openflare/origin/logics.go +++ b/backend/OpenFlare/plugins/server/openflare/origin/logics.go @@ -11,8 +11,9 @@ import ( "sort" "strings" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "gorm.io/gorm" ) diff --git a/internal/apps/openflare/origin/logics_test.go b/backend/OpenFlare/plugins/server/openflare/origin/logics_test.go similarity index 94% rename from internal/apps/openflare/origin/logics_test.go rename to backend/OpenFlare/plugins/server/openflare/origin/logics_test.go index ba918721..fdafd38c 100644 --- a/internal/apps/openflare/origin/logics_test.go +++ b/backend/OpenFlare/plugins/server/openflare/origin/logics_test.go @@ -7,8 +7,9 @@ import ( "context" "testing" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "github.com/glebarez/sqlite" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/internal/apps/openflare/origin/routers.go b/backend/OpenFlare/plugins/server/openflare/origin/routers.go similarity index 97% rename from internal/apps/openflare/origin/routers.go rename to backend/OpenFlare/plugins/server/openflare/origin/routers.go index 1ecd0dcb..1652234b 100644 --- a/internal/apps/openflare/origin/routers.go +++ b/backend/OpenFlare/plugins/server/openflare/origin/routers.go @@ -6,8 +6,9 @@ package origin import ( "net/http" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/OpenFlare/plugins/server/openflare/apiutil" + "Wavelet/pkg/response" + "github.com/gin-gonic/gin" ) diff --git a/internal/apps/openflare/pages/data/pages/artifacts/published-site/1d0b1001941b126350515d2eaa00cea377a6ceffd4c72c76c77910686be8dc4e.zip b/backend/OpenFlare/plugins/server/openflare/pages/data/pages/artifacts/published-site/1d0b1001941b126350515d2eaa00cea377a6ceffd4c72c76c77910686be8dc4e.zip similarity index 100% rename from internal/apps/openflare/pages/data/pages/artifacts/published-site/1d0b1001941b126350515d2eaa00cea377a6ceffd4c72c76c77910686be8dc4e.zip rename to backend/OpenFlare/plugins/server/openflare/pages/data/pages/artifacts/published-site/1d0b1001941b126350515d2eaa00cea377a6ceffd4c72c76c77910686be8dc4e.zip diff --git a/internal/apps/openflare/pages/download_url.go b/backend/OpenFlare/plugins/server/openflare/pages/download_url.go similarity index 97% rename from internal/apps/openflare/pages/download_url.go rename to backend/OpenFlare/plugins/server/openflare/pages/download_url.go index 4d2647d2..245c15ad 100644 --- a/internal/apps/openflare/pages/download_url.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/download_url.go @@ -9,7 +9,7 @@ import ( "net/url" "strings" - "github.com/Rain-kl/Wavelet/pkg/pagesarchive" + "Wavelet/OpenFlare/share/pagesarchive" ) // downloadPagesPackageFromURL is the deprecated one-shot URL adapter. It uses diff --git a/internal/apps/openflare/pages/download_url_test.go b/backend/OpenFlare/plugins/server/openflare/pages/download_url_test.go similarity index 100% rename from internal/apps/openflare/pages/download_url_test.go rename to backend/OpenFlare/plugins/server/openflare/pages/download_url_test.go diff --git a/internal/apps/openflare/pages/errs.go b/backend/OpenFlare/plugins/server/openflare/pages/errs.go similarity index 100% rename from internal/apps/openflare/pages/errs.go rename to backend/OpenFlare/plugins/server/openflare/pages/errs.go diff --git a/internal/apps/openflare/pages/github_source.go b/backend/OpenFlare/plugins/server/openflare/pages/github_source.go similarity index 98% rename from internal/apps/openflare/pages/github_source.go rename to backend/OpenFlare/plugins/server/openflare/pages/github_source.go index cd929a19..4da3958d 100644 --- a/internal/apps/openflare/pages/github_source.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/github_source.go @@ -17,9 +17,10 @@ import ( "unicode" "unicode/utf8" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/pkg/logger" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/pkg/logger" + "gorm.io/gorm" ) diff --git a/internal/apps/openflare/pages/github_source_action.go b/backend/OpenFlare/plugins/server/openflare/pages/github_source_action.go similarity index 98% rename from internal/apps/openflare/pages/github_source_action.go rename to backend/OpenFlare/plugins/server/openflare/pages/github_source_action.go index 755019c9..982ca6ce 100644 --- a/internal/apps/openflare/pages/github_source_action.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/github_source_action.go @@ -13,12 +13,13 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/infra/task" - "github.com/Rain-kl/Wavelet/internal/integration/githubrelease" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/pkg/logger" - "github.com/Rain-kl/Wavelet/pkg/pagesarchive" + "Wavelet/OpenFlare/plugins/server/infra/task" + "Wavelet/OpenFlare/plugins/server/integration/githubrelease" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/share/pagesarchive" + "Wavelet/pkg/logger" + "gorm.io/gorm" ) diff --git a/internal/apps/openflare/pages/github_source_identity_test.go b/backend/OpenFlare/plugins/server/openflare/pages/github_source_identity_test.go similarity index 97% rename from internal/apps/openflare/pages/github_source_identity_test.go rename to backend/OpenFlare/plugins/server/openflare/pages/github_source_identity_test.go index d1cccba9..df8db095 100644 --- a/internal/apps/openflare/pages/github_source_identity_test.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/github_source_identity_test.go @@ -8,8 +8,8 @@ import ( "testing" "time" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" ) func TestGitHubSourceIdentityLengthPrefixesFieldsAndResetsRuntime(t *testing.T) { diff --git a/internal/apps/openflare/pages/github_source_test.go b/backend/OpenFlare/plugins/server/openflare/pages/github_source_test.go similarity index 99% rename from internal/apps/openflare/pages/github_source_test.go rename to backend/OpenFlare/plugins/server/openflare/pages/github_source_test.go index d773a2c9..c89c33cf 100644 --- a/internal/apps/openflare/pages/github_source_test.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/github_source_test.go @@ -16,12 +16,13 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" + + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/infra/task" + "Wavelet/OpenFlare/plugins/server/integration/githubrelease" + "Wavelet/OpenFlare/plugins/server/model" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/infra/task" - "github.com/Rain-kl/Wavelet/internal/integration/githubrelease" - "github.com/Rain-kl/Wavelet/internal/model" "github.com/hibiken/asynq" "gorm.io/gorm" ) diff --git a/internal/apps/openflare/pages/helpers.go b/backend/OpenFlare/plugins/server/openflare/pages/helpers.go similarity index 98% rename from internal/apps/openflare/pages/helpers.go rename to backend/OpenFlare/plugins/server/openflare/pages/helpers.go index 2cd66190..ee5f42a6 100644 --- a/internal/apps/openflare/pages/helpers.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/helpers.go @@ -18,11 +18,12 @@ import ( "strconv" "strings" - "github.com/Rain-kl/Wavelet/internal/apps/upload" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/pkg/logger" - "github.com/Rain-kl/Wavelet/pkg/pagesarchive" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/upload" + "Wavelet/OpenFlare/share/pagesarchive" + "Wavelet/pkg/logger" + "gorm.io/gorm" "gorm.io/gorm/clause" ) diff --git a/internal/apps/openflare/pages/logics.go b/backend/OpenFlare/plugins/server/openflare/pages/logics.go similarity index 99% rename from internal/apps/openflare/pages/logics.go rename to backend/OpenFlare/plugins/server/openflare/pages/logics.go index 57cdf7f7..80435411 100644 --- a/internal/apps/openflare/pages/logics.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/logics.go @@ -16,11 +16,12 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/apps/upload" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/pkg/logger" - "github.com/Rain-kl/Wavelet/pkg/pagesarchive" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/upload" + "Wavelet/OpenFlare/share/pagesarchive" + "Wavelet/pkg/logger" + "gorm.io/gorm" "gorm.io/gorm/clause" ) diff --git a/internal/apps/openflare/pages/logics_test.go b/backend/OpenFlare/plugins/server/openflare/pages/logics_test.go similarity index 99% rename from internal/apps/openflare/pages/logics_test.go rename to backend/OpenFlare/plugins/server/openflare/pages/logics_test.go index 6d282a5a..2c5c1d99 100644 --- a/internal/apps/openflare/pages/logics_test.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/logics_test.go @@ -17,11 +17,12 @@ import ( "path/filepath" "testing" - "github.com/Rain-kl/Wavelet/internal/apps/upload" - "github.com/Rain-kl/Wavelet/internal/infra/objectstore" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/infra/objectstore" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/upload" + "github.com/glebarez/sqlite" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/internal/apps/openflare/pages/package_metadata.go b/backend/OpenFlare/plugins/server/openflare/pages/package_metadata.go similarity index 94% rename from internal/apps/openflare/pages/package_metadata.go rename to backend/OpenFlare/plugins/server/openflare/pages/package_metadata.go index 76caef92..f1384336 100644 --- a/internal/apps/openflare/pages/package_metadata.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/package_metadata.go @@ -9,9 +9,9 @@ import ( "fmt" "strings" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" - "github.com/Rain-kl/Wavelet/internal/apps/upload" + "Wavelet/OpenFlare/plugins/server/upload" ) // ProjectLatestPackageMetadata describes the active package limits published to Agents. diff --git a/internal/apps/openflare/pages/package_metadata_test.go b/backend/OpenFlare/plugins/server/openflare/pages/package_metadata_test.go similarity index 95% rename from internal/apps/openflare/pages/package_metadata_test.go rename to backend/OpenFlare/plugins/server/openflare/pages/package_metadata_test.go index 46ef3ccd..81851ebd 100644 --- a/internal/apps/openflare/pages/package_metadata_test.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/package_metadata_test.go @@ -10,8 +10,8 @@ import ( "fmt" "testing" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" ) func TestGetProjectLatestPackageMetadataPreservesZeroTotalSize(t *testing.T) { diff --git a/internal/apps/openflare/pages/rebind.go b/backend/OpenFlare/plugins/server/openflare/pages/rebind.go similarity index 97% rename from internal/apps/openflare/pages/rebind.go rename to backend/OpenFlare/plugins/server/openflare/pages/rebind.go index 333894d9..d357593d 100644 --- a/internal/apps/openflare/pages/rebind.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/rebind.go @@ -11,10 +11,11 @@ import ( "path" "strings" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" + + "Wavelet/OpenFlare/plugins/server/model" + openrestyrender "Wavelet/OpenFlare/share/render/openresty" - "github.com/Rain-kl/Wavelet/internal/model" - openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty" "gorm.io/gorm" ) diff --git a/internal/apps/openflare/pages/rebind_test.go b/backend/OpenFlare/plugins/server/openflare/pages/rebind_test.go similarity index 96% rename from internal/apps/openflare/pages/rebind_test.go rename to backend/OpenFlare/plugins/server/openflare/pages/rebind_test.go index acd40c4b..b146f0d8 100644 --- a/internal/apps/openflare/pages/rebind_test.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/rebind_test.go @@ -8,8 +8,9 @@ import ( "encoding/json" "testing" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/internal/apps/openflare/pages/routers.go b/backend/OpenFlare/plugins/server/openflare/pages/routers.go similarity index 98% rename from internal/apps/openflare/pages/routers.go rename to backend/OpenFlare/plugins/server/openflare/pages/routers.go index e07cd496..f335954e 100644 --- a/internal/apps/openflare/pages/routers.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/routers.go @@ -11,11 +11,12 @@ import ( "net/http" "strconv" - "github.com/Rain-kl/Wavelet/internal/apps/oauth" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/shared/response" - "github.com/Rain-kl/Wavelet/pkg/logger" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/oauth" + "Wavelet/OpenFlare/plugins/server/openflare/apiutil" + "Wavelet/pkg/logger" + "Wavelet/pkg/response" + "github.com/gin-gonic/gin" "gorm.io/gorm" ) diff --git a/internal/apps/openflare/pages/routers_source_test.go b/backend/OpenFlare/plugins/server/openflare/pages/routers_source_test.go similarity index 96% rename from internal/apps/openflare/pages/routers_source_test.go rename to backend/OpenFlare/plugins/server/openflare/pages/routers_source_test.go index b558d61c..b7866a29 100644 --- a/internal/apps/openflare/pages/routers_source_test.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/routers_source_test.go @@ -13,11 +13,12 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/apps/oauth" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/infra/task" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/testhelper" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/infra/task" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/oauth" + "Wavelet/OpenFlare/plugins/server/testhelper" + "github.com/alicebob/miniredis/v2" "github.com/gin-gonic/gin" "github.com/hibiken/asynq" diff --git a/internal/apps/openflare/pages/routers_test.go b/backend/OpenFlare/plugins/server/openflare/pages/routers_test.go similarity index 95% rename from internal/apps/openflare/pages/routers_test.go rename to backend/OpenFlare/plugins/server/openflare/pages/routers_test.go index 2ad9eda8..f92b7f8e 100644 --- a/internal/apps/openflare/pages/routers_test.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/routers_test.go @@ -13,10 +13,11 @@ import ( "strconv" "testing" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" + + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/oauth" - "github.com/Rain-kl/Wavelet/internal/apps/oauth" - "github.com/Rain-kl/Wavelet/internal/model" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/internal/apps/openflare/pages/source.go b/backend/OpenFlare/plugins/server/openflare/pages/source.go similarity index 99% rename from internal/apps/openflare/pages/source.go rename to backend/OpenFlare/plugins/server/openflare/pages/source.go index af95202b..c5e8bb8b 100644 --- a/internal/apps/openflare/pages/source.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/source.go @@ -15,8 +15,9 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "gorm.io/gorm" ) diff --git a/internal/apps/openflare/pages/source_manual_test_helpers_test.go b/backend/OpenFlare/plugins/server/openflare/pages/source_manual_test_helpers_test.go similarity index 92% rename from internal/apps/openflare/pages/source_manual_test_helpers_test.go rename to backend/OpenFlare/plugins/server/openflare/pages/source_manual_test_helpers_test.go index d54fc991..32cd3c7b 100644 --- a/internal/apps/openflare/pages/source_manual_test_helpers_test.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/source_manual_test_helpers_test.go @@ -7,8 +7,8 @@ import ( "context" "time" - "github.com/Rain-kl/Wavelet/internal/apps/upload" - "github.com/Rain-kl/Wavelet/internal/model" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/upload" ) func syncRemoteSource( diff --git a/internal/apps/openflare/pages/source_orphan_cleanup.go b/backend/OpenFlare/plugins/server/openflare/pages/source_orphan_cleanup.go similarity index 98% rename from internal/apps/openflare/pages/source_orphan_cleanup.go rename to backend/OpenFlare/plugins/server/openflare/pages/source_orphan_cleanup.go index 570eea86..69d92bba 100644 --- a/internal/apps/openflare/pages/source_orphan_cleanup.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/source_orphan_cleanup.go @@ -10,10 +10,11 @@ import ( "strconv" "time" - "github.com/Rain-kl/Wavelet/internal/apps/upload" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/pkg/logger" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/upload" + "Wavelet/pkg/logger" + "gorm.io/gorm" "gorm.io/gorm/clause" ) diff --git a/internal/apps/openflare/pages/source_orphan_cleanup_test.go b/backend/OpenFlare/plugins/server/openflare/pages/source_orphan_cleanup_test.go similarity index 98% rename from internal/apps/openflare/pages/source_orphan_cleanup_test.go rename to backend/OpenFlare/plugins/server/openflare/pages/source_orphan_cleanup_test.go index 57d655c6..90226776 100644 --- a/internal/apps/openflare/pages/source_orphan_cleanup_test.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/source_orphan_cleanup_test.go @@ -10,9 +10,10 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/apps/upload" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/upload" + "gorm.io/gorm" "gorm.io/gorm/clause" ) diff --git a/internal/apps/openflare/pages/source_provider.go b/backend/OpenFlare/plugins/server/openflare/pages/source_provider.go similarity index 99% rename from internal/apps/openflare/pages/source_provider.go rename to backend/OpenFlare/plugins/server/openflare/pages/source_provider.go index 2026dcd8..183a8582 100644 --- a/internal/apps/openflare/pages/source_provider.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/source_provider.go @@ -21,8 +21,8 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/pkg/httppool" - "github.com/Rain-kl/Wavelet/pkg/pagesarchive" + "Wavelet/OpenFlare/share/pagesarchive" + "Wavelet/pkg/httppool" ) const ( diff --git a/internal/apps/openflare/pages/source_provider_test.go b/backend/OpenFlare/plugins/server/openflare/pages/source_provider_test.go similarity index 100% rename from internal/apps/openflare/pages/source_provider_test.go rename to backend/OpenFlare/plugins/server/openflare/pages/source_provider_test.go diff --git a/internal/apps/openflare/pages/source_runtime.go b/backend/OpenFlare/plugins/server/openflare/pages/source_runtime.go similarity index 99% rename from internal/apps/openflare/pages/source_runtime.go rename to backend/OpenFlare/plugins/server/openflare/pages/source_runtime.go index 4f2bf571..6056b671 100644 --- a/internal/apps/openflare/pages/source_runtime.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/source_runtime.go @@ -11,8 +11,9 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "gorm.io/gorm" ) diff --git a/internal/apps/openflare/pages/source_runtime_test.go b/backend/OpenFlare/plugins/server/openflare/pages/source_runtime_test.go similarity index 98% rename from internal/apps/openflare/pages/source_runtime_test.go rename to backend/OpenFlare/plugins/server/openflare/pages/source_runtime_test.go index 88e51918..1accf2f0 100644 --- a/internal/apps/openflare/pages/source_runtime_test.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/source_runtime_test.go @@ -10,10 +10,10 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" ) func TestSourceLeaseHeartbeatRenewsAndCancelsOnOwnershipLoss(t *testing.T) { diff --git a/internal/apps/openflare/pages/source_scanner.go b/backend/OpenFlare/plugins/server/openflare/pages/source_scanner.go similarity index 98% rename from internal/apps/openflare/pages/source_scanner.go rename to backend/OpenFlare/plugins/server/openflare/pages/source_scanner.go index 06510e38..7ed3e585 100644 --- a/internal/apps/openflare/pages/source_scanner.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/source_scanner.go @@ -11,10 +11,10 @@ import ( "fmt" "time" - "github.com/Rain-kl/Wavelet/internal/infra/task" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/pkg/logger" + "Wavelet/OpenFlare/plugins/server/infra/task" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/pkg/logger" ) const ( diff --git a/internal/apps/openflare/pages/source_scanner_test.go b/backend/OpenFlare/plugins/server/openflare/pages/source_scanner_test.go similarity index 98% rename from internal/apps/openflare/pages/source_scanner_test.go rename to backend/OpenFlare/plugins/server/openflare/pages/source_scanner_test.go index 13565544..ee06e609 100644 --- a/internal/apps/openflare/pages/source_scanner_test.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/source_scanner_test.go @@ -16,11 +16,12 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" + + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/integration/githubrelease" + "Wavelet/OpenFlare/plugins/server/model" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/integration/githubrelease" - "github.com/Rain-kl/Wavelet/internal/model" "gorm.io/gorm" ) diff --git a/internal/apps/openflare/pages/source_sync.go b/backend/OpenFlare/plugins/server/openflare/pages/source_sync.go similarity index 98% rename from internal/apps/openflare/pages/source_sync.go rename to backend/OpenFlare/plugins/server/openflare/pages/source_sync.go index a7f93122..0a8e3504 100644 --- a/internal/apps/openflare/pages/source_sync.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/source_sync.go @@ -15,12 +15,13 @@ import ( "time" "unicode/utf8" - "github.com/Rain-kl/Wavelet/internal/apps/upload" - "github.com/Rain-kl/Wavelet/internal/infra/task" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/pkg/logger" - "github.com/Rain-kl/Wavelet/pkg/util" + "Wavelet/OpenFlare/plugins/server/infra/task" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/upload" + "Wavelet/pkg/logger" + "Wavelet/pkg/util" + "gorm.io/gorm" "gorm.io/gorm/clause" ) diff --git a/internal/apps/openflare/pages/source_sync_test.go b/backend/OpenFlare/plugins/server/openflare/pages/source_sync_test.go similarity index 98% rename from internal/apps/openflare/pages/source_sync_test.go rename to backend/OpenFlare/plugins/server/openflare/pages/source_sync_test.go index 982c5243..9d6c2626 100644 --- a/internal/apps/openflare/pages/source_sync_test.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/source_sync_test.go @@ -17,12 +17,13 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" + + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/upload" + "Wavelet/OpenFlare/share/pagesarchive" - "github.com/Rain-kl/Wavelet/internal/apps/upload" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/pkg/pagesarchive" "gorm.io/gorm" ) diff --git a/internal/apps/openflare/pages/source_tasks.go b/backend/OpenFlare/plugins/server/openflare/pages/source_tasks.go similarity index 98% rename from internal/apps/openflare/pages/source_tasks.go rename to backend/OpenFlare/plugins/server/openflare/pages/source_tasks.go index 9d3b314a..a46c56b2 100644 --- a/internal/apps/openflare/pages/source_tasks.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/source_tasks.go @@ -13,10 +13,11 @@ import ( "strconv" "strings" - "github.com/Rain-kl/Wavelet/internal/infra/task" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/pkg/logger" + "Wavelet/OpenFlare/plugins/server/infra/task" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/pkg/logger" + "gorm.io/gorm" ) diff --git a/internal/apps/openflare/pages/source_tasks_test.go b/backend/OpenFlare/plugins/server/openflare/pages/source_tasks_test.go similarity index 100% rename from internal/apps/openflare/pages/source_tasks_test.go rename to backend/OpenFlare/plugins/server/openflare/pages/source_tasks_test.go diff --git a/internal/apps/openflare/pages/source_test.go b/backend/OpenFlare/plugins/server/openflare/pages/source_test.go similarity index 98% rename from internal/apps/openflare/pages/source_test.go rename to backend/OpenFlare/plugins/server/openflare/pages/source_test.go index 30d302ea..9e94d686 100644 --- a/internal/apps/openflare/pages/source_test.go +++ b/backend/OpenFlare/plugins/server/openflare/pages/source_test.go @@ -10,10 +10,10 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" ) func setupPagesSourceTest(t *testing.T) context.Context { diff --git a/internal/apps/openflare/proxy_route/build_helpers.go b/backend/OpenFlare/plugins/server/openflare/proxy_route/build_helpers.go similarity index 98% rename from internal/apps/openflare/proxy_route/build_helpers.go rename to backend/OpenFlare/plugins/server/openflare/proxy_route/build_helpers.go index b2f457d8..17b7b224 100644 --- a/internal/apps/openflare/proxy_route/build_helpers.go +++ b/backend/OpenFlare/plugins/server/openflare/proxy_route/build_helpers.go @@ -10,7 +10,7 @@ import ( "errors" "strings" - "github.com/Rain-kl/Wavelet/internal/model" + "Wavelet/OpenFlare/plugins/server/model" ) type proxyRouteJSONFields struct { diff --git a/internal/apps/openflare/proxy_route/errs.go b/backend/OpenFlare/plugins/server/openflare/proxy_route/errs.go similarity index 100% rename from internal/apps/openflare/proxy_route/errs.go rename to backend/OpenFlare/plugins/server/openflare/proxy_route/errs.go diff --git a/internal/apps/openflare/proxy_route/helpers.go b/backend/OpenFlare/plugins/server/openflare/proxy_route/helpers.go similarity index 99% rename from internal/apps/openflare/proxy_route/helpers.go rename to backend/OpenFlare/plugins/server/openflare/proxy_route/helpers.go index 98418483..dcb2c86b 100644 --- a/internal/apps/openflare/proxy_route/helpers.go +++ b/backend/OpenFlare/plugins/server/openflare/proxy_route/helpers.go @@ -17,9 +17,10 @@ import ( "strings" "unicode" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" + + "Wavelet/OpenFlare/plugins/server/model" - "github.com/Rain-kl/Wavelet/internal/model" "gorm.io/gorm" ) diff --git a/internal/apps/openflare/proxy_route/helpers_limit_test.go b/backend/OpenFlare/plugins/server/openflare/proxy_route/helpers_limit_test.go similarity index 100% rename from internal/apps/openflare/proxy_route/helpers_limit_test.go rename to backend/OpenFlare/plugins/server/openflare/proxy_route/helpers_limit_test.go diff --git a/internal/apps/openflare/proxy_route/logics.go b/backend/OpenFlare/plugins/server/openflare/proxy_route/logics.go similarity index 99% rename from internal/apps/openflare/proxy_route/logics.go rename to backend/OpenFlare/plugins/server/openflare/proxy_route/logics.go index da28846b..ee57f081 100644 --- a/internal/apps/openflare/proxy_route/logics.go +++ b/backend/OpenFlare/plugins/server/openflare/proxy_route/logics.go @@ -10,8 +10,9 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "gorm.io/gorm" ) diff --git a/internal/apps/openflare/proxy_route/logics_test.go b/backend/OpenFlare/plugins/server/openflare/proxy_route/logics_test.go similarity index 97% rename from internal/apps/openflare/proxy_route/logics_test.go rename to backend/OpenFlare/plugins/server/openflare/proxy_route/logics_test.go index c2011d46..e811aa5f 100644 --- a/internal/apps/openflare/proxy_route/logics_test.go +++ b/backend/OpenFlare/plugins/server/openflare/proxy_route/logics_test.go @@ -7,9 +7,10 @@ import ( "context" "testing" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "github.com/glebarez/sqlite" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/internal/apps/openflare/proxy_route/origin_helpers.go b/backend/OpenFlare/plugins/server/openflare/proxy_route/origin_helpers.go similarity index 97% rename from internal/apps/openflare/proxy_route/origin_helpers.go rename to backend/OpenFlare/plugins/server/openflare/proxy_route/origin_helpers.go index 0bc50260..01eaf6e5 100644 --- a/internal/apps/openflare/proxy_route/origin_helpers.go +++ b/backend/OpenFlare/plugins/server/openflare/proxy_route/origin_helpers.go @@ -8,7 +8,7 @@ import ( "errors" "strings" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" "gorm.io/gorm" ) diff --git a/internal/apps/openflare/proxy_route/routers.go b/backend/OpenFlare/plugins/server/openflare/proxy_route/routers.go similarity index 97% rename from internal/apps/openflare/proxy_route/routers.go rename to backend/OpenFlare/plugins/server/openflare/proxy_route/routers.go index c5e8b119..29c76b9b 100644 --- a/internal/apps/openflare/proxy_route/routers.go +++ b/backend/OpenFlare/plugins/server/openflare/proxy_route/routers.go @@ -6,8 +6,9 @@ package proxy_route import ( "net/http" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/OpenFlare/plugins/server/openflare/apiutil" + "Wavelet/pkg/response" + "github.com/gin-gonic/gin" ) diff --git a/internal/apps/openflare/relay/errs.go b/backend/OpenFlare/plugins/server/openflare/relay/errs.go similarity index 100% rename from internal/apps/openflare/relay/errs.go rename to backend/OpenFlare/plugins/server/openflare/relay/errs.go diff --git a/internal/apps/openflare/relay/helpers.go b/backend/OpenFlare/plugins/server/openflare/relay/helpers.go similarity index 97% rename from internal/apps/openflare/relay/helpers.go rename to backend/OpenFlare/plugins/server/openflare/relay/helpers.go index a95aaf4e..7b49882b 100644 --- a/internal/apps/openflare/relay/helpers.go +++ b/backend/OpenFlare/plugins/server/openflare/relay/helpers.go @@ -8,8 +8,8 @@ import ( "net" "strings" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" ) const ( diff --git a/internal/apps/openflare/relay/logics.go b/backend/OpenFlare/plugins/server/openflare/relay/logics.go similarity index 93% rename from internal/apps/openflare/relay/logics.go rename to backend/OpenFlare/plugins/server/openflare/relay/logics.go index 12f1c462..4fa32620 100644 --- a/internal/apps/openflare/relay/logics.go +++ b/backend/OpenFlare/plugins/server/openflare/relay/logics.go @@ -10,10 +10,10 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/agent" - ofgeoip "github.com/Rain-kl/Wavelet/internal/apps/openflare/geoip" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/openflare/agent" + ofgeoip "Wavelet/OpenFlare/plugins/server/openflare/geoip" + "Wavelet/OpenFlare/plugins/server/repository" ) const nodeStatusOnline = "online" diff --git a/internal/apps/openflare/relay/logics_test.go b/backend/OpenFlare/plugins/server/openflare/relay/logics_test.go similarity index 94% rename from internal/apps/openflare/relay/logics_test.go rename to backend/OpenFlare/plugins/server/openflare/relay/logics_test.go index 729226d7..5279fb96 100644 --- a/internal/apps/openflare/relay/logics_test.go +++ b/backend/OpenFlare/plugins/server/openflare/relay/logics_test.go @@ -9,12 +9,13 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/testhelper" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/testhelper" + + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/openflare/agent" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/agent" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" "github.com/glebarez/sqlite" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/internal/apps/openflare/relay/middleware.go b/backend/OpenFlare/plugins/server/openflare/relay/middleware.go similarity index 85% rename from internal/apps/openflare/relay/middleware.go rename to backend/OpenFlare/plugins/server/openflare/relay/middleware.go index 3cb40403..28dba96b 100644 --- a/internal/apps/openflare/relay/middleware.go +++ b/backend/OpenFlare/plugins/server/openflare/relay/middleware.go @@ -6,9 +6,10 @@ package relay import ( "strings" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/agent" + "Wavelet/OpenFlare/plugins/server/openflare/agent" + + "Wavelet/pkg/response" - "github.com/Rain-kl/Wavelet/internal/shared/response" "github.com/gin-gonic/gin" ) diff --git a/internal/apps/openflare/relay/middleware_test.go b/backend/OpenFlare/plugins/server/openflare/relay/middleware_test.go similarity index 92% rename from internal/apps/openflare/relay/middleware_test.go rename to backend/OpenFlare/plugins/server/openflare/relay/middleware_test.go index 14520894..72eba2e7 100644 --- a/internal/apps/openflare/relay/middleware_test.go +++ b/backend/OpenFlare/plugins/server/openflare/relay/middleware_test.go @@ -9,11 +9,12 @@ import ( "net/http/httptest" "testing" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" + + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/pkg/response" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/shared/response" "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" "github.com/stretchr/testify/assert" diff --git a/internal/apps/openflare/relay/observability.go b/backend/OpenFlare/plugins/server/openflare/relay/observability.go similarity index 91% rename from internal/apps/openflare/relay/observability.go rename to backend/OpenFlare/plugins/server/openflare/relay/observability.go index c148e2a1..6b6811dd 100644 --- a/internal/apps/openflare/relay/observability.go +++ b/backend/OpenFlare/plugins/server/openflare/relay/observability.go @@ -7,10 +7,11 @@ import ( "context" "time" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" + + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/openflare/agent" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/agent" - "github.com/Rain-kl/Wavelet/internal/model" "go.uber.org/zap" ) diff --git a/internal/apps/openflare/relay/protocol_alias.go b/backend/OpenFlare/plugins/server/openflare/relay/protocol_alias.go similarity index 91% rename from internal/apps/openflare/relay/protocol_alias.go rename to backend/OpenFlare/plugins/server/openflare/relay/protocol_alias.go index b801ad9d..9dc4e773 100644 --- a/internal/apps/openflare/relay/protocol_alias.go +++ b/backend/OpenFlare/plugins/server/openflare/relay/protocol_alias.go @@ -3,7 +3,7 @@ package relay -import pkgprotocol "github.com/Rain-kl/Wavelet/pkg/protocol" +import pkgprotocol "Wavelet/OpenFlare/share/protocol" // ProxyStat is an alias for protocol.RelayProxyStat. type ProxyStat = pkgprotocol.RelayProxyStat diff --git a/internal/apps/openflare/relay/routers.go b/backend/OpenFlare/plugins/server/openflare/relay/routers.go similarity index 89% rename from internal/apps/openflare/relay/routers.go rename to backend/OpenFlare/plugins/server/openflare/relay/routers.go index 67dfcf9f..0ac6574b 100644 --- a/internal/apps/openflare/relay/routers.go +++ b/backend/OpenFlare/plugins/server/openflare/relay/routers.go @@ -6,10 +6,11 @@ package relay import ( "net/http" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil" - ofws "github.com/Rain-kl/Wavelet/internal/apps/openflare/websocket" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/openflare/apiutil" + ofws "Wavelet/OpenFlare/plugins/server/openflare/websocket" + "Wavelet/pkg/response" + "github.com/gin-gonic/gin" ) diff --git a/internal/apps/openflare/routeidentity/identity.go b/backend/OpenFlare/plugins/server/openflare/routeidentity/identity.go similarity index 100% rename from internal/apps/openflare/routeidentity/identity.go rename to backend/OpenFlare/plugins/server/openflare/routeidentity/identity.go diff --git a/internal/apps/openflare/routeidentity/identity_test.go b/backend/OpenFlare/plugins/server/openflare/routeidentity/identity_test.go similarity index 100% rename from internal/apps/openflare/routeidentity/identity_test.go rename to backend/OpenFlare/plugins/server/openflare/routeidentity/identity_test.go diff --git a/internal/apps/openflare/tasks/doc.go b/backend/OpenFlare/plugins/server/openflare/tasks/doc.go similarity index 100% rename from internal/apps/openflare/tasks/doc.go rename to backend/OpenFlare/plugins/server/openflare/tasks/doc.go diff --git a/internal/apps/openflare/tasks/log_db_switch.go b/backend/OpenFlare/plugins/server/openflare/tasks/log_db_switch.go similarity index 96% rename from internal/apps/openflare/tasks/log_db_switch.go rename to backend/OpenFlare/plugins/server/openflare/tasks/log_db_switch.go index 78d193d0..ba13dfb4 100644 --- a/internal/apps/openflare/tasks/log_db_switch.go +++ b/backend/OpenFlare/plugins/server/openflare/tasks/log_db_switch.go @@ -10,14 +10,14 @@ import ( "fmt" "time" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/chwriter" - "github.com/Rain-kl/Wavelet/internal/infra/config" - "github.com/Rain-kl/Wavelet/internal/infra/task" - "github.com/Rain-kl/Wavelet/internal/model" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/repository/logstore" - "github.com/Rain-kl/Wavelet/pkg/logger" + "Wavelet/OpenFlare/plugins/server/infra/config" + "Wavelet/OpenFlare/plugins/server/infra/task" + "Wavelet/OpenFlare/plugins/server/model" + analyticsmodel "Wavelet/OpenFlare/plugins/server/model/analytics" + "Wavelet/OpenFlare/plugins/server/openflare/chwriter" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/repository/logstore" + "Wavelet/pkg/logger" ) const copyBatchSize = 1000 diff --git a/internal/apps/openflare/tasks/log_db_switch_test.go b/backend/OpenFlare/plugins/server/openflare/tasks/log_db_switch_test.go similarity index 97% rename from internal/apps/openflare/tasks/log_db_switch_test.go rename to backend/OpenFlare/plugins/server/openflare/tasks/log_db_switch_test.go index 727895fb..f7216adb 100644 --- a/internal/apps/openflare/tasks/log_db_switch_test.go +++ b/backend/OpenFlare/plugins/server/openflare/tasks/log_db_switch_test.go @@ -18,12 +18,12 @@ import ( "gorm.io/gorm" "gorm.io/gorm/logger" - "github.com/Rain-kl/Wavelet/internal/infra/config" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/repository/logstore" + "Wavelet/OpenFlare/plugins/server/infra/config" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + analyticsmodel "Wavelet/OpenFlare/plugins/server/model/analytics" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/repository/logstore" ) var logDBSwitchDBSeq int64 diff --git a/internal/apps/openflare/tasks/ssl_renew.go b/backend/OpenFlare/plugins/server/openflare/tasks/ssl_renew.go similarity index 88% rename from internal/apps/openflare/tasks/ssl_renew.go rename to backend/OpenFlare/plugins/server/openflare/tasks/ssl_renew.go index ec70ded2..853b7f68 100644 --- a/internal/apps/openflare/tasks/ssl_renew.go +++ b/backend/OpenFlare/plugins/server/openflare/tasks/ssl_renew.go @@ -7,10 +7,10 @@ import ( "context" "time" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/tls" - "github.com/Rain-kl/Wavelet/pkg/logger" + "Wavelet/OpenFlare/plugins/server/openflare/tls" + "Wavelet/pkg/logger" ) // RunSSLRenewJob renews all TLS certificates that are due for renewal. diff --git a/internal/apps/openflare/tasks/ssl_renew_test.go b/backend/OpenFlare/plugins/server/openflare/tasks/ssl_renew_test.go similarity index 86% rename from internal/apps/openflare/tasks/ssl_renew_test.go rename to backend/OpenFlare/plugins/server/openflare/tasks/ssl_renew_test.go index f298a269..3b06dba7 100644 --- a/internal/apps/openflare/tasks/ssl_renew_test.go +++ b/backend/OpenFlare/plugins/server/openflare/tasks/ssl_renew_test.go @@ -8,13 +8,14 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/tls" - "github.com/Rain-kl/Wavelet/internal/infra/config" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/infra/task" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/testhelper" + "Wavelet/OpenFlare/plugins/server/infra/config" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/infra/task" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/openflare/tls" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/testhelper" + "github.com/hibiken/asynq" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/internal/apps/openflare/tls/acme/client.go b/backend/OpenFlare/plugins/server/openflare/tls/acme/client.go similarity index 100% rename from internal/apps/openflare/tls/acme/client.go rename to backend/OpenFlare/plugins/server/openflare/tls/acme/client.go diff --git a/internal/apps/openflare/tls/acme_obtain_test.go b/backend/OpenFlare/plugins/server/openflare/tls/acme_obtain_test.go similarity index 98% rename from internal/apps/openflare/tls/acme_obtain_test.go rename to backend/OpenFlare/plugins/server/openflare/tls/acme_obtain_test.go index 5bee84a0..1a2f256c 100644 --- a/internal/apps/openflare/tls/acme_obtain_test.go +++ b/backend/OpenFlare/plugins/server/openflare/tls/acme_obtain_test.go @@ -8,9 +8,10 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" + + "Wavelet/OpenFlare/plugins/server/model" - "github.com/Rain-kl/Wavelet/internal/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/internal/apps/openflare/tls/errs.go b/backend/OpenFlare/plugins/server/openflare/tls/errs.go similarity index 100% rename from internal/apps/openflare/tls/errs.go rename to backend/OpenFlare/plugins/server/openflare/tls/errs.go diff --git a/internal/apps/openflare/tls/helpers.go b/backend/OpenFlare/plugins/server/openflare/tls/helpers.go similarity index 100% rename from internal/apps/openflare/tls/helpers.go rename to backend/OpenFlare/plugins/server/openflare/tls/helpers.go diff --git a/internal/apps/openflare/tls/logics.go b/backend/OpenFlare/plugins/server/openflare/tls/logics.go similarity index 99% rename from internal/apps/openflare/tls/logics.go rename to backend/OpenFlare/plugins/server/openflare/tls/logics.go index 60b427c0..b1770add 100644 --- a/internal/apps/openflare/tls/logics.go +++ b/backend/OpenFlare/plugins/server/openflare/tls/logics.go @@ -12,10 +12,10 @@ import ( "mime/multipart" "strings" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" - "github.com/Rain-kl/Wavelet/internal/infra/task" - "github.com/Rain-kl/Wavelet/internal/model" + "Wavelet/OpenFlare/plugins/server/infra/task" + "Wavelet/OpenFlare/plugins/server/model" ) // CertificateInput TLS 证书创建/更新请求。 diff --git a/internal/apps/openflare/tls/logics_test.go b/backend/OpenFlare/plugins/server/openflare/tls/logics_test.go similarity index 92% rename from internal/apps/openflare/tls/logics_test.go rename to backend/OpenFlare/plugins/server/openflare/tls/logics_test.go index e5db86e2..4f8c8ff7 100644 --- a/internal/apps/openflare/tls/logics_test.go +++ b/backend/OpenFlare/plugins/server/openflare/tls/logics_test.go @@ -16,13 +16,14 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/credential" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/openflare/credential" + "Wavelet/OpenFlare/plugins/server/repository" + + "Wavelet/OpenFlare/plugins/server/infra/config" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/infra/task" + "Wavelet/OpenFlare/plugins/server/model" - "github.com/Rain-kl/Wavelet/internal/infra/config" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/infra/task" - "github.com/Rain-kl/Wavelet/internal/model" "github.com/alicebob/miniredis/v2" "github.com/glebarez/sqlite" "github.com/hibiken/asynq" diff --git a/internal/apps/openflare/tls/obtain.go b/backend/OpenFlare/plugins/server/openflare/tls/obtain.go similarity index 95% rename from internal/apps/openflare/tls/obtain.go rename to backend/OpenFlare/plugins/server/openflare/tls/obtain.go index 89c0a0f7..8cbe9000 100644 --- a/internal/apps/openflare/tls/obtain.go +++ b/backend/OpenFlare/plugins/server/openflare/tls/obtain.go @@ -9,11 +9,11 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/tls/acme" - "github.com/Rain-kl/Wavelet/internal/infra/task" - "github.com/Rain-kl/Wavelet/internal/model" + "Wavelet/OpenFlare/plugins/server/infra/task" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/openflare/tls/acme" ) const ( diff --git a/internal/apps/openflare/tls/obtain_helpers.go b/backend/OpenFlare/plugins/server/openflare/tls/obtain_helpers.go similarity index 93% rename from internal/apps/openflare/tls/obtain_helpers.go rename to backend/OpenFlare/plugins/server/openflare/tls/obtain_helpers.go index d135fa98..71a5631a 100644 --- a/internal/apps/openflare/tls/obtain_helpers.go +++ b/backend/OpenFlare/plugins/server/openflare/tls/obtain_helpers.go @@ -7,10 +7,10 @@ import ( "context" "fmt" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/tls/acme" - "github.com/Rain-kl/Wavelet/internal/model" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/openflare/tls/acme" ) func resolveAcmeAccount(ctx context.Context, cert *model.TLSCertificate) (*model.AcmeAccount, error) { diff --git a/internal/apps/openflare/tls/routers.go b/backend/OpenFlare/plugins/server/openflare/tls/routers.go similarity index 99% rename from internal/apps/openflare/tls/routers.go rename to backend/OpenFlare/plugins/server/openflare/tls/routers.go index 4838965a..a2108ef6 100644 --- a/internal/apps/openflare/tls/routers.go +++ b/backend/OpenFlare/plugins/server/openflare/tls/routers.go @@ -6,8 +6,9 @@ package tls import ( "net/http" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/OpenFlare/plugins/server/openflare/apiutil" + "Wavelet/pkg/response" + "github.com/gin-gonic/gin" ) diff --git a/internal/apps/openflare/tls/sensitive.go b/backend/OpenFlare/plugins/server/openflare/tls/sensitive.go similarity index 85% rename from internal/apps/openflare/tls/sensitive.go rename to backend/OpenFlare/plugins/server/openflare/tls/sensitive.go index acd90a0a..0e797146 100644 --- a/internal/apps/openflare/tls/sensitive.go +++ b/backend/OpenFlare/plugins/server/openflare/tls/sensitive.go @@ -3,7 +3,7 @@ package tls -import "github.com/Rain-kl/Wavelet/internal/apps/openflare/credential" +import "Wavelet/OpenFlare/plugins/server/openflare/credential" func sealSensitive(plaintext string) (string, error) { return credential.Seal(plaintext) diff --git a/internal/apps/openflare/tls/tasks.go b/backend/OpenFlare/plugins/server/openflare/tls/tasks.go similarity index 96% rename from internal/apps/openflare/tls/tasks.go rename to backend/OpenFlare/plugins/server/openflare/tls/tasks.go index 3dceff00..c3b141cf 100644 --- a/internal/apps/openflare/tls/tasks.go +++ b/backend/OpenFlare/plugins/server/openflare/tls/tasks.go @@ -9,9 +9,9 @@ import ( "errors" "fmt" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" - "github.com/Rain-kl/Wavelet/internal/infra/task" + "Wavelet/OpenFlare/plugins/server/infra/task" ) const ( diff --git a/internal/apps/openflare/tls/tasks_test.go b/backend/OpenFlare/plugins/server/openflare/tls/tasks_test.go similarity index 97% rename from internal/apps/openflare/tls/tasks_test.go rename to backend/OpenFlare/plugins/server/openflare/tls/tasks_test.go index 27267008..626e583f 100644 --- a/internal/apps/openflare/tls/tasks_test.go +++ b/backend/OpenFlare/plugins/server/openflare/tls/tasks_test.go @@ -9,9 +9,10 @@ import ( "errors" "testing" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" + + "Wavelet/OpenFlare/plugins/server/model" - "github.com/Rain-kl/Wavelet/internal/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/internal/apps/openflare/uptimekuma/client.go b/backend/OpenFlare/plugins/server/openflare/uptimekuma/client.go similarity index 100% rename from internal/apps/openflare/uptimekuma/client.go rename to backend/OpenFlare/plugins/server/openflare/uptimekuma/client.go diff --git a/internal/apps/openflare/uptimekuma/sync.go b/backend/OpenFlare/plugins/server/openflare/uptimekuma/sync.go similarity index 98% rename from internal/apps/openflare/uptimekuma/sync.go rename to backend/OpenFlare/plugins/server/openflare/uptimekuma/sync.go index e33e00d5..e1963c01 100644 --- a/internal/apps/openflare/uptimekuma/sync.go +++ b/backend/OpenFlare/plugins/server/openflare/uptimekuma/sync.go @@ -11,8 +11,8 @@ import ( "strings" "sync/atomic" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" ) const uptimeKumaTagOpenFlare = "OpenFlare" diff --git a/internal/apps/openflare/uptimekuma/sync_helpers.go b/backend/OpenFlare/plugins/server/openflare/uptimekuma/sync_helpers.go similarity index 98% rename from internal/apps/openflare/uptimekuma/sync_helpers.go rename to backend/OpenFlare/plugins/server/openflare/uptimekuma/sync_helpers.go index 2a184805..f98b89ab 100644 --- a/internal/apps/openflare/uptimekuma/sync_helpers.go +++ b/backend/OpenFlare/plugins/server/openflare/uptimekuma/sync_helpers.go @@ -11,7 +11,7 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/model" + "Wavelet/OpenFlare/plugins/server/model" ) const monitorListWaitTimeout = 5 * time.Second diff --git a/internal/apps/openflare/uptimekuma/sync_test.go b/backend/OpenFlare/plugins/server/openflare/uptimekuma/sync_test.go similarity index 98% rename from internal/apps/openflare/uptimekuma/sync_test.go rename to backend/OpenFlare/plugins/server/openflare/uptimekuma/sync_test.go index eaebfdb9..0174e69a 100644 --- a/internal/apps/openflare/uptimekuma/sync_test.go +++ b/backend/OpenFlare/plugins/server/openflare/uptimekuma/sync_test.go @@ -15,9 +15,10 @@ import ( "testing" "time" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "github.com/glebarez/sqlite" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/internal/apps/openflare/waf/errs.go b/backend/OpenFlare/plugins/server/openflare/waf/errs.go similarity index 100% rename from internal/apps/openflare/waf/errs.go rename to backend/OpenFlare/plugins/server/openflare/waf/errs.go diff --git a/internal/apps/openflare/waf/graph_compile.go b/backend/OpenFlare/plugins/server/openflare/waf/graph_compile.go similarity index 100% rename from internal/apps/openflare/waf/graph_compile.go rename to backend/OpenFlare/plugins/server/openflare/waf/graph_compile.go diff --git a/internal/apps/openflare/waf/graph_compile_test.go b/backend/OpenFlare/plugins/server/openflare/waf/graph_compile_test.go similarity index 100% rename from internal/apps/openflare/waf/graph_compile_test.go rename to backend/OpenFlare/plugins/server/openflare/waf/graph_compile_test.go diff --git a/internal/apps/openflare/waf/graph_types.go b/backend/OpenFlare/plugins/server/openflare/waf/graph_types.go similarity index 100% rename from internal/apps/openflare/waf/graph_types.go rename to backend/OpenFlare/plugins/server/openflare/waf/graph_types.go diff --git a/internal/apps/openflare/waf/graph_validate.go b/backend/OpenFlare/plugins/server/openflare/waf/graph_validate.go similarity index 100% rename from internal/apps/openflare/waf/graph_validate.go rename to backend/OpenFlare/plugins/server/openflare/waf/graph_validate.go diff --git a/internal/apps/openflare/waf/graph_validate_test.go b/backend/OpenFlare/plugins/server/openflare/waf/graph_validate_test.go similarity index 100% rename from internal/apps/openflare/waf/graph_validate_test.go rename to backend/OpenFlare/plugins/server/openflare/waf/graph_validate_test.go diff --git a/internal/apps/openflare/waf/ip_group_sync.go b/backend/OpenFlare/plugins/server/openflare/waf/ip_group_sync.go similarity index 98% rename from internal/apps/openflare/waf/ip_group_sync.go rename to backend/OpenFlare/plugins/server/openflare/waf/ip_group_sync.go index 5738b6b4..b907c534 100644 --- a/internal/apps/openflare/waf/ip_group_sync.go +++ b/backend/OpenFlare/plugins/server/openflare/waf/ip_group_sync.go @@ -18,11 +18,11 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/agent" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/websocket" - "github.com/Rain-kl/Wavelet/internal/model" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/openflare/agent" + "Wavelet/OpenFlare/plugins/server/openflare/websocket" exprlang "github.com/expr-lang/expr" "github.com/expr-lang/expr/vm" diff --git a/internal/apps/openflare/waf/ip_group_sync_test.go b/backend/OpenFlare/plugins/server/openflare/waf/ip_group_sync_test.go similarity index 97% rename from internal/apps/openflare/waf/ip_group_sync_test.go rename to backend/OpenFlare/plugins/server/openflare/waf/ip_group_sync_test.go index a01544d2..3cdc5ef4 100644 --- a/internal/apps/openflare/waf/ip_group_sync_test.go +++ b/backend/OpenFlare/plugins/server/openflare/waf/ip_group_sync_test.go @@ -11,11 +11,12 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/testhelper" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/testhelper" + + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" "github.com/glebarez/sqlite" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/internal/apps/openflare/waf/logics.go b/backend/OpenFlare/plugins/server/openflare/waf/logics.go similarity index 99% rename from internal/apps/openflare/waf/logics.go rename to backend/OpenFlare/plugins/server/openflare/waf/logics.go index 499e182a..b0e9e606 100644 --- a/internal/apps/openflare/waf/logics.go +++ b/backend/OpenFlare/plugins/server/openflare/waf/logics.go @@ -14,9 +14,9 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" - "github.com/Rain-kl/Wavelet/internal/model" + "Wavelet/OpenFlare/plugins/server/model" exprlang "github.com/expr-lang/expr" "gorm.io/gorm" diff --git a/internal/apps/openflare/waf/logics_test.go b/backend/OpenFlare/plugins/server/openflare/waf/logics_test.go similarity index 93% rename from internal/apps/openflare/waf/logics_test.go rename to backend/OpenFlare/plugins/server/openflare/waf/logics_test.go index e96d6c54..7d478628 100644 --- a/internal/apps/openflare/waf/logics_test.go +++ b/backend/OpenFlare/plugins/server/openflare/waf/logics_test.go @@ -7,10 +7,11 @@ import ( "context" "testing" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" + + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" "github.com/glebarez/sqlite" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/internal/apps/openflare/waf/routers.go b/backend/OpenFlare/plugins/server/openflare/waf/routers.go similarity index 98% rename from internal/apps/openflare/waf/routers.go rename to backend/OpenFlare/plugins/server/openflare/waf/routers.go index 972e0387..700e35c7 100644 --- a/internal/apps/openflare/waf/routers.go +++ b/backend/OpenFlare/plugins/server/openflare/waf/routers.go @@ -7,8 +7,9 @@ import ( "net/http" "strconv" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/OpenFlare/plugins/server/openflare/apiutil" + "Wavelet/pkg/response" + "github.com/gin-gonic/gin" ) diff --git a/internal/apps/openflare/waf/rule_logics.go b/backend/OpenFlare/plugins/server/openflare/waf/rule_logics.go similarity index 98% rename from internal/apps/openflare/waf/rule_logics.go rename to backend/OpenFlare/plugins/server/openflare/waf/rule_logics.go index c128f293..4460cbe7 100644 --- a/internal/apps/openflare/waf/rule_logics.go +++ b/backend/OpenFlare/plugins/server/openflare/waf/rule_logics.go @@ -11,9 +11,10 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" + + "Wavelet/OpenFlare/plugins/server/model" - "github.com/Rain-kl/Wavelet/internal/model" "gorm.io/gorm" ) diff --git a/internal/apps/openflare/waf/rule_logics_test.go b/backend/OpenFlare/plugins/server/openflare/waf/rule_logics_test.go similarity index 97% rename from internal/apps/openflare/waf/rule_logics_test.go rename to backend/OpenFlare/plugins/server/openflare/waf/rule_logics_test.go index b4376b83..29dbecd4 100644 --- a/internal/apps/openflare/waf/rule_logics_test.go +++ b/backend/OpenFlare/plugins/server/openflare/waf/rule_logics_test.go @@ -12,11 +12,12 @@ import ( "strconv" "testing" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" + + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/pkg/response" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/shared/response" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/internal/apps/openflare/waf/rule_routers.go b/backend/OpenFlare/plugins/server/openflare/waf/rule_routers.go similarity index 96% rename from internal/apps/openflare/waf/rule_routers.go rename to backend/OpenFlare/plugins/server/openflare/waf/rule_routers.go index cf3fd1f4..7f02083e 100644 --- a/internal/apps/openflare/waf/rule_routers.go +++ b/backend/OpenFlare/plugins/server/openflare/waf/rule_routers.go @@ -7,10 +7,11 @@ import ( "errors" "net/http" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/shared/response" - "github.com/Rain-kl/Wavelet/pkg/logger" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/openflare/apiutil" + "Wavelet/pkg/logger" + "Wavelet/pkg/response" + "github.com/gin-gonic/gin" "gorm.io/gorm" ) diff --git a/internal/apps/openflare/websocket/agent_hub.go b/backend/OpenFlare/plugins/server/openflare/websocket/agent_hub.go similarity index 100% rename from internal/apps/openflare/websocket/agent_hub.go rename to backend/OpenFlare/plugins/server/openflare/websocket/agent_hub.go diff --git a/internal/apps/openflare/websocket/client_core.go b/backend/OpenFlare/plugins/server/openflare/websocket/client_core.go similarity index 100% rename from internal/apps/openflare/websocket/client_core.go rename to backend/OpenFlare/plugins/server/openflare/websocket/client_core.go diff --git a/internal/apps/openflare/websocket/client_core_test.go b/backend/OpenFlare/plugins/server/openflare/websocket/client_core_test.go similarity index 100% rename from internal/apps/openflare/websocket/client_core_test.go rename to backend/OpenFlare/plugins/server/openflare/websocket/client_core_test.go diff --git a/internal/apps/openflare/websocket/common.go b/backend/OpenFlare/plugins/server/openflare/websocket/common.go similarity index 100% rename from internal/apps/openflare/websocket/common.go rename to backend/OpenFlare/plugins/server/openflare/websocket/common.go diff --git a/internal/apps/openflare/websocket/consts.go b/backend/OpenFlare/plugins/server/openflare/websocket/consts.go similarity index 100% rename from internal/apps/openflare/websocket/consts.go rename to backend/OpenFlare/plugins/server/openflare/websocket/consts.go diff --git a/internal/apps/openflare/websocket/flared_hub.go b/backend/OpenFlare/plugins/server/openflare/websocket/flared_hub.go similarity index 100% rename from internal/apps/openflare/websocket/flared_hub.go rename to backend/OpenFlare/plugins/server/openflare/websocket/flared_hub.go diff --git a/internal/apps/openflare/websocket/read_pump.go b/backend/OpenFlare/plugins/server/openflare/websocket/read_pump.go similarity index 100% rename from internal/apps/openflare/websocket/read_pump.go rename to backend/OpenFlare/plugins/server/openflare/websocket/read_pump.go diff --git a/internal/apps/openflare/websocket/relay_hub.go b/backend/OpenFlare/plugins/server/openflare/websocket/relay_hub.go similarity index 100% rename from internal/apps/openflare/websocket/relay_hub.go rename to backend/OpenFlare/plugins/server/openflare/websocket/relay_hub.go diff --git a/internal/apps/openflare/websocket/write_pump.go b/backend/OpenFlare/plugins/server/openflare/websocket/write_pump.go similarity index 100% rename from internal/apps/openflare/websocket/write_pump.go rename to backend/OpenFlare/plugins/server/openflare/websocket/write_pump.go diff --git a/internal/apps/openflare/zone/errs.go b/backend/OpenFlare/plugins/server/openflare/zone/errs.go similarity index 100% rename from internal/apps/openflare/zone/errs.go rename to backend/OpenFlare/plugins/server/openflare/zone/errs.go diff --git a/internal/apps/openflare/zone/legacy_import.go b/backend/OpenFlare/plugins/server/openflare/zone/legacy_import.go similarity index 99% rename from internal/apps/openflare/zone/legacy_import.go rename to backend/OpenFlare/plugins/server/openflare/zone/legacy_import.go index ed7c09f3..540b415a 100644 --- a/internal/apps/openflare/zone/legacy_import.go +++ b/backend/OpenFlare/plugins/server/openflare/zone/legacy_import.go @@ -12,7 +12,7 @@ import ( "strconv" "strings" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/routeidentity" + "Wavelet/OpenFlare/plugins/server/openflare/routeidentity" ) // ImportReport describes the idempotent legacy migration result. diff --git a/internal/apps/openflare/zone/legacy_import_test.go b/backend/OpenFlare/plugins/server/openflare/zone/legacy_import_test.go similarity index 98% rename from internal/apps/openflare/zone/legacy_import_test.go rename to backend/OpenFlare/plugins/server/openflare/zone/legacy_import_test.go index c15071d5..41b2a661 100644 --- a/internal/apps/openflare/zone/legacy_import_test.go +++ b/backend/OpenFlare/plugins/server/openflare/zone/legacy_import_test.go @@ -8,7 +8,8 @@ import ( "database/sql" "testing" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "github.com/glebarez/sqlite" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/internal/apps/openflare/zone/logics.go b/backend/OpenFlare/plugins/server/openflare/zone/logics.go similarity index 97% rename from internal/apps/openflare/zone/logics.go rename to backend/OpenFlare/plugins/server/openflare/zone/logics.go index 34b27517..d6d12258 100644 --- a/internal/apps/openflare/zone/logics.go +++ b/backend/OpenFlare/plugins/server/openflare/zone/logics.go @@ -10,10 +10,11 @@ import ( "strings" "time" - cf "github.com/Rain-kl/Wavelet/internal/apps/openflare/cloudflare" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/pkg/logger" + "Wavelet/OpenFlare/plugins/server/model" + cf "Wavelet/OpenFlare/plugins/server/openflare/cloudflare" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/pkg/logger" + "golang.org/x/net/publicsuffix" "gorm.io/gorm" ) diff --git a/internal/apps/openflare/zone/logics_test.go b/backend/OpenFlare/plugins/server/openflare/zone/logics_test.go similarity index 95% rename from internal/apps/openflare/zone/logics_test.go rename to backend/OpenFlare/plugins/server/openflare/zone/logics_test.go index 57b9e465..ab6de695 100644 --- a/internal/apps/openflare/zone/logics_test.go +++ b/backend/OpenFlare/plugins/server/openflare/zone/logics_test.go @@ -8,11 +8,12 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/testhelper" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/testhelper" + + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" "github.com/glebarez/sqlite" "github.com/stretchr/testify/require" "gorm.io/gorm" diff --git a/internal/apps/openflare/zone/routers.go b/backend/OpenFlare/plugins/server/openflare/zone/routers.go similarity index 98% rename from internal/apps/openflare/zone/routers.go rename to backend/OpenFlare/plugins/server/openflare/zone/routers.go index 4201ee36..c58e4136 100644 --- a/internal/apps/openflare/zone/routers.go +++ b/backend/OpenFlare/plugins/server/openflare/zone/routers.go @@ -7,8 +7,9 @@ import ( "errors" "net/http" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/OpenFlare/plugins/server/openflare/apiutil" + "Wavelet/pkg/response" + "github.com/gin-gonic/gin" "gorm.io/gorm" ) diff --git a/internal/apps/openflare/zone/stats.go b/backend/OpenFlare/plugins/server/openflare/zone/stats.go similarity index 98% rename from internal/apps/openflare/zone/stats.go rename to backend/OpenFlare/plugins/server/openflare/zone/stats.go index 002b0abe..1af0af41 100644 --- a/internal/apps/openflare/zone/stats.go +++ b/backend/OpenFlare/plugins/server/openflare/zone/stats.go @@ -9,8 +9,9 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "gorm.io/gorm" ) diff --git a/pkg/cap/cap.go b/backend/OpenFlare/plugins/server/pkg/cap/cap.go similarity index 100% rename from pkg/cap/cap.go rename to backend/OpenFlare/plugins/server/pkg/cap/cap.go diff --git a/pkg/cap/errs.go b/backend/OpenFlare/plugins/server/pkg/cap/errs.go similarity index 100% rename from pkg/cap/errs.go rename to backend/OpenFlare/plugins/server/pkg/cap/errs.go diff --git a/pkg/cap/prng.go b/backend/OpenFlare/plugins/server/pkg/cap/prng.go similarity index 100% rename from pkg/cap/prng.go rename to backend/OpenFlare/plugins/server/pkg/cap/prng.go diff --git a/pkg/cap/store.go b/backend/OpenFlare/plugins/server/pkg/cap/store.go similarity index 100% rename from pkg/cap/store.go rename to backend/OpenFlare/plugins/server/pkg/cap/store.go diff --git a/pkg/push/custom.go b/backend/OpenFlare/plugins/server/pkg/push/custom.go similarity index 98% rename from pkg/push/custom.go rename to backend/OpenFlare/plugins/server/pkg/push/custom.go index c8326792..954362eb 100644 --- a/pkg/push/custom.go +++ b/backend/OpenFlare/plugins/server/pkg/push/custom.go @@ -13,7 +13,7 @@ import ( "net/http" "strings" - "github.com/Rain-kl/Wavelet/pkg/httppool" + "Wavelet/pkg/httppool" ) func init() { diff --git a/pkg/push/custom_test.go b/backend/OpenFlare/plugins/server/pkg/push/custom_test.go similarity index 100% rename from pkg/push/custom_test.go rename to backend/OpenFlare/plugins/server/pkg/push/custom_test.go diff --git a/pkg/push/email.go b/backend/OpenFlare/plugins/server/pkg/push/email.go similarity index 98% rename from pkg/push/email.go rename to backend/OpenFlare/plugins/server/pkg/push/email.go index 42d86c08..b2bd6f04 100644 --- a/pkg/push/email.go +++ b/backend/OpenFlare/plugins/server/pkg/push/email.go @@ -11,7 +11,7 @@ import ( "net/smtp" "strings" - "github.com/Rain-kl/Wavelet/pkg/util" + "Wavelet/pkg/util" ) func init() { diff --git a/pkg/push/email_test.go b/backend/OpenFlare/plugins/server/pkg/push/email_test.go similarity index 100% rename from pkg/push/email_test.go rename to backend/OpenFlare/plugins/server/pkg/push/email_test.go diff --git a/pkg/push/lark.go b/backend/OpenFlare/plugins/server/pkg/push/lark.go similarity index 99% rename from pkg/push/lark.go rename to backend/OpenFlare/plugins/server/pkg/push/lark.go index a33b310f..deac06cb 100644 --- a/pkg/push/lark.go +++ b/backend/OpenFlare/plugins/server/pkg/push/lark.go @@ -17,7 +17,7 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/pkg/httppool" + "Wavelet/pkg/httppool" ) func init() { diff --git a/pkg/push/push.go b/backend/OpenFlare/plugins/server/pkg/push/push.go similarity index 100% rename from pkg/push/push.go rename to backend/OpenFlare/plugins/server/pkg/push/push.go diff --git a/pkg/push/telegram.go b/backend/OpenFlare/plugins/server/pkg/push/telegram.go similarity index 98% rename from pkg/push/telegram.go rename to backend/OpenFlare/plugins/server/pkg/push/telegram.go index 72a95070..6fda8eff 100644 --- a/pkg/push/telegram.go +++ b/backend/OpenFlare/plugins/server/pkg/push/telegram.go @@ -12,7 +12,7 @@ import ( "net/http" "strings" - "github.com/Rain-kl/Wavelet/pkg/httppool" + "Wavelet/pkg/httppool" ) func init() { diff --git a/pkg/push/telegram_test.go b/backend/OpenFlare/plugins/server/pkg/push/telegram_test.go similarity index 100% rename from pkg/push/telegram_test.go rename to backend/OpenFlare/plugins/server/pkg/push/telegram_test.go diff --git a/pkg/push/template.go b/backend/OpenFlare/plugins/server/pkg/push/template.go similarity index 100% rename from pkg/push/template.go rename to backend/OpenFlare/plugins/server/pkg/push/template.go diff --git a/pkg/push/template_test.go b/backend/OpenFlare/plugins/server/pkg/push/template_test.go similarity index 100% rename from pkg/push/template_test.go rename to backend/OpenFlare/plugins/server/pkg/push/template_test.go diff --git a/internal/platform/bootstrap/bootstrap.go b/backend/OpenFlare/plugins/server/platform/bootstrap/bootstrap.go similarity index 90% rename from internal/platform/bootstrap/bootstrap.go rename to backend/OpenFlare/plugins/server/platform/bootstrap/bootstrap.go index 259f5b75..946522c0 100644 --- a/internal/platform/bootstrap/bootstrap.go +++ b/backend/OpenFlare/plugins/server/platform/bootstrap/bootstrap.go @@ -12,18 +12,19 @@ import ( "log" "sync" - admin_push "github.com/Rain-kl/Wavelet/internal/apps/admin/push" - "github.com/Rain-kl/Wavelet/internal/apps/admin/push/custom_events" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/chwriter" - ofgeoip "github.com/Rain-kl/Wavelet/internal/apps/openflare/geoip" - "github.com/Rain-kl/Wavelet/internal/infra/config" - taskhandlers "github.com/Rain-kl/Wavelet/internal/infra/task/handlers" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/platform/lifecycle" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/repository/logstore" - "github.com/Rain-kl/Wavelet/pkg/cache/ram" - "github.com/Rain-kl/Wavelet/pkg/logger" + admin_push "Wavelet/OpenFlare/plugins/server/admin/push" + "Wavelet/OpenFlare/plugins/server/admin/push/custom_events" + "Wavelet/OpenFlare/plugins/server/infra/config" + taskhandlers "Wavelet/OpenFlare/plugins/server/infra/task/handlers" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/openflare/chwriter" + ofgeoip "Wavelet/OpenFlare/plugins/server/openflare/geoip" + "Wavelet/OpenFlare/plugins/server/platform/lifecycle" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/repository/logstore" + "Wavelet/pkg/cache/ram" + "Wavelet/pkg/logger" + "gorm.io/gorm" ) diff --git a/internal/platform/bootstrap/bootstrap_test.go b/backend/OpenFlare/plugins/server/platform/bootstrap/bootstrap_test.go similarity index 95% rename from internal/platform/bootstrap/bootstrap_test.go rename to backend/OpenFlare/plugins/server/platform/bootstrap/bootstrap_test.go index 05c1eb62..a0cc27a9 100644 --- a/internal/platform/bootstrap/bootstrap_test.go +++ b/backend/OpenFlare/plugins/server/platform/bootstrap/bootstrap_test.go @@ -8,13 +8,13 @@ import ( "strings" "testing" - admin_push "github.com/Rain-kl/Wavelet/internal/apps/admin/push" - "github.com/Rain-kl/Wavelet/internal/infra/config" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/repository/logstore" - "github.com/Rain-kl/Wavelet/internal/testhelper" + admin_push "Wavelet/OpenFlare/plugins/server/admin/push" + "Wavelet/OpenFlare/plugins/server/infra/config" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/repository/logstore" + "Wavelet/OpenFlare/plugins/server/testhelper" ) func TestInitSyncsPushEventsOnce(t *testing.T) { diff --git a/internal/platform/lifecycle/lifecycle.go b/backend/OpenFlare/plugins/server/platform/lifecycle/lifecycle.go similarity index 97% rename from internal/platform/lifecycle/lifecycle.go rename to backend/OpenFlare/plugins/server/platform/lifecycle/lifecycle.go index 333e30d4..f0e1296d 100644 --- a/internal/platform/lifecycle/lifecycle.go +++ b/backend/OpenFlare/plugins/server/platform/lifecycle/lifecycle.go @@ -9,7 +9,7 @@ import ( "log" "sync" - "github.com/Rain-kl/Wavelet/pkg/util" + "Wavelet/pkg/util" ) // ShutdownFunc defines the signature for a graceful shutdown callback. diff --git a/backend/OpenFlare/plugins/server/plugin.go b/backend/OpenFlare/plugins/server/plugin.go new file mode 100644 index 00000000..11b689bd --- /dev/null +++ b/backend/OpenFlare/plugins/server/plugin.go @@ -0,0 +1,37 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package server 装载 OpenFlare 控制面插件:站点/区域、Cloudflare 接入、Pages 部署、 +// WAF、节点与回源管理、边缘协议接口以及根级默认路由,全部经 ctx.Router() 声明, +// 由 driver_http 挂载到装配根提供的 gin engine 上。 +package server + +import ( + "Wavelet/OpenFlare/plugins/server/infra/config" + router_root "Wavelet/OpenFlare/plugins/server/router/root" + v1 "Wavelet/OpenFlare/plugins/server/router/v1" + "Wavelet/core" +) + +// Plugin 实现 core.Plugin,是 OpenFlare 控制面的装载入口。 +type Plugin struct{} + +// New 创建 server 插件。 +func New() *Plugin { return &Plugin{} } + +// Name 返回插件标识。 +func (p *Plugin) Name() string { return "server" } + +// Apply 声明控制面 HTTP 路由树。 +// +// 中间件(Recovery/CORS/session/otelgin/错误与日志)留在引擎层,由装配根的 +// router.BuildEngine 提供;内核暂无 engine 级中间件贡献点,见计划附录 A。 +// 任务、设置与迁移仍由 platform/bootstrap 显式装配,待后续迁入 Apply。 +func (p *Plugin) Apply(ctx *core.Context) error { + root := ctx.Router() + router_root.RegisterRootRoutes(root) + + api := root.Group(config.Config.App.APIPrefix) + v1.RegisterV1Routes(api.Group("/v1"), api) + return nil +} diff --git a/backend/OpenFlare/plugins/server/plugin_parity_test.go b/backend/OpenFlare/plugins/server/plugin_parity_test.go new file mode 100644 index 00000000..472d469b --- /dev/null +++ b/backend/OpenFlare/plugins/server/plugin_parity_test.go @@ -0,0 +1,121 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package server + +import ( + "context" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "testing" + + "Wavelet/OpenFlare/plugins/server/infra/config" + "Wavelet/core" + + "github.com/gin-gonic/gin" +) + +// baselineRoutesFile 是改造前遗留注册路径导出的 (方法 路径) 全集。 +const baselineRoutesFile = "docs/superpowers/specs/baseline/routes-engine.txt" + +// TestPluginRoutesMatchLegacyBaseline 保证 server 插件经 ctx.Router() 声明的路由 +// 与迁移前的 gin 路由表逐条一致:少一条即接口消失,多一条即路由漂移。 +func TestPluginRoutesMatchLegacyBaseline(t *testing.T) { + gin.SetMode(gin.TestMode) + if config.Config.App.APIPrefix == "" { + config.Config.App.APIPrefix = "/api" + } + + ctx := core.NewContext(context.Background()) + if err := New().Apply(ctx); err != nil { + t.Fatalf("Apply() error = %v", err) + } + + got := routeSet(ctx) + if out := os.Getenv("OF_DUMP_ROUTES"); out != "" { + if err := os.WriteFile(out, []byte(strings.Join(sortedKeys(got), "\n")+"\n"), 0o600); err != nil { + t.Fatalf("write route dump: %v", err) + } + } + + want := loadBaseline(t) + missing := make([]string, 0) + extra := make([]string, 0) + for k := range want { + if !got[k] { + missing = append(missing, k) + } + } + for k := range got { + if !want[k] { + extra = append(extra, k) + } + } + sort.Strings(missing) + sort.Strings(extra) + + if len(missing) > 0 || len(extra) > 0 { + t.Errorf("route table drifted: baseline=%d plugin=%d\nmissing (%d):\n %s\nunexpected (%d):\n %s", + len(want), len(got), len(missing), strings.Join(missing, "\n "), + len(extra), strings.Join(extra, "\n ")) + } +} + +func routeSet(ctx *core.Context) map[string]bool { + set := make(map[string]bool) + for _, rd := range ctx.Router().Routes() { + set[rd.Method+" "+rd.Path] = true + } + return set +} + +func sortedKeys(m map[string]bool) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func loadBaseline(t *testing.T) map[string]bool { + t.Helper() + path := locateFile(t, baselineRoutesFile) + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read baseline %s: %v", path, err) + } + set := make(map[string]bool) + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line != "" { + set[line] = true + } + } + if len(set) == 0 { + t.Fatalf("baseline %s is empty", path) + } + return set +} + +// locateFile 从测试文件所在目录向上查找,避免依赖测试运行深度。 +func locateFile(t *testing.T, rel string) string { + t.Helper() + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + dir := filepath.Dir(thisFile) + for range 8 { + candidate := filepath.Join(dir, rel) + if _, err := os.Stat(candidate); err == nil { + return candidate + } + dir = filepath.Join(dir, "..") + } + t.Fatalf("%s not found above %s", rel, filepath.Dir(thisFile)) + return "" +} diff --git a/internal/repository/access_token.go b/backend/OpenFlare/plugins/server/repository/access_token.go similarity index 95% rename from internal/repository/access_token.go rename to backend/OpenFlare/plugins/server/repository/access_token.go index ac5527dd..d83c26d6 100644 --- a/internal/repository/access_token.go +++ b/backend/OpenFlare/plugins/server/repository/access_token.go @@ -6,8 +6,8 @@ package repository import ( "context" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" ) // ListAccessTokensByUserID returns all access tokens for a user ordered by created_at desc. diff --git a/internal/repository/analytics/access_log.go b/backend/OpenFlare/plugins/server/repository/analytics/access_log.go similarity index 96% rename from internal/repository/analytics/access_log.go rename to backend/OpenFlare/plugins/server/repository/analytics/access_log.go index 9be348ec..eacaf175 100644 --- a/internal/repository/analytics/access_log.go +++ b/backend/OpenFlare/plugins/server/repository/analytics/access_log.go @@ -10,8 +10,8 @@ import ( "fmt" "time" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + analyticsmodel "Wavelet/OpenFlare/plugins/server/model/analytics" ) func userAccessLogConn() error { diff --git a/internal/repository/analytics/access_log_filter.go b/backend/OpenFlare/plugins/server/repository/analytics/access_log_filter.go similarity index 92% rename from internal/repository/analytics/access_log_filter.go rename to backend/OpenFlare/plugins/server/repository/analytics/access_log_filter.go index ca64d0b7..dbe3c5d8 100644 --- a/internal/repository/analytics/access_log_filter.go +++ b/backend/OpenFlare/plugins/server/repository/analytics/access_log_filter.go @@ -7,8 +7,8 @@ import ( "fmt" "strings" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" - "github.com/Rain-kl/Wavelet/pkg/util" + analyticsmodel "Wavelet/OpenFlare/plugins/server/model/analytics" + "Wavelet/pkg/util" ) const userAccessLogFilterClauseCapacity = 4 diff --git a/internal/repository/analytics/access_log_stats.go b/backend/OpenFlare/plugins/server/repository/analytics/access_log_stats.go similarity index 96% rename from internal/repository/analytics/access_log_stats.go rename to backend/OpenFlare/plugins/server/repository/analytics/access_log_stats.go index 6192e843..9def07c1 100644 --- a/internal/repository/analytics/access_log_stats.go +++ b/backend/OpenFlare/plugins/server/repository/analytics/access_log_stats.go @@ -9,8 +9,8 @@ import ( "sort" "time" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + analyticsmodel "Wavelet/OpenFlare/plugins/server/model/analytics" ) const hoursInDay = 24 diff --git a/internal/repository/analytics/access_log_test.go b/backend/OpenFlare/plugins/server/repository/analytics/access_log_test.go similarity index 98% rename from internal/repository/analytics/access_log_test.go rename to backend/OpenFlare/plugins/server/repository/analytics/access_log_test.go index 2da7c929..e74498ba 100644 --- a/internal/repository/analytics/access_log_test.go +++ b/backend/OpenFlare/plugins/server/repository/analytics/access_log_test.go @@ -9,10 +9,11 @@ import ( "testing" "time" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + analyticsmodel "Wavelet/OpenFlare/plugins/server/model/analytics" + "github.com/ClickHouse/clickhouse-go/v2/lib/column" "github.com/ClickHouse/clickhouse-go/v2/lib/driver" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/internal/repository/analytics/access_log_writer.go b/backend/OpenFlare/plugins/server/repository/analytics/access_log_writer.go similarity index 88% rename from internal/repository/analytics/access_log_writer.go rename to backend/OpenFlare/plugins/server/repository/analytics/access_log_writer.go index d79627ee..709f1121 100644 --- a/internal/repository/analytics/access_log_writer.go +++ b/backend/OpenFlare/plugins/server/repository/analytics/access_log_writer.go @@ -8,8 +8,8 @@ import ( "errors" "fmt" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + analyticsmodel "Wavelet/OpenFlare/plugins/server/model/analytics" ) // BatchInsert writes access logs to ClickHouse using the native batch API. diff --git a/internal/repository/analytics/browser.go b/backend/OpenFlare/plugins/server/repository/analytics/browser.go similarity index 100% rename from internal/repository/analytics/browser.go rename to backend/OpenFlare/plugins/server/repository/analytics/browser.go diff --git a/internal/repository/analytics/clickhouse_count.go b/backend/OpenFlare/plugins/server/repository/analytics/clickhouse_count.go similarity index 100% rename from internal/repository/analytics/clickhouse_count.go rename to backend/OpenFlare/plugins/server/repository/analytics/clickhouse_count.go diff --git a/internal/repository/analytics/clickhouse_count_test.go b/backend/OpenFlare/plugins/server/repository/analytics/clickhouse_count_test.go similarity index 100% rename from internal/repository/analytics/clickhouse_count_test.go rename to backend/OpenFlare/plugins/server/repository/analytics/clickhouse_count_test.go diff --git a/internal/repository/analytics/clickhouse_maintenance.go b/backend/OpenFlare/plugins/server/repository/analytics/clickhouse_maintenance.go similarity index 100% rename from internal/repository/analytics/clickhouse_maintenance.go rename to backend/OpenFlare/plugins/server/repository/analytics/clickhouse_maintenance.go diff --git a/internal/repository/analytics/clickhouse_maintenance_test.go b/backend/OpenFlare/plugins/server/repository/analytics/clickhouse_maintenance_test.go similarity index 100% rename from internal/repository/analytics/clickhouse_maintenance_test.go rename to backend/OpenFlare/plugins/server/repository/analytics/clickhouse_maintenance_test.go diff --git a/internal/repository/analytics/clickhouse_stats.go b/backend/OpenFlare/plugins/server/repository/analytics/clickhouse_stats.go similarity index 91% rename from internal/repository/analytics/clickhouse_stats.go rename to backend/OpenFlare/plugins/server/repository/analytics/clickhouse_stats.go index 7a4a71de..3e2e1889 100644 --- a/internal/repository/analytics/clickhouse_stats.go +++ b/backend/OpenFlare/plugins/server/repository/analytics/clickhouse_stats.go @@ -8,9 +8,9 @@ import ( "errors" "fmt" - "github.com/Rain-kl/Wavelet/internal/infra/config" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" + "Wavelet/OpenFlare/plugins/server/infra/config" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + analyticsmodel "Wavelet/OpenFlare/plugins/server/model/analytics" ) // ClickHouseOperationalStats summarizes ClickHouse merge/mutation pressure diff --git a/internal/repository/analytics/node_access_log.go b/backend/OpenFlare/plugins/server/repository/analytics/node_access_log.go similarity index 98% rename from internal/repository/analytics/node_access_log.go rename to backend/OpenFlare/plugins/server/repository/analytics/node_access_log.go index a8e78f6c..d90736c6 100644 --- a/internal/repository/analytics/node_access_log.go +++ b/backend/OpenFlare/plugins/server/repository/analytics/node_access_log.go @@ -10,9 +10,10 @@ import ( "strings" "time" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + analyticsmodel "Wavelet/OpenFlare/plugins/server/model/analytics" + "github.com/ClickHouse/clickhouse-go/v2/lib/driver" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" ) // NodeAccessLogRegionCount aggregates access log regions. diff --git a/internal/repository/analytics/node_access_log_delete.go b/backend/OpenFlare/plugins/server/repository/analytics/node_access_log_delete.go similarity index 100% rename from internal/repository/analytics/node_access_log_delete.go rename to backend/OpenFlare/plugins/server/repository/analytics/node_access_log_delete.go diff --git a/internal/repository/analytics/node_access_log_filter.go b/backend/OpenFlare/plugins/server/repository/analytics/node_access_log_filter.go similarity index 98% rename from internal/repository/analytics/node_access_log_filter.go rename to backend/OpenFlare/plugins/server/repository/analytics/node_access_log_filter.go index fc1f71c8..dc653870 100644 --- a/internal/repository/analytics/node_access_log_filter.go +++ b/backend/OpenFlare/plugins/server/repository/analytics/node_access_log_filter.go @@ -7,8 +7,8 @@ import ( "fmt" "strings" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" - "github.com/Rain-kl/Wavelet/pkg/util" + analyticsmodel "Wavelet/OpenFlare/plugins/server/model/analytics" + "Wavelet/pkg/util" ) const ( diff --git a/internal/repository/analytics/node_access_log_stats.go b/backend/OpenFlare/plugins/server/repository/analytics/node_access_log_stats.go similarity index 99% rename from internal/repository/analytics/node_access_log_stats.go rename to backend/OpenFlare/plugins/server/repository/analytics/node_access_log_stats.go index 8f6cceb7..0872d628 100644 --- a/internal/repository/analytics/node_access_log_stats.go +++ b/backend/OpenFlare/plugins/server/repository/analytics/node_access_log_stats.go @@ -9,7 +9,7 @@ import ( "strings" "time" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" + analyticsmodel "Wavelet/OpenFlare/plugins/server/model/analytics" ) // NodeAccessLogBucketAggregate is a folded bucket aggregate row. diff --git a/internal/repository/analytics/node_access_log_test.go b/backend/OpenFlare/plugins/server/repository/analytics/node_access_log_test.go similarity index 92% rename from internal/repository/analytics/node_access_log_test.go rename to backend/OpenFlare/plugins/server/repository/analytics/node_access_log_test.go index 5bc75c6f..7e125f59 100644 --- a/internal/repository/analytics/node_access_log_test.go +++ b/backend/OpenFlare/plugins/server/repository/analytics/node_access_log_test.go @@ -8,8 +8,9 @@ import ( "testing" "time" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + analyticsmodel "Wavelet/OpenFlare/plugins/server/model/analytics" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/internal/repository/analytics/node_access_log_writer.go b/backend/OpenFlare/plugins/server/repository/analytics/node_access_log_writer.go similarity index 88% rename from internal/repository/analytics/node_access_log_writer.go rename to backend/OpenFlare/plugins/server/repository/analytics/node_access_log_writer.go index 0558c109..6db50a1a 100644 --- a/internal/repository/analytics/node_access_log_writer.go +++ b/backend/OpenFlare/plugins/server/repository/analytics/node_access_log_writer.go @@ -10,9 +10,9 @@ import ( "strings" "time" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/infra/persistence/idgen" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/infra/persistence/idgen" + analyticsmodel "Wavelet/OpenFlare/plugins/server/model/analytics" ) // BatchInsertNodeAccessLogs writes node access logs to ClickHouse using the native batch API. diff --git a/internal/repository/analytics/node_observability.go b/backend/OpenFlare/plugins/server/repository/analytics/node_observability.go similarity index 99% rename from internal/repository/analytics/node_observability.go rename to backend/OpenFlare/plugins/server/repository/analytics/node_observability.go index 42668976..96ab9412 100644 --- a/internal/repository/analytics/node_observability.go +++ b/backend/OpenFlare/plugins/server/repository/analytics/node_observability.go @@ -10,9 +10,10 @@ import ( "slices" "time" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + analyticsmodel "Wavelet/OpenFlare/plugins/server/model/analytics" + "github.com/ClickHouse/clickhouse-go/v2/lib/driver" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" ) func observabilityConn() (driver.Conn, error) { diff --git a/internal/repository/analytics/node_observability_delete.go b/backend/OpenFlare/plugins/server/repository/analytics/node_observability_delete.go similarity index 100% rename from internal/repository/analytics/node_observability_delete.go rename to backend/OpenFlare/plugins/server/repository/analytics/node_observability_delete.go diff --git a/internal/repository/analytics/node_observability_filter.go b/backend/OpenFlare/plugins/server/repository/analytics/node_observability_filter.go similarity index 95% rename from internal/repository/analytics/node_observability_filter.go rename to backend/OpenFlare/plugins/server/repository/analytics/node_observability_filter.go index 881a0027..b2d40dd5 100644 --- a/internal/repository/analytics/node_observability_filter.go +++ b/backend/OpenFlare/plugins/server/repository/analytics/node_observability_filter.go @@ -6,7 +6,7 @@ package analytics import ( "strings" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" + analyticsmodel "Wavelet/OpenFlare/plugins/server/model/analytics" ) const nodeObservabilityFilterClauseCapacity = 3 diff --git a/internal/repository/analytics/node_observability_latest_test.go b/backend/OpenFlare/plugins/server/repository/analytics/node_observability_latest_test.go similarity index 98% rename from internal/repository/analytics/node_observability_latest_test.go rename to backend/OpenFlare/plugins/server/repository/analytics/node_observability_latest_test.go index f78e4ae2..00fd89ea 100644 --- a/internal/repository/analytics/node_observability_latest_test.go +++ b/backend/OpenFlare/plugins/server/repository/analytics/node_observability_latest_test.go @@ -10,8 +10,9 @@ import ( "testing" "time" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "github.com/ClickHouse/clickhouse-go/v2/lib/driver" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/internal/repository/analytics/node_observability_test.go b/backend/OpenFlare/plugins/server/repository/analytics/node_observability_test.go similarity index 91% rename from internal/repository/analytics/node_observability_test.go rename to backend/OpenFlare/plugins/server/repository/analytics/node_observability_test.go index bf8b7a21..388bc42c 100644 --- a/internal/repository/analytics/node_observability_test.go +++ b/backend/OpenFlare/plugins/server/repository/analytics/node_observability_test.go @@ -8,8 +8,9 @@ import ( "testing" "time" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + analyticsmodel "Wavelet/OpenFlare/plugins/server/model/analytics" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/internal/repository/analytics/node_observability_writer.go b/backend/OpenFlare/plugins/server/repository/analytics/node_observability_writer.go similarity index 97% rename from internal/repository/analytics/node_observability_writer.go rename to backend/OpenFlare/plugins/server/repository/analytics/node_observability_writer.go index 6deff8c4..a15e8253 100644 --- a/internal/repository/analytics/node_observability_writer.go +++ b/backend/OpenFlare/plugins/server/repository/analytics/node_observability_writer.go @@ -10,9 +10,9 @@ import ( "strings" "time" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/infra/persistence/idgen" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/infra/persistence/idgen" + analyticsmodel "Wavelet/OpenFlare/plugins/server/model/analytics" ) const edgeHealthStatusUnknown = "unknown" diff --git a/internal/repository/analytics/sql_fragments.go b/backend/OpenFlare/plugins/server/repository/analytics/sql_fragments.go similarity index 100% rename from internal/repository/analytics/sql_fragments.go rename to backend/OpenFlare/plugins/server/repository/analytics/sql_fragments.go diff --git a/internal/repository/auth_source.go b/backend/OpenFlare/plugins/server/repository/auth_source.go similarity index 98% rename from internal/repository/auth_source.go rename to backend/OpenFlare/plugins/server/repository/auth_source.go index f45cb146..46d73be6 100644 --- a/internal/repository/auth_source.go +++ b/backend/OpenFlare/plugins/server/repository/auth_source.go @@ -10,8 +10,8 @@ import ( "gorm.io/gorm" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" ) // GetAuthSources 获取所有认证源(已脱敏) diff --git a/internal/repository/auth_source_cache.go b/backend/OpenFlare/plugins/server/repository/auth_source_cache.go similarity index 97% rename from internal/repository/auth_source_cache.go rename to backend/OpenFlare/plugins/server/repository/auth_source_cache.go index 4ea564dd..72bee78d 100644 --- a/internal/repository/auth_source_cache.go +++ b/backend/OpenFlare/plugins/server/repository/auth_source_cache.go @@ -10,10 +10,10 @@ import ( "sync" "time" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/pkg/cache/ram" - "github.com/Rain-kl/Wavelet/pkg/util" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/pkg/cache/ram" + "Wavelet/pkg/util" ) const ( diff --git a/internal/repository/auth_source_cache_test.go b/backend/OpenFlare/plugins/server/repository/auth_source_cache_test.go similarity index 98% rename from internal/repository/auth_source_cache_test.go rename to backend/OpenFlare/plugins/server/repository/auth_source_cache_test.go index dcf92afd..73e95f99 100644 --- a/internal/repository/auth_source_cache_test.go +++ b/backend/OpenFlare/plugins/server/repository/auth_source_cache_test.go @@ -14,8 +14,8 @@ import ( "github.com/redis/go-redis/v9/maintnotifications" "gorm.io/gorm" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" ) func setupAuthSourceCacheTest(t *testing.T) (*gorm.DB, *miniredis.Miniredis, func()) { diff --git a/internal/repository/errs.go b/backend/OpenFlare/plugins/server/repository/errs.go similarity index 100% rename from internal/repository/errs.go rename to backend/OpenFlare/plugins/server/repository/errs.go diff --git a/internal/repository/logstore/cleanup.go b/backend/OpenFlare/plugins/server/repository/logstore/cleanup.go similarity index 98% rename from internal/repository/logstore/cleanup.go rename to backend/OpenFlare/plugins/server/repository/logstore/cleanup.go index 50c34e5d..b175c8bc 100644 --- a/internal/repository/logstore/cleanup.go +++ b/backend/OpenFlare/plugins/server/repository/logstore/cleanup.go @@ -11,8 +11,8 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/pkg/logger" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/pkg/logger" ) // CleanupSummary 汇总本次清理结果。 diff --git a/internal/repository/logstore/cleanup_test.go b/backend/OpenFlare/plugins/server/repository/logstore/cleanup_test.go similarity index 98% rename from internal/repository/logstore/cleanup_test.go rename to backend/OpenFlare/plugins/server/repository/logstore/cleanup_test.go index be80c49e..329fcdad 100644 --- a/internal/repository/logstore/cleanup_test.go +++ b/backend/OpenFlare/plugins/server/repository/logstore/cleanup_test.go @@ -15,9 +15,9 @@ import ( "gorm.io/gorm" "gorm.io/gorm/logger" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + analyticsmodel "Wavelet/OpenFlare/plugins/server/model/analytics" ) // cleanupTestModels 清理涉及的 5 张日志/可观测表。 diff --git a/internal/repository/logstore/clickhouse_store.go b/backend/OpenFlare/plugins/server/repository/logstore/clickhouse_store.go similarity index 99% rename from internal/repository/logstore/clickhouse_store.go rename to backend/OpenFlare/plugins/server/repository/logstore/clickhouse_store.go index e7ef4cca..76f1d443 100644 --- a/internal/repository/logstore/clickhouse_store.go +++ b/backend/OpenFlare/plugins/server/repository/logstore/clickhouse_store.go @@ -10,11 +10,12 @@ import ( "math" "time" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + analyticsmodel "Wavelet/OpenFlare/plugins/server/model/analytics" + analyticsrepo "Wavelet/OpenFlare/plugins/server/repository/analytics" + "github.com/ClickHouse/clickhouse-go/v2/lib/driver" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" - analyticsrepo "github.com/Rain-kl/Wavelet/internal/repository/analytics" ) // clickhouseLogStore 实现 AccessLogStore / ObservabilityStore / StatusStore, diff --git a/internal/repository/logstore/clickhouse_store_test.go b/backend/OpenFlare/plugins/server/repository/logstore/clickhouse_store_test.go similarity index 95% rename from internal/repository/logstore/clickhouse_store_test.go rename to backend/OpenFlare/plugins/server/repository/logstore/clickhouse_store_test.go index ea1cfab0..c0306097 100644 --- a/internal/repository/logstore/clickhouse_store_test.go +++ b/backend/OpenFlare/plugins/server/repository/logstore/clickhouse_store_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" ) // TestClickHouseHourlyDelegationRegression 验证 CH 后端小时级聚合读委托 analyticsrepo: diff --git a/internal/repository/logstore/dialect_postgres.go b/backend/OpenFlare/plugins/server/repository/logstore/dialect_postgres.go similarity index 100% rename from internal/repository/logstore/dialect_postgres.go rename to backend/OpenFlare/plugins/server/repository/logstore/dialect_postgres.go diff --git a/internal/repository/logstore/dialect_sqlite.go b/backend/OpenFlare/plugins/server/repository/logstore/dialect_sqlite.go similarity index 100% rename from internal/repository/logstore/dialect_sqlite.go rename to backend/OpenFlare/plugins/server/repository/logstore/dialect_sqlite.go diff --git a/internal/repository/logstore/hooks.go b/backend/OpenFlare/plugins/server/repository/logstore/hooks.go similarity index 95% rename from internal/repository/logstore/hooks.go rename to backend/OpenFlare/plugins/server/repository/logstore/hooks.go index 6ebfa0c1..e0316117 100644 --- a/internal/repository/logstore/hooks.go +++ b/backend/OpenFlare/plugins/server/repository/logstore/hooks.go @@ -6,7 +6,7 @@ package logstore import ( "sync" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" + analyticsmodel "Wavelet/OpenFlare/plugins/server/model/analytics" ) // AccessLogHooks 节点访问日志异步入队回调(由 chwriter 装配)。 diff --git a/backend/OpenFlare/plugins/server/repository/logstore/imports_test.go b/backend/OpenFlare/plugins/server/repository/logstore/imports_test.go new file mode 100644 index 00000000..cea1c6f5 --- /dev/null +++ b/backend/OpenFlare/plugins/server/repository/logstore/imports_test.go @@ -0,0 +1,115 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package logstore + +import ( + "os/exec" + "strings" + "testing" +) + +// serverPkg 是下游 server 插件的包路径前缀。 +const serverPkg = "Wavelet/OpenFlare/plugins/server" + +// forbiddenImports 业务域禁止直接触碰的底层日志实现。 +var forbiddenImports = []string{ + serverPkg + "/repository/analytics", +} + +// allowedAnalyticsDelegation 允许直接依赖 analytics 仓储的委托层: +// - repository:持久化门面,ListOpenFlareLatestMetricSnapshotsSince 的 +// CH 快速路径仍直连 analytics(LIMIT 1 BY node_id);小时级聚合读已改走 logstore; +// - repository/logstore:CH 后端实现按设计委托 analytics。 +// +// 除此之外,依赖闭包内任何包都禁止引入 analytics 仓储。 +var allowedAnalyticsDelegation = map[string]bool{ + serverPkg + "/repository": true, + serverPkg + "/repository/logstore": true, +} + +// allowedInfraPersistence 允许业务域包引入的 infra/persistence 子包。 +var allowedInfraPersistence = []string{ + serverPkg + "/infra/persistence/batchwriter", // batchwriter 统计类型 + serverPkg + "/infra/persistence/idgen", // 雪花 ID 生成(无日志依赖) +} + +// domainScopes 是 server 插件内的业务域包(等价于改造前的 internal/apps/...)。 +// 持久化与基础设施层(repository/infra/model/…)不受本门禁约束。 +var domainScopes = []string{ + "openflare", "admin", "oauth", "user", "upload", "cap", "config", "health", +} + +func TestDomainsMustNotImportLogBackendDirectly(t *testing.T) { + t.Chdir(moduleRoot(t)) + + wanted := make([]string, 0, len(domainScopes)) + patterns := make([]string, 0, len(domainScopes)) + for _, d := range domainScopes { + wanted = append(wanted, serverPkg+"/"+d) + patterns = append(patterns, "./OpenFlare/plugins/server/"+d+"/...") + } + + args := append([]string{"list", "-test", "-f", `{{.ImportPath}} {{join .Imports " "}}`}, patterns...) + //nolint:gosec // 固定参数,无外部输入 + out, err := exec.Command("go", args...).Output() + if err != nil { + t.Fatalf("go list: %v", err) + } + + scanned := 0 + for _, line := range strings.Split(string(out), "\n") { + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + pkg := fields[0] + if !hasAnyPrefix(pkg, wanted) { + continue + } + scanned++ + for _, imp := range fields[1:] { + for _, forbidden := range forbiddenImports { + if imp == forbidden && !allowedAnalyticsDelegation[pkg] { + t.Errorf("%s must not import forbidden log backend %s", pkg, forbidden) + } + } + if strings.HasPrefix(imp, serverPkg+"/infra/persistence/") { + allowed := false + for _, a := range allowedInfraPersistence { + if imp == a || strings.HasPrefix(imp, a+"/") { + allowed = true + break + } + } + if !allowed { + t.Errorf("%s must not import infra/persistence subpackage directly: %s", pkg, imp) + } + } + } + } + // 扫描到 0 个包说明包路径已漂移,门禁会静默失效——必须报错而非给绿灯。 + if scanned == 0 { + t.Fatalf("no domain package scanned; domainScopes is stale: %v", wanted) + } +} + +func hasAnyPrefix(s string, prefixes []string) bool { + for _, p := range prefixes { + if s == p || strings.HasPrefix(s, p+"/") { + return true + } + } + return false +} + +// moduleRoot 向 go 查询模块根目录,避免依赖测试文件所在深度的相对路径。 +func moduleRoot(t *testing.T) string { + t.Helper() + cmd := exec.Command("go", "list", "-m", "-f", "{{.Dir}}") + out, err := cmd.Output() + if err != nil { + t.Fatalf("resolve module root: %v", err) + } + return strings.TrimSpace(string(out)) +} diff --git a/internal/repository/logstore/insert_hooks_test.go b/backend/OpenFlare/plugins/server/repository/logstore/insert_hooks_test.go similarity index 94% rename from internal/repository/logstore/insert_hooks_test.go rename to backend/OpenFlare/plugins/server/repository/logstore/insert_hooks_test.go index 20257bb5..cb2efd05 100644 --- a/internal/repository/logstore/insert_hooks_test.go +++ b/backend/OpenFlare/plugins/server/repository/logstore/insert_hooks_test.go @@ -8,8 +8,8 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/model" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" + "Wavelet/OpenFlare/plugins/server/model" + analyticsmodel "Wavelet/OpenFlare/plugins/server/model/analytics" ) // TestGormAccessLogInsertBatchHooks 覆盖访问日志写入入口: diff --git a/internal/repository/logstore/logstore.go b/backend/OpenFlare/plugins/server/repository/logstore/logstore.go similarity index 98% rename from internal/repository/logstore/logstore.go rename to backend/OpenFlare/plugins/server/repository/logstore/logstore.go index ceac0ba3..5707ad28 100644 --- a/internal/repository/logstore/logstore.go +++ b/backend/OpenFlare/plugins/server/repository/logstore/logstore.go @@ -10,8 +10,8 @@ import ( "errors" "time" - "github.com/Rain-kl/Wavelet/internal/model" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" + "Wavelet/OpenFlare/plugins/server/model" + analyticsmodel "Wavelet/OpenFlare/plugins/server/model/analytics" ) // ErrMigrating 表示日志数据库正在迁移,当前禁止写入。 diff --git a/internal/repository/logstore/partition_cleanup.go b/backend/OpenFlare/plugins/server/repository/logstore/partition_cleanup.go similarity index 100% rename from internal/repository/logstore/partition_cleanup.go rename to backend/OpenFlare/plugins/server/repository/logstore/partition_cleanup.go diff --git a/internal/repository/logstore/postgres_partition_integration_test.go b/backend/OpenFlare/plugins/server/repository/logstore/postgres_partition_integration_test.go similarity index 99% rename from internal/repository/logstore/postgres_partition_integration_test.go rename to backend/OpenFlare/plugins/server/repository/logstore/postgres_partition_integration_test.go index 3d4cea32..baff20aa 100644 --- a/internal/repository/logstore/postgres_partition_integration_test.go +++ b/backend/OpenFlare/plugins/server/repository/logstore/postgres_partition_integration_test.go @@ -16,7 +16,7 @@ import ( "gorm.io/gorm" "gorm.io/gorm/logger" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" + analyticsmodel "Wavelet/OpenFlare/plugins/server/model/analytics" ) // TestEnsurePartitionsPostgresInsertAcrossMonths 需要 TEST_POSTGRES_DSN(未设置时跳过): diff --git a/internal/repository/logstore/postgres_store.go b/backend/OpenFlare/plugins/server/repository/logstore/postgres_store.go similarity index 99% rename from internal/repository/logstore/postgres_store.go rename to backend/OpenFlare/plugins/server/repository/logstore/postgres_store.go index 0b68ecf6..549ab2c5 100644 --- a/internal/repository/logstore/postgres_store.go +++ b/backend/OpenFlare/plugins/server/repository/logstore/postgres_store.go @@ -13,10 +13,11 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/infra/persistence/idgen" - "github.com/Rain-kl/Wavelet/internal/model" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" - "github.com/Rain-kl/Wavelet/pkg/util" + "Wavelet/OpenFlare/plugins/server/infra/persistence/idgen" + "Wavelet/OpenFlare/plugins/server/model" + analyticsmodel "Wavelet/OpenFlare/plugins/server/model/analytics" + "Wavelet/pkg/util" + "gorm.io/gorm" ) diff --git a/internal/repository/logstore/postgres_store_test.go b/backend/OpenFlare/plugins/server/repository/logstore/postgres_store_test.go similarity index 99% rename from internal/repository/logstore/postgres_store_test.go rename to backend/OpenFlare/plugins/server/repository/logstore/postgres_store_test.go index 5f07e405..1f56fe9b 100644 --- a/internal/repository/logstore/postgres_store_test.go +++ b/backend/OpenFlare/plugins/server/repository/logstore/postgres_store_test.go @@ -17,8 +17,8 @@ import ( "gorm.io/gorm" "gorm.io/gorm/logger" - "github.com/Rain-kl/Wavelet/internal/model" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" + "Wavelet/OpenFlare/plugins/server/model" + analyticsmodel "Wavelet/OpenFlare/plugins/server/model/analytics" ) func newTestGormStore(t *testing.T) *gormLogStore { diff --git a/internal/repository/logstore/provider.go b/backend/OpenFlare/plugins/server/repository/logstore/provider.go similarity index 96% rename from internal/repository/logstore/provider.go rename to backend/OpenFlare/plugins/server/repository/logstore/provider.go index 551a47a0..4cbeb956 100644 --- a/internal/repository/logstore/provider.go +++ b/backend/OpenFlare/plugins/server/repository/logstore/provider.go @@ -10,10 +10,10 @@ import ( "sync" "time" - "github.com/Rain-kl/Wavelet/internal/infra/config" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/pkg/logger" + "Wavelet/OpenFlare/plugins/server/infra/config" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/pkg/logger" ) // logDatabaseKey / logMigrationKey 对应 model.ConfigKeyLogDatabase / ConfigKeyLogDBMigration。 diff --git a/internal/repository/logstore/provider_test.go b/backend/OpenFlare/plugins/server/repository/logstore/provider_test.go similarity index 97% rename from internal/repository/logstore/provider_test.go rename to backend/OpenFlare/plugins/server/repository/logstore/provider_test.go index 056d4d75..8258ecd6 100644 --- a/internal/repository/logstore/provider_test.go +++ b/backend/OpenFlare/plugins/server/repository/logstore/provider_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" + analyticsmodel "Wavelet/OpenFlare/plugins/server/model/analytics" ) func TestMigratingReadsConfig(t *testing.T) { diff --git a/internal/repository/openflare_access_log.go b/backend/OpenFlare/plugins/server/repository/openflare_access_log.go similarity index 98% rename from internal/repository/openflare_access_log.go rename to backend/OpenFlare/plugins/server/repository/openflare_access_log.go index 0c6d177a..38ee3f90 100644 --- a/internal/repository/openflare_access_log.go +++ b/backend/OpenFlare/plugins/server/repository/openflare_access_log.go @@ -9,10 +9,10 @@ import ( "strings" "time" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" + analyticsmodel "Wavelet/OpenFlare/plugins/server/model/analytics" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository/logstore" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository/logstore" ) const ( diff --git a/internal/repository/openflare_access_log_test.go b/backend/OpenFlare/plugins/server/repository/openflare_access_log_test.go similarity index 95% rename from internal/repository/openflare_access_log_test.go rename to backend/OpenFlare/plugins/server/repository/openflare_access_log_test.go index f43fbd68..f9ac9b73 100644 --- a/internal/repository/openflare_access_log_test.go +++ b/backend/OpenFlare/plugins/server/repository/openflare_access_log_test.go @@ -14,10 +14,10 @@ import ( "gorm.io/gorm" "gorm.io/gorm/logger" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" - "github.com/Rain-kl/Wavelet/internal/repository/logstore" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + analyticsmodel "Wavelet/OpenFlare/plugins/server/model/analytics" + "Wavelet/OpenFlare/plugins/server/repository/logstore" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/internal/repository/openflare_acme_account.go b/backend/OpenFlare/plugins/server/repository/openflare_acme_account.go similarity index 93% rename from internal/repository/openflare_acme_account.go rename to backend/OpenFlare/plugins/server/repository/openflare_acme_account.go index 36533b2c..62ac32f8 100644 --- a/internal/repository/openflare_acme_account.go +++ b/backend/OpenFlare/plugins/server/repository/openflare_acme_account.go @@ -9,8 +9,8 @@ import ( "gorm.io/gorm" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" ) // GetAcmeAccountByID 按 ID 查询 ACME 账号。 diff --git a/internal/repository/openflare_apply_log.go b/backend/OpenFlare/plugins/server/repository/openflare_apply_log.go similarity index 97% rename from internal/repository/openflare_apply_log.go rename to backend/OpenFlare/plugins/server/repository/openflare_apply_log.go index a2691695..1815a4fb 100644 --- a/internal/repository/openflare_apply_log.go +++ b/backend/OpenFlare/plugins/server/repository/openflare_apply_log.go @@ -11,8 +11,8 @@ import ( "gorm.io/gorm" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" ) // ListOpenFlareApplyLogs returns apply logs ordered by id desc with optional pagination. diff --git a/internal/repository/openflare_apply_log_test.go b/backend/OpenFlare/plugins/server/repository/openflare_apply_log_test.go similarity index 95% rename from internal/repository/openflare_apply_log_test.go rename to backend/OpenFlare/plugins/server/repository/openflare_apply_log_test.go index 224716de..2bb495ae 100644 --- a/internal/repository/openflare_apply_log_test.go +++ b/backend/OpenFlare/plugins/server/repository/openflare_apply_log_test.go @@ -8,9 +8,10 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/model" + "Wavelet/OpenFlare/plugins/server/model" + + db "Wavelet/OpenFlare/plugins/server/infra/persistence" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" "github.com/glebarez/sqlite" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/internal/repository/openflare_cloudflare.go b/backend/OpenFlare/plugins/server/repository/openflare_cloudflare.go similarity index 98% rename from internal/repository/openflare_cloudflare.go rename to backend/OpenFlare/plugins/server/repository/openflare_cloudflare.go index 0f28b117..89e67b64 100644 --- a/internal/repository/openflare_cloudflare.go +++ b/backend/OpenFlare/plugins/server/repository/openflare_cloudflare.go @@ -7,8 +7,9 @@ import ( "context" "errors" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "gorm.io/gorm" ) diff --git a/internal/repository/openflare_cloudflare_test.go b/backend/OpenFlare/plugins/server/repository/openflare_cloudflare_test.go similarity index 96% rename from internal/repository/openflare_cloudflare_test.go rename to backend/OpenFlare/plugins/server/repository/openflare_cloudflare_test.go index 4d4102f9..bde4cbf4 100644 --- a/internal/repository/openflare_cloudflare_test.go +++ b/backend/OpenFlare/plugins/server/repository/openflare_cloudflare_test.go @@ -7,8 +7,9 @@ import ( "context" "testing" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "github.com/glebarez/sqlite" "gorm.io/gorm" ) diff --git a/internal/repository/openflare_config_version.go b/backend/OpenFlare/plugins/server/repository/openflare_config_version.go similarity index 97% rename from internal/repository/openflare_config_version.go rename to backend/OpenFlare/plugins/server/repository/openflare_config_version.go index 00d12a97..4ff1de53 100644 --- a/internal/repository/openflare_config_version.go +++ b/backend/OpenFlare/plugins/server/repository/openflare_config_version.go @@ -9,8 +9,8 @@ import ( "gorm.io/gorm" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" ) // ListConfigVersionSummaries returns config version summaries ordered by created_at desc. diff --git a/internal/repository/openflare_dns_account.go b/backend/OpenFlare/plugins/server/repository/openflare_dns_account.go similarity index 93% rename from internal/repository/openflare_dns_account.go rename to backend/OpenFlare/plugins/server/repository/openflare_dns_account.go index 69ac94fc..9a2fd358 100644 --- a/internal/repository/openflare_dns_account.go +++ b/backend/OpenFlare/plugins/server/repository/openflare_dns_account.go @@ -7,8 +7,8 @@ import ( "context" "errors" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" ) // ListDNSAccounts 列出全部 DNS 账号(授权信息不通过 JSON 暴露)。 diff --git a/internal/repository/openflare_node.go b/backend/OpenFlare/plugins/server/repository/openflare_node.go similarity index 97% rename from internal/repository/openflare_node.go rename to backend/OpenFlare/plugins/server/repository/openflare_node.go index 55bc954f..d6b192c7 100644 --- a/internal/repository/openflare_node.go +++ b/backend/OpenFlare/plugins/server/repository/openflare_node.go @@ -10,8 +10,8 @@ import ( "gorm.io/gorm" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" ) const ( diff --git a/internal/repository/openflare_observability.go b/backend/OpenFlare/plugins/server/repository/openflare_observability.go similarity index 98% rename from internal/repository/openflare_observability.go rename to backend/OpenFlare/plugins/server/repository/openflare_observability.go index c2d4606c..c081e0fc 100644 --- a/internal/repository/openflare_observability.go +++ b/backend/OpenFlare/plugins/server/repository/openflare_observability.go @@ -13,12 +13,12 @@ import ( "gorm.io/gorm" "gorm.io/gorm/clause" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" - analyticsrepo "github.com/Rain-kl/Wavelet/internal/repository/analytics" - "github.com/Rain-kl/Wavelet/internal/repository/logstore" - "github.com/Rain-kl/Wavelet/pkg/logger" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + analyticsmodel "Wavelet/OpenFlare/plugins/server/model/analytics" + analyticsrepo "Wavelet/OpenFlare/plugins/server/repository/analytics" + "Wavelet/OpenFlare/plugins/server/repository/logstore" + "Wavelet/pkg/logger" ) const ( diff --git a/internal/repository/openflare_origin.go b/backend/OpenFlare/plugins/server/repository/openflare_origin.go similarity index 97% rename from internal/repository/openflare_origin.go rename to backend/OpenFlare/plugins/server/repository/openflare_origin.go index 634f7ee5..dd4c3ef3 100644 --- a/internal/repository/openflare_origin.go +++ b/backend/OpenFlare/plugins/server/repository/openflare_origin.go @@ -8,8 +8,8 @@ import ( "gorm.io/gorm" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" ) // WithOriginTx runs fn inside a database transaction for origin multi-step work. diff --git a/internal/repository/openflare_pages.go b/backend/OpenFlare/plugins/server/repository/openflare_pages.go similarity index 96% rename from internal/repository/openflare_pages.go rename to backend/OpenFlare/plugins/server/repository/openflare_pages.go index 250bbef8..32eedbce 100644 --- a/internal/repository/openflare_pages.go +++ b/backend/OpenFlare/plugins/server/repository/openflare_pages.go @@ -6,8 +6,8 @@ package repository import ( "context" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" ) // HasPagesProjectsTable 判断 Pages 项目表是否已迁移。 diff --git a/internal/repository/openflare_pages_cleanup.go b/backend/OpenFlare/plugins/server/repository/openflare_pages_cleanup.go similarity index 94% rename from internal/repository/openflare_pages_cleanup.go rename to backend/OpenFlare/plugins/server/repository/openflare_pages_cleanup.go index 21e28851..32215105 100644 --- a/internal/repository/openflare_pages_cleanup.go +++ b/backend/OpenFlare/plugins/server/repository/openflare_pages_cleanup.go @@ -7,8 +7,8 @@ import ( "context" "errors" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" ) // ListPagesOrphanUploadCandidates returns at most 100 unreferenced, isolated diff --git a/internal/repository/openflare_pages_cleanup_test.go b/backend/OpenFlare/plugins/server/repository/openflare_pages_cleanup_test.go similarity index 98% rename from internal/repository/openflare_pages_cleanup_test.go rename to backend/OpenFlare/plugins/server/repository/openflare_pages_cleanup_test.go index 10309be4..8b6d7256 100644 --- a/internal/repository/openflare_pages_cleanup_test.go +++ b/backend/OpenFlare/plugins/server/repository/openflare_pages_cleanup_test.go @@ -9,9 +9,10 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/model" + "Wavelet/OpenFlare/plugins/server/model" + + db "Wavelet/OpenFlare/plugins/server/infra/persistence" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" "github.com/glebarez/sqlite" "gorm.io/gorm" ) diff --git a/internal/repository/openflare_pages_source.go b/backend/OpenFlare/plugins/server/repository/openflare_pages_source.go similarity index 99% rename from internal/repository/openflare_pages_source.go rename to backend/OpenFlare/plugins/server/repository/openflare_pages_source.go index 74ca2370..fca653f1 100644 --- a/internal/repository/openflare_pages_source.go +++ b/backend/OpenFlare/plugins/server/repository/openflare_pages_source.go @@ -10,8 +10,8 @@ import ( "gorm.io/gorm" "gorm.io/gorm/clause" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" ) const pagesRowLockStrength = "UPDATE" diff --git a/internal/repository/openflare_proxy_route.go b/backend/OpenFlare/plugins/server/repository/openflare_proxy_route.go similarity index 98% rename from internal/repository/openflare_proxy_route.go rename to backend/OpenFlare/plugins/server/repository/openflare_proxy_route.go index f9a6e833..7081eb73 100644 --- a/internal/repository/openflare_proxy_route.go +++ b/backend/OpenFlare/plugins/server/repository/openflare_proxy_route.go @@ -10,8 +10,8 @@ import ( "gorm.io/gorm" "gorm.io/gorm/clause" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" ) // Zone domain binding sentinel errors (shared by proxy_route and zone binding helpers). diff --git a/internal/repository/openflare_tls.go b/backend/OpenFlare/plugins/server/repository/openflare_tls.go similarity index 96% rename from internal/repository/openflare_tls.go rename to backend/OpenFlare/plugins/server/repository/openflare_tls.go index 318883f7..ee14db06 100644 --- a/internal/repository/openflare_tls.go +++ b/backend/OpenFlare/plugins/server/repository/openflare_tls.go @@ -7,8 +7,8 @@ import ( "context" "errors" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" ) // HasTLSProxyRoutesTable 判断代理规则表是否已迁移。 diff --git a/internal/repository/openflare_waf.go b/backend/OpenFlare/plugins/server/repository/openflare_waf.go similarity index 99% rename from internal/repository/openflare_waf.go rename to backend/OpenFlare/plugins/server/repository/openflare_waf.go index 54f2e1dc..d339dec2 100644 --- a/internal/repository/openflare_waf.go +++ b/backend/OpenFlare/plugins/server/repository/openflare_waf.go @@ -10,8 +10,8 @@ import ( "gorm.io/gorm" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" ) func wafDB(ctx context.Context) (*gorm.DB, error) { diff --git a/internal/repository/openflare_waf_bindings_test.go b/backend/OpenFlare/plugins/server/repository/openflare_waf_bindings_test.go similarity index 93% rename from internal/repository/openflare_waf_bindings_test.go rename to backend/OpenFlare/plugins/server/repository/openflare_waf_bindings_test.go index 8245d79a..9eeffefe 100644 --- a/internal/repository/openflare_waf_bindings_test.go +++ b/backend/OpenFlare/plugins/server/repository/openflare_waf_bindings_test.go @@ -7,9 +7,10 @@ import ( "context" "testing" - "github.com/Rain-kl/Wavelet/internal/model" + "Wavelet/OpenFlare/plugins/server/model" + + db "Wavelet/OpenFlare/plugins/server/infra/persistence" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" "github.com/glebarez/sqlite" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/internal/repository/openflare_waf_graph_test.go b/backend/OpenFlare/plugins/server/repository/openflare_waf_graph_test.go similarity index 98% rename from internal/repository/openflare_waf_graph_test.go rename to backend/OpenFlare/plugins/server/repository/openflare_waf_graph_test.go index a4cedbdd..b9702ea1 100644 --- a/internal/repository/openflare_waf_graph_test.go +++ b/backend/OpenFlare/plugins/server/repository/openflare_waf_graph_test.go @@ -12,9 +12,10 @@ import ( "testing" "testing/fstest" - "github.com/Rain-kl/Wavelet/internal/model" + "Wavelet/OpenFlare/plugins/server/model" + + db "Wavelet/OpenFlare/plugins/server/infra/persistence" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" "github.com/glebarez/sqlite" "github.com/pressly/goose/v3" "github.com/stretchr/testify/assert" diff --git a/internal/repository/openflare_zone.go b/backend/OpenFlare/plugins/server/repository/openflare_zone.go similarity index 98% rename from internal/repository/openflare_zone.go rename to backend/OpenFlare/plugins/server/repository/openflare_zone.go index b2d22f03..1f19c5d4 100644 --- a/internal/repository/openflare_zone.go +++ b/backend/OpenFlare/plugins/server/repository/openflare_zone.go @@ -9,8 +9,8 @@ import ( "gorm.io/gorm" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" ) // ListZones returns all zones ordered by domain ascending. diff --git a/internal/repository/openflare_zone_test.go b/backend/OpenFlare/plugins/server/repository/openflare_zone_test.go similarity index 96% rename from internal/repository/openflare_zone_test.go rename to backend/OpenFlare/plugins/server/repository/openflare_zone_test.go index 98800d11..1af2a13e 100644 --- a/internal/repository/openflare_zone_test.go +++ b/backend/OpenFlare/plugins/server/repository/openflare_zone_test.go @@ -7,9 +7,10 @@ import ( "context" "testing" - "github.com/Rain-kl/Wavelet/internal/model" + "Wavelet/OpenFlare/plugins/server/model" + + db "Wavelet/OpenFlare/plugins/server/infra/persistence" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" "github.com/glebarez/sqlite" "github.com/stretchr/testify/require" "gorm.io/gorm" diff --git a/internal/repository/push_channel.go b/backend/OpenFlare/plugins/server/repository/push_channel.go similarity index 96% rename from internal/repository/push_channel.go rename to backend/OpenFlare/plugins/server/repository/push_channel.go index 7afa800b..beb78098 100644 --- a/internal/repository/push_channel.go +++ b/backend/OpenFlare/plugins/server/repository/push_channel.go @@ -7,8 +7,8 @@ import ( "context" "time" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" ) const activePushChannelCacheTTL = 24 * time.Hour diff --git a/internal/repository/push_event.go b/backend/OpenFlare/plugins/server/repository/push_event.go similarity index 97% rename from internal/repository/push_event.go rename to backend/OpenFlare/plugins/server/repository/push_event.go index ad46ed0e..9d9252f3 100644 --- a/internal/repository/push_event.go +++ b/backend/OpenFlare/plugins/server/repository/push_event.go @@ -7,8 +7,8 @@ import ( "context" "time" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" ) const activePushEventCacheTTL = 24 * time.Hour diff --git a/internal/repository/push_history.go b/backend/OpenFlare/plugins/server/repository/push_history.go similarity index 95% rename from internal/repository/push_history.go rename to backend/OpenFlare/plugins/server/repository/push_history.go index 1ad29560..bf859af3 100644 --- a/internal/repository/push_history.go +++ b/backend/OpenFlare/plugins/server/repository/push_history.go @@ -9,8 +9,8 @@ import ( "gorm.io/gorm" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" ) // PushHistoryListFilter filters push history pagination queries. diff --git a/internal/repository/schedule.go b/backend/OpenFlare/plugins/server/repository/schedule.go similarity index 93% rename from internal/repository/schedule.go rename to backend/OpenFlare/plugins/server/repository/schedule.go index 7195928b..976a1caa 100644 --- a/internal/repository/schedule.go +++ b/backend/OpenFlare/plugins/server/repository/schedule.go @@ -6,8 +6,8 @@ package repository import ( "context" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" ) // CreateSchedule 创建定时任务 diff --git a/internal/repository/system_config.go b/backend/OpenFlare/plugins/server/repository/system_config.go similarity index 97% rename from internal/repository/system_config.go rename to backend/OpenFlare/plugins/server/repository/system_config.go index 4e816678..e6c0154e 100644 --- a/internal/repository/system_config.go +++ b/backend/OpenFlare/plugins/server/repository/system_config.go @@ -13,9 +13,9 @@ import ( "github.com/shopspring/decimal" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/pkg/cache/ram" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/pkg/cache/ram" ) const configTypeSystem = "system" diff --git a/internal/repository/system_config_admin.go b/backend/OpenFlare/plugins/server/repository/system_config_admin.go similarity index 96% rename from internal/repository/system_config_admin.go rename to backend/OpenFlare/plugins/server/repository/system_config_admin.go index cdc3f7a6..b0ada3d4 100644 --- a/internal/repository/system_config_admin.go +++ b/backend/OpenFlare/plugins/server/repository/system_config_admin.go @@ -7,8 +7,9 @@ import ( "context" "errors" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "gorm.io/gorm" ) diff --git a/internal/repository/system_config_cache.go b/backend/OpenFlare/plugins/server/repository/system_config_cache.go similarity index 97% rename from internal/repository/system_config_cache.go rename to backend/OpenFlare/plugins/server/repository/system_config_cache.go index 07f5db36..0d4dc81e 100644 --- a/internal/repository/system_config_cache.go +++ b/backend/OpenFlare/plugins/server/repository/system_config_cache.go @@ -12,9 +12,9 @@ import ( "gorm.io/gorm" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/pkg/cache/ram" - "github.com/Rain-kl/Wavelet/pkg/util" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/pkg/cache/ram" + "Wavelet/pkg/util" ) const ( diff --git a/internal/repository/system_config_test.go b/backend/OpenFlare/plugins/server/repository/system_config_test.go similarity index 97% rename from internal/repository/system_config_test.go rename to backend/OpenFlare/plugins/server/repository/system_config_test.go index 2172c324..0d9087a8 100644 --- a/internal/repository/system_config_test.go +++ b/backend/OpenFlare/plugins/server/repository/system_config_test.go @@ -9,9 +9,10 @@ import ( "testing" "time" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/pkg/cache/ram" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/pkg/cache/ram" + "github.com/alicebob/miniredis/v2" "github.com/glebarez/sqlite" "github.com/redis/go-redis/v9" diff --git a/internal/repository/task_execution.go b/backend/OpenFlare/plugins/server/repository/task_execution.go similarity index 97% rename from internal/repository/task_execution.go rename to backend/OpenFlare/plugins/server/repository/task_execution.go index 8c193677..4e66b592 100644 --- a/internal/repository/task_execution.go +++ b/backend/OpenFlare/plugins/server/repository/task_execution.go @@ -13,10 +13,10 @@ import ( "github.com/redis/go-redis/v9" "gorm.io/gorm" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/infra/persistence/idgen" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/pkg/util" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/infra/persistence/idgen" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/pkg/util" ) const ( diff --git a/internal/repository/task_execution_test.go b/backend/OpenFlare/plugins/server/repository/task_execution_test.go similarity index 99% rename from internal/repository/task_execution_test.go rename to backend/OpenFlare/plugins/server/repository/task_execution_test.go index f56da601..757fe7ba 100644 --- a/internal/repository/task_execution_test.go +++ b/backend/OpenFlare/plugins/server/repository/task_execution_test.go @@ -10,9 +10,10 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/model" + "Wavelet/OpenFlare/plugins/server/model" + + db "Wavelet/OpenFlare/plugins/server/infra/persistence" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" "github.com/alicebob/miniredis/v2" "github.com/glebarez/sqlite" "github.com/redis/go-redis/v9" diff --git a/internal/repository/template.go b/backend/OpenFlare/plugins/server/repository/template.go similarity index 93% rename from internal/repository/template.go rename to backend/OpenFlare/plugins/server/repository/template.go index c7998902..3a9779bd 100644 --- a/internal/repository/template.go +++ b/backend/OpenFlare/plugins/server/repository/template.go @@ -7,8 +7,9 @@ import ( "context" "errors" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "gorm.io/gorm" ) diff --git a/internal/repository/upload.go b/backend/OpenFlare/plugins/server/repository/upload.go similarity index 98% rename from internal/repository/upload.go rename to backend/OpenFlare/plugins/server/repository/upload.go index f366df7f..13a989a1 100644 --- a/internal/repository/upload.go +++ b/backend/OpenFlare/plugins/server/repository/upload.go @@ -11,9 +11,9 @@ import ( "gorm.io/gorm" "gorm.io/gorm/clause" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/pkg/util" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/pkg/util" ) // UploadListFilter filters paginated upload queries. diff --git a/internal/repository/upload_stat.go b/backend/OpenFlare/plugins/server/repository/upload_stat.go similarity index 96% rename from internal/repository/upload_stat.go rename to backend/OpenFlare/plugins/server/repository/upload_stat.go index 7e224e19..bce2dcc9 100644 --- a/internal/repository/upload_stat.go +++ b/backend/OpenFlare/plugins/server/repository/upload_stat.go @@ -10,8 +10,8 @@ import ( "gorm.io/gorm" "gorm.io/gorm/clause" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" ) // ListUploadStats returns all upload statistics rows. diff --git a/internal/repository/user.go b/backend/OpenFlare/plugins/server/repository/user.go similarity index 97% rename from internal/repository/user.go rename to backend/OpenFlare/plugins/server/repository/user.go index 6569acab..3d623c71 100644 --- a/internal/repository/user.go +++ b/backend/OpenFlare/plugins/server/repository/user.go @@ -8,10 +8,11 @@ import ( "errors" "time" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/infra/persistence/idgen" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/pkg/util" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/infra/persistence/idgen" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/pkg/util" + "gorm.io/gorm" ) diff --git a/internal/router/middlewares.go b/backend/OpenFlare/plugins/server/router/middlewares.go similarity index 91% rename from internal/router/middlewares.go rename to backend/OpenFlare/plugins/server/router/middlewares.go index ceb9e874..62d9d939 100644 --- a/internal/router/middlewares.go +++ b/backend/OpenFlare/plugins/server/router/middlewares.go @@ -12,12 +12,13 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/infra/config" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/shared/response" - "github.com/Rain-kl/Wavelet/pkg/logger" - otel_trace "github.com/Rain-kl/Wavelet/pkg/trace" + "Wavelet/OpenFlare/plugins/server/infra/config" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/pkg/logger" + "Wavelet/pkg/response" + otel_trace "Wavelet/pkg/trace" + "github.com/gin-gonic/gin" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" diff --git a/internal/router/middlewares_test.go b/backend/OpenFlare/plugins/server/router/middlewares_test.go similarity index 96% rename from internal/router/middlewares_test.go rename to backend/OpenFlare/plugins/server/router/middlewares_test.go index c801fd5e..851921fd 100644 --- a/internal/router/middlewares_test.go +++ b/backend/OpenFlare/plugins/server/router/middlewares_test.go @@ -9,9 +9,10 @@ import ( "net/http/httptest" "testing" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/testhelper" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/testhelper" + "github.com/gin-gonic/gin" ) diff --git a/internal/router/root/custom.go b/backend/OpenFlare/plugins/server/router/root/custom.go similarity index 79% rename from internal/router/root/custom.go rename to backend/OpenFlare/plugins/server/router/root/custom.go index c34b2222..2e1e486b 100644 --- a/internal/router/root/custom.go +++ b/backend/OpenFlare/plugins/server/router/root/custom.go @@ -5,10 +5,10 @@ package root import ( - "github.com/gin-gonic/gin" + "Wavelet/core" ) // RegisterCustomRootRoutes registers custom business routes that belong to the root path. -func RegisterCustomRootRoutes(_ *gin.Engine) { +func RegisterCustomRootRoutes(_ core.RouterExtension) { // Add custom root routes here } diff --git a/internal/router/root/default.go b/backend/OpenFlare/plugins/server/router/root/default.go similarity index 64% rename from internal/router/root/default.go rename to backend/OpenFlare/plugins/server/router/root/default.go index d19baddc..98c61c91 100644 --- a/internal/router/root/default.go +++ b/backend/OpenFlare/plugins/server/router/root/default.go @@ -4,18 +4,19 @@ package root import ( - _ "github.com/Rain-kl/Wavelet/docs" // Swagger documentation generation setup - publicconfig "github.com/Rain-kl/Wavelet/internal/apps/config" - "github.com/Rain-kl/Wavelet/internal/apps/health" - "github.com/Rain-kl/Wavelet/internal/apps/upload" - "github.com/Rain-kl/Wavelet/internal/infra/config" - "github.com/gin-gonic/gin" + publicconfig "Wavelet/OpenFlare/plugins/server/config" + "Wavelet/OpenFlare/plugins/server/health" + "Wavelet/OpenFlare/plugins/server/infra/config" + "Wavelet/OpenFlare/plugins/server/upload" + "Wavelet/core" + _ "Wavelet/docs" // Swagger documentation generation setup + swaggerFiles "github.com/swaggo/files" ginSwagger "github.com/swaggo/gin-swagger" ) // RegisterDefaultRootRoutes registers default routes that belong to the root path. -func RegisterDefaultRootRoutes(r *gin.Engine) { +func RegisterDefaultRootRoutes(r core.RouterExtension) { // 1. Serve files by ID r.GET("/f/:id", upload.ServeFileByID) diff --git a/internal/router/root/frontend.go b/backend/OpenFlare/plugins/server/router/root/frontend.go similarity index 100% rename from internal/router/root/frontend.go rename to backend/OpenFlare/plugins/server/router/root/frontend.go diff --git a/internal/router/root/frontend_static.go b/backend/OpenFlare/plugins/server/router/root/frontend_static.go similarity index 100% rename from internal/router/root/frontend_static.go rename to backend/OpenFlare/plugins/server/router/root/frontend_static.go diff --git a/internal/router/root/frontend_static_test.go b/backend/OpenFlare/plugins/server/router/root/frontend_static_test.go similarity index 100% rename from internal/router/root/frontend_static_test.go rename to backend/OpenFlare/plugins/server/router/root/frontend_static_test.go diff --git a/internal/router/root/root.go b/backend/OpenFlare/plugins/server/router/root/root.go similarity index 59% rename from internal/router/root/root.go rename to backend/OpenFlare/plugins/server/router/root/root.go index a6700b61..c34b8a64 100644 --- a/internal/router/root/root.go +++ b/backend/OpenFlare/plugins/server/router/root/root.go @@ -5,22 +5,24 @@ package root import ( + "Wavelet/core" + "github.com/gin-gonic/gin" ) // RegisterFrontend is a package-level variable overridden by frontend.go when built with embed_frontend. +// +// 前端 SPA 兜底只能表达在 gin engine 上(NoRoute + 静态资源),内核暂无 NoRoute +// 贡献点,因此由装配根的 router.BuildEngine 调用,不走插件的声明式路由。 var RegisterFrontend = func(_ *gin.Engine) { // No-op by default } -// RegisterRootRoutes registers custom business routes that belong to the root path. -func RegisterRootRoutes(r *gin.Engine) { +// RegisterRootRoutes registers the root-level API routes declared by the server plugin. +func RegisterRootRoutes(r core.RouterExtension) { // 1. Default root routes (/f/:id, /robots.txt, and /swagger/*any) RegisterDefaultRootRoutes(r) // 2. Register custom serving RegisterCustomRootRoutes(r) - - // 3. Register frontend serving - RegisterFrontend(r) } diff --git a/backend/OpenFlare/plugins/server/router/router.go b/backend/OpenFlare/plugins/server/router/router.go new file mode 100644 index 00000000..c03344b6 --- /dev/null +++ b/backend/OpenFlare/plugins/server/router/router.go @@ -0,0 +1,76 @@ +// Copyright 2025 linux.do +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package router + +import ( + "log" + "strconv" + + router_root "Wavelet/OpenFlare/plugins/server/router/root" + + "Wavelet/OpenFlare/plugins/server/infra/config" + "Wavelet/OpenFlare/plugins/server/oauth" + + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/redis" + "github.com/gin-gonic/gin" + "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" +) + +// BuildEngine 构造已挂全部中间件与路由的 gin engine,但不监听端口。 +// 独立出来是为了让 Cordis 装配根可以用 driver_http.WithEngine 复用同一台引擎 +// (内核目前没有 engine 级中间件贡献点,见 docs/superpowers/plans 附录 A)。 +func BuildEngine() *gin.Engine { + // 运行模式 + if config.Config.App.IsProduction() { + gin.SetMode(gin.ReleaseMode) + } + + // 初始化路由 + r := gin.New() + // Legacy OpenFlare list endpoints register both /resource and /resource/; disable auto slash redirects. + r.RedirectTrailingSlash = false + r.Use(gin.Recovery()) + r.Use(corsMiddleware()) + + cfg := config.Config.Redis + addrs := cfg.Addrs + sessionAddr := "localhost:6379" + if len(addrs) > 0 { + sessionAddr = addrs[0] + } + + sessionStore, err := redis.NewStoreWithDB( + cfg.MinIdleConn, + "tcp", + sessionAddr, + cfg.Username, + cfg.Password, + strconv.Itoa(cfg.DB), + []byte(config.Config.App.SessionSecret), + ) + if err != nil { + log.Fatalf("[API] init session store failed: %v\n", err) + } + + // 设置 Session Redis Key 前缀 + if cfg.KeyPrefix != "" { + if err := redis.SetKeyPrefix(sessionStore, cfg.KeyPrefix+"session:"); err != nil { + log.Printf("[API] set session key prefix failed: %v\n", err) + } + } + + sessionStore.Options(oauth.GetSessionOptions(config.Config.App.SessionAge)) + + r.Use(sessions.Sessions(config.Config.App.SessionCookieName, sessionStore)) + + // 补充中间件 + r.Use(otelgin.Middleware(config.Config.App.AppName), errorHandlerMiddleware(), loggerMiddleware()) + + // 前端 SPA 兜底(NoRoute + 静态资源)只有 gin engine 才能表达, + // 内核暂无 NoRoute 贡献点;其余路由全部由 server 插件经 ctx.Router() 声明。 + router_root.RegisterFrontend(r) + return r +} diff --git a/internal/router/v1/admin.go b/backend/OpenFlare/plugins/server/router/v1/admin.go similarity index 81% rename from internal/router/v1/admin.go rename to backend/OpenFlare/plugins/server/router/v1/admin.go index 043cb162..fa0c7b6e 100644 --- a/internal/router/v1/admin.go +++ b/backend/OpenFlare/plugins/server/router/v1/admin.go @@ -5,25 +5,25 @@ package v1 import ( - "github.com/Rain-kl/Wavelet/internal/apps/admin" - admin_auth_source "github.com/Rain-kl/Wavelet/internal/apps/admin/auth_source" - admin_cache "github.com/Rain-kl/Wavelet/internal/apps/admin/cache" - admin_db_manage "github.com/Rain-kl/Wavelet/internal/apps/admin/db_manage" - admin_logs "github.com/Rain-kl/Wavelet/internal/apps/admin/logs" - admin_push "github.com/Rain-kl/Wavelet/internal/apps/admin/push" - admin_status "github.com/Rain-kl/Wavelet/internal/apps/admin/status" - "github.com/Rain-kl/Wavelet/internal/apps/admin/system_config" - admin_task "github.com/Rain-kl/Wavelet/internal/apps/admin/task" - admin_template "github.com/Rain-kl/Wavelet/internal/apps/admin/template" - admin_updater "github.com/Rain-kl/Wavelet/internal/apps/admin/updater" - admin_user "github.com/Rain-kl/Wavelet/internal/apps/admin/user" - "github.com/Rain-kl/Wavelet/internal/apps/oauth" - "github.com/Rain-kl/Wavelet/internal/apps/upload" - "github.com/gin-gonic/gin" + "Wavelet/OpenFlare/plugins/server/admin" + admin_auth_source "Wavelet/OpenFlare/plugins/server/admin/auth_source" + admin_cache "Wavelet/OpenFlare/plugins/server/admin/cache" + admin_db_manage "Wavelet/OpenFlare/plugins/server/admin/db_manage" + admin_logs "Wavelet/OpenFlare/plugins/server/admin/logs" + admin_push "Wavelet/OpenFlare/plugins/server/admin/push" + admin_status "Wavelet/OpenFlare/plugins/server/admin/status" + "Wavelet/OpenFlare/plugins/server/admin/system_config" + admin_task "Wavelet/OpenFlare/plugins/server/admin/task" + admin_template "Wavelet/OpenFlare/plugins/server/admin/template" + admin_updater "Wavelet/OpenFlare/plugins/server/admin/updater" + admin_user "Wavelet/OpenFlare/plugins/server/admin/user" + "Wavelet/OpenFlare/plugins/server/oauth" + "Wavelet/OpenFlare/plugins/server/upload" + "Wavelet/core" ) // RegisterAdminRoutes registers all admin-related routes with sub-group categorizations. -func RegisterAdminRoutes(apiV1Router *gin.RouterGroup) { +func RegisterAdminRoutes(apiV1Router core.RouterExtension) { adminRouter := apiV1Router.Group("/admin") adminRouter.Use(oauth.LoginRequired(), admin.LoginAdminRequired()) { @@ -48,7 +48,7 @@ func RegisterAdminRoutes(apiV1Router *gin.RouterGroup) { } // registerAdminDiagnosticRoutes registers infrastructure, system status, caching, database, and logs diagnostics. -func registerAdminDiagnosticRoutes(adminRouter *gin.RouterGroup) { +func registerAdminDiagnosticRoutes(adminRouter core.RouterExtension) { // System status adminRouter.GET("/status", admin_status.GetSystemStatus) adminRouter.GET("/status/log-database", admin_status.GetLogDatabaseStatus) @@ -92,7 +92,7 @@ func registerAdminDiagnosticRoutes(adminRouter *gin.RouterGroup) { } // registerAdminIAMRoutes registers Identity & Access Management endpoints (Users & Auth Sources). -func registerAdminIAMRoutes(adminRouter *gin.RouterGroup) { +func registerAdminIAMRoutes(adminRouter core.RouterExtension) { // Users management users := adminRouter.Group("/users") { @@ -116,7 +116,7 @@ func registerAdminIAMRoutes(adminRouter *gin.RouterGroup) { } // registerAdminConfigRoutes registers system configurations and template settings. -func registerAdminConfigRoutes(adminRouter *gin.RouterGroup) { +func registerAdminConfigRoutes(adminRouter core.RouterExtension) { // System configs configs := adminRouter.Group("/system-configs") { @@ -147,7 +147,7 @@ func registerAdminConfigRoutes(adminRouter *gin.RouterGroup) { } // registerAdminStorageRoutes registers file and asset storage administration. -func registerAdminStorageRoutes(adminRouter *gin.RouterGroup) { +func registerAdminStorageRoutes(adminRouter core.RouterExtension) { uploads := adminRouter.Group("/uploads") { uploads.GET("", upload.ListFiles) @@ -160,7 +160,7 @@ func registerAdminStorageRoutes(adminRouter *gin.RouterGroup) { } // registerAdminTaskRoutes registers task orchestrations, execution logs and schedules. -func registerAdminTaskRoutes(adminRouter *gin.RouterGroup) { +func registerAdminTaskRoutes(adminRouter core.RouterExtension) { tasks := adminRouter.Group("/tasks") { // Task dispatch & metadata @@ -187,7 +187,7 @@ func registerAdminTaskRoutes(adminRouter *gin.RouterGroup) { } // registerAdminPushRoutes registers messaging channels and push notification events. -func registerAdminPushRoutes(adminRouter *gin.RouterGroup) { +func registerAdminPushRoutes(adminRouter core.RouterExtension) { push := adminRouter.Group("/push") { // Push Events diff --git a/internal/router/v1/custom.go b/backend/OpenFlare/plugins/server/router/v1/custom.go similarity index 100% rename from internal/router/v1/custom.go rename to backend/OpenFlare/plugins/server/router/v1/custom.go diff --git a/internal/router/v1/openflare/openflare.go b/backend/OpenFlare/plugins/server/router/v1/openflare/openflare.go similarity index 85% rename from internal/router/v1/openflare/openflare.go rename to backend/OpenFlare/plugins/server/router/v1/openflare/openflare.go index bba95eb7..6b6e2896 100644 --- a/internal/router/v1/openflare/openflare.go +++ b/backend/OpenFlare/plugins/server/router/v1/openflare/openflare.go @@ -6,10 +6,10 @@ // Agent/Relay/Tunnel protocol routes are mounted via RegisterRoutes under /api/v1. package openflare -import "github.com/gin-gonic/gin" +import "Wavelet/core" // RegisterRoutes mounts Agent/Relay/Tunnel protocol routes under the /api/v1 group. -func RegisterRoutes(apiV1Router *gin.RouterGroup) { +func RegisterRoutes(apiV1Router core.RouterExtension) { registerAgentRoutes(apiV1Router) registerRelayRoutes(apiV1Router) registerTunnelRoutes(apiV1Router) diff --git a/internal/router/v1/openflare/register_agent.go b/backend/OpenFlare/plugins/server/router/v1/openflare/register_agent.go similarity index 88% rename from internal/router/v1/openflare/register_agent.go rename to backend/OpenFlare/plugins/server/router/v1/openflare/register_agent.go index 28198836..18a7d9bd 100644 --- a/internal/router/v1/openflare/register_agent.go +++ b/backend/OpenFlare/plugins/server/router/v1/openflare/register_agent.go @@ -4,11 +4,11 @@ package openflare import ( - "github.com/Rain-kl/Wavelet/internal/apps/openflare/agent" - "github.com/gin-gonic/gin" + "Wavelet/OpenFlare/plugins/server/openflare/agent" + "Wavelet/core" ) -func registerAgentRoutes(apiV1Router *gin.RouterGroup) { +func registerAgentRoutes(apiV1Router core.RouterExtension) { agentRoute := apiV1Router.Group("/agent") { discoveryRoute := agentRoute.Group("/") diff --git a/internal/router/v1/openflare/register_apply_log.go b/backend/OpenFlare/plugins/server/router/v1/openflare/register_apply_log.go similarity index 61% rename from internal/router/v1/openflare/register_apply_log.go rename to backend/OpenFlare/plugins/server/router/v1/openflare/register_apply_log.go index 4356f9ee..e44c011d 100644 --- a/internal/router/v1/openflare/register_apply_log.go +++ b/backend/OpenFlare/plugins/server/router/v1/openflare/register_apply_log.go @@ -4,12 +4,12 @@ package openflare import ( - "github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/apply_log" - "github.com/gin-gonic/gin" + "Wavelet/OpenFlare/plugins/server/openflare/apiutil" + "Wavelet/OpenFlare/plugins/server/openflare/apply_log" + "Wavelet/core" ) -func registerApplyLogRoutes(apiGroup *gin.RouterGroup) { +func registerApplyLogRoutes(apiGroup core.RouterExtension) { applyLogRoute := apiGroup.Group("/apply-logs") applyLogRoute.Use(apiutil.AdminMiddlewares()...) { diff --git a/internal/router/v1/openflare/register_cloudflare.go b/backend/OpenFlare/plugins/server/router/v1/openflare/register_cloudflare.go similarity index 85% rename from internal/router/v1/openflare/register_cloudflare.go rename to backend/OpenFlare/plugins/server/router/v1/openflare/register_cloudflare.go index 8cf64b6c..68f9e937 100644 --- a/internal/router/v1/openflare/register_cloudflare.go +++ b/backend/OpenFlare/plugins/server/router/v1/openflare/register_cloudflare.go @@ -4,12 +4,12 @@ package openflare import ( - "github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil" - cf "github.com/Rain-kl/Wavelet/internal/apps/openflare/cloudflare" - "github.com/gin-gonic/gin" + "Wavelet/OpenFlare/plugins/server/openflare/apiutil" + cf "Wavelet/OpenFlare/plugins/server/openflare/cloudflare" + "Wavelet/core" ) -func registerCloudflareRoutes(apiGroup *gin.RouterGroup) { +func registerCloudflareRoutes(apiGroup core.RouterExtension) { route := apiGroup.Group("/cloudflare") route.Use(apiutil.AdminMiddlewares()...) route.GET("/connection", cf.GetConnectionHandler) diff --git a/internal/router/v1/openflare/register_config_version.go b/backend/OpenFlare/plugins/server/router/v1/openflare/register_config_version.go similarity index 79% rename from internal/router/v1/openflare/register_config_version.go rename to backend/OpenFlare/plugins/server/router/v1/openflare/register_config_version.go index e8e56432..aedb1687 100644 --- a/internal/router/v1/openflare/register_config_version.go +++ b/backend/OpenFlare/plugins/server/router/v1/openflare/register_config_version.go @@ -4,12 +4,12 @@ package openflare import ( - "github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/config_version" - "github.com/gin-gonic/gin" + "Wavelet/OpenFlare/plugins/server/openflare/apiutil" + "Wavelet/OpenFlare/plugins/server/openflare/config_version" + "Wavelet/core" ) -func registerConfigVersionRoutes(apiGroup *gin.RouterGroup) { +func registerConfigVersionRoutes(apiGroup core.RouterExtension) { configVersionGroup := apiGroup.Group("/config-versions") configVersionGroup.Use(apiutil.AdminMiddlewares()...) { diff --git a/internal/router/v1/openflare/register_dashboard.go b/backend/OpenFlare/plugins/server/router/v1/openflare/register_dashboard.go similarity index 56% rename from internal/router/v1/openflare/register_dashboard.go rename to backend/OpenFlare/plugins/server/router/v1/openflare/register_dashboard.go index 780f11a1..6648ad97 100644 --- a/internal/router/v1/openflare/register_dashboard.go +++ b/backend/OpenFlare/plugins/server/router/v1/openflare/register_dashboard.go @@ -4,12 +4,12 @@ package openflare import ( - "github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/dashboard" - "github.com/gin-gonic/gin" + "Wavelet/OpenFlare/plugins/server/openflare/apiutil" + "Wavelet/OpenFlare/plugins/server/openflare/dashboard" + "Wavelet/core" ) -func registerDashboardRoutes(apiGroup *gin.RouterGroup) { +func registerDashboardRoutes(apiGroup core.RouterExtension) { dashboardRoute := apiGroup.Group("/dashboard") dashboardRoute.Use(apiutil.AdminMiddlewares()...) { diff --git a/internal/router/v1/openflare/register_node.go b/backend/OpenFlare/plugins/server/router/v1/openflare/register_node.go similarity index 83% rename from internal/router/v1/openflare/register_node.go rename to backend/OpenFlare/plugins/server/router/v1/openflare/register_node.go index 9fa12189..cd301e49 100644 --- a/internal/router/v1/openflare/register_node.go +++ b/backend/OpenFlare/plugins/server/router/v1/openflare/register_node.go @@ -4,12 +4,12 @@ package openflare import ( - "github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/node" - "github.com/gin-gonic/gin" + "Wavelet/OpenFlare/plugins/server/openflare/apiutil" + "Wavelet/OpenFlare/plugins/server/openflare/node" + "Wavelet/core" ) -func registerNodeRoutes(apiGroup *gin.RouterGroup) { +func registerNodeRoutes(apiGroup core.RouterExtension) { nodeRoute := apiGroup.Group("/nodes") nodeRoute.Use(apiutil.AdminMiddlewares()...) { diff --git a/internal/router/v1/openflare/register_observability.go b/backend/OpenFlare/plugins/server/router/v1/openflare/register_observability.go similarity index 79% rename from internal/router/v1/openflare/register_observability.go rename to backend/OpenFlare/plugins/server/router/v1/openflare/register_observability.go index ba47d318..6c5d417b 100644 --- a/internal/router/v1/openflare/register_observability.go +++ b/backend/OpenFlare/plugins/server/router/v1/openflare/register_observability.go @@ -4,12 +4,12 @@ package openflare import ( - "github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/observability" - "github.com/gin-gonic/gin" + "Wavelet/OpenFlare/plugins/server/openflare/apiutil" + "Wavelet/OpenFlare/plugins/server/openflare/observability" + "Wavelet/core" ) -func registerObservabilityRoutes(apiGroup *gin.RouterGroup) { +func registerObservabilityRoutes(apiGroup core.RouterExtension) { accessLogRoute := apiGroup.Group("/access-logs") accessLogRoute.Use(apiutil.AdminMiddlewares()...) { diff --git a/internal/router/v1/openflare/register_option.go b/backend/OpenFlare/plugins/server/router/v1/openflare/register_option.go similarity index 76% rename from internal/router/v1/openflare/register_option.go rename to backend/OpenFlare/plugins/server/router/v1/openflare/register_option.go index c86f3e9f..7c0ac37b 100644 --- a/internal/router/v1/openflare/register_option.go +++ b/backend/OpenFlare/plugins/server/router/v1/openflare/register_option.go @@ -4,12 +4,12 @@ package openflare import ( - "github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/option" - "github.com/gin-gonic/gin" + "Wavelet/OpenFlare/plugins/server/openflare/apiutil" + "Wavelet/OpenFlare/plugins/server/openflare/option" + "Wavelet/core" ) -func registerOptionRoutes(apiGroup *gin.RouterGroup) { +func registerOptionRoutes(apiGroup core.RouterExtension) { apiGroup.GET("/status", option.GetStatusHandler) optionRoute := apiGroup.Group("/option") diff --git a/internal/router/v1/openflare/register_origin.go b/backend/OpenFlare/plugins/server/router/v1/openflare/register_origin.go similarity index 71% rename from internal/router/v1/openflare/register_origin.go rename to backend/OpenFlare/plugins/server/router/v1/openflare/register_origin.go index ec9aacba..454f62af 100644 --- a/internal/router/v1/openflare/register_origin.go +++ b/backend/OpenFlare/plugins/server/router/v1/openflare/register_origin.go @@ -4,12 +4,12 @@ package openflare import ( - "github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/origin" - "github.com/gin-gonic/gin" + "Wavelet/OpenFlare/plugins/server/openflare/apiutil" + "Wavelet/OpenFlare/plugins/server/openflare/origin" + "Wavelet/core" ) -func registerOriginRoutes(apiGroup *gin.RouterGroup) { +func registerOriginRoutes(apiGroup core.RouterExtension) { originRoute := apiGroup.Group("/origins") originRoute.Use(apiutil.AdminMiddlewares()...) { diff --git a/internal/router/v1/openflare/register_pages.go b/backend/OpenFlare/plugins/server/router/v1/openflare/register_pages.go similarity index 86% rename from internal/router/v1/openflare/register_pages.go rename to backend/OpenFlare/plugins/server/router/v1/openflare/register_pages.go index 89ba3b7c..f10112b0 100644 --- a/internal/router/v1/openflare/register_pages.go +++ b/backend/OpenFlare/plugins/server/router/v1/openflare/register_pages.go @@ -4,12 +4,12 @@ package openflare import ( - "github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/pages" - "github.com/gin-gonic/gin" + "Wavelet/OpenFlare/plugins/server/openflare/apiutil" + "Wavelet/OpenFlare/plugins/server/openflare/pages" + "Wavelet/core" ) -func registerPagesRoutes(apiGroup *gin.RouterGroup) { +func registerPagesRoutes(apiGroup core.RouterExtension) { pagesRoute := apiGroup.Group("/pages") pagesRoute.Use(apiutil.AdminMiddlewares()...) { diff --git a/internal/router/v1/openflare/register_proxy_route.go b/backend/OpenFlare/plugins/server/router/v1/openflare/register_proxy_route.go similarity index 73% rename from internal/router/v1/openflare/register_proxy_route.go rename to backend/OpenFlare/plugins/server/router/v1/openflare/register_proxy_route.go index 5f67a32f..9e6e7623 100644 --- a/internal/router/v1/openflare/register_proxy_route.go +++ b/backend/OpenFlare/plugins/server/router/v1/openflare/register_proxy_route.go @@ -4,12 +4,12 @@ package openflare import ( - "github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/proxy_route" - "github.com/gin-gonic/gin" + "Wavelet/OpenFlare/plugins/server/openflare/apiutil" + "Wavelet/OpenFlare/plugins/server/openflare/proxy_route" + "Wavelet/core" ) -func registerProxyRouteRoutes(apiGroup *gin.RouterGroup) { +func registerProxyRouteRoutes(apiGroup core.RouterExtension) { proxyRouteGroup := apiGroup.Group("/proxy-routes") proxyRouteGroup.Use(apiutil.AdminMiddlewares()...) { diff --git a/internal/router/v1/openflare/register_relay_flared.go b/backend/OpenFlare/plugins/server/router/v1/openflare/register_relay_flared.go similarity index 68% rename from internal/router/v1/openflare/register_relay_flared.go rename to backend/OpenFlare/plugins/server/router/v1/openflare/register_relay_flared.go index f45a9834..383afb8f 100644 --- a/internal/router/v1/openflare/register_relay_flared.go +++ b/backend/OpenFlare/plugins/server/router/v1/openflare/register_relay_flared.go @@ -4,12 +4,12 @@ package openflare import ( - "github.com/Rain-kl/Wavelet/internal/apps/openflare/flared" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/relay" - "github.com/gin-gonic/gin" + "Wavelet/OpenFlare/plugins/server/openflare/flared" + "Wavelet/OpenFlare/plugins/server/openflare/relay" + "Wavelet/core" ) -func registerRelayRoutes(apiV1Router *gin.RouterGroup) { +func registerRelayRoutes(apiV1Router core.RouterExtension) { relayRoute := apiV1Router.Group("/relay") relayRoute.Use(relay.Auth()) { @@ -18,7 +18,7 @@ func registerRelayRoutes(apiV1Router *gin.RouterGroup) { } } -func registerTunnelRoutes(apiV1Router *gin.RouterGroup) { +func registerTunnelRoutes(apiV1Router core.RouterExtension) { tunnelRoute := apiV1Router.Group("/tunnel") tunnelRoute.Use(flared.TunnelAuth()) { diff --git a/internal/router/v1/openflare/register_tls.go b/backend/OpenFlare/plugins/server/router/v1/openflare/register_tls.go similarity index 89% rename from internal/router/v1/openflare/register_tls.go rename to backend/OpenFlare/plugins/server/router/v1/openflare/register_tls.go index 59417297..f9605e38 100644 --- a/internal/router/v1/openflare/register_tls.go +++ b/backend/OpenFlare/plugins/server/router/v1/openflare/register_tls.go @@ -4,12 +4,12 @@ package openflare import ( - "github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/tls" - "github.com/gin-gonic/gin" + "Wavelet/OpenFlare/plugins/server/openflare/apiutil" + "Wavelet/OpenFlare/plugins/server/openflare/tls" + "Wavelet/core" ) -func registerTLSRoutes(apiGroup *gin.RouterGroup) { +func registerTLSRoutes(apiGroup core.RouterExtension) { tlsCertificateRoute := apiGroup.Group("/tls-certificates") tlsCertificateRoute.Use(apiutil.AdminMiddlewares()...) { diff --git a/internal/router/v1/openflare/register_waf.go b/backend/OpenFlare/plugins/server/router/v1/openflare/register_waf.go similarity index 85% rename from internal/router/v1/openflare/register_waf.go rename to backend/OpenFlare/plugins/server/router/v1/openflare/register_waf.go index 5340eaea..7af19c6e 100644 --- a/internal/router/v1/openflare/register_waf.go +++ b/backend/OpenFlare/plugins/server/router/v1/openflare/register_waf.go @@ -4,12 +4,12 @@ package openflare import ( - "github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/waf" - "github.com/gin-gonic/gin" + "Wavelet/OpenFlare/plugins/server/openflare/apiutil" + "Wavelet/OpenFlare/plugins/server/openflare/waf" + "Wavelet/core" ) -func registerWAFRoutes(apiGroup *gin.RouterGroup) { +func registerWAFRoutes(apiGroup core.RouterExtension) { wafRoute := apiGroup.Group("/waf") wafRoute.Use(apiutil.AdminMiddlewares()...) { diff --git a/internal/router/v1/openflare/register_zone.go b/backend/OpenFlare/plugins/server/router/v1/openflare/register_zone.go similarity index 78% rename from internal/router/v1/openflare/register_zone.go rename to backend/OpenFlare/plugins/server/router/v1/openflare/register_zone.go index afc992ef..bbf69ef3 100644 --- a/internal/router/v1/openflare/register_zone.go +++ b/backend/OpenFlare/plugins/server/router/v1/openflare/register_zone.go @@ -4,12 +4,12 @@ package openflare import ( - "github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil" - "github.com/Rain-kl/Wavelet/internal/apps/openflare/zone" - "github.com/gin-gonic/gin" + "Wavelet/OpenFlare/plugins/server/openflare/apiutil" + "Wavelet/OpenFlare/plugins/server/openflare/zone" + "Wavelet/core" ) -func registerZoneRoutes(apiGroup *gin.RouterGroup) { +func registerZoneRoutes(apiGroup core.RouterExtension) { zoneGroup := apiGroup.Group("/zones") zoneGroup.Use(apiutil.AdminMiddlewares()...) apiutil.RegisterCollection(zoneGroup, "GET", zone.ListHandler) diff --git a/internal/router/v1/openflare/v1.go b/backend/OpenFlare/plugins/server/router/v1/openflare/v1.go similarity index 88% rename from internal/router/v1/openflare/v1.go rename to backend/OpenFlare/plugins/server/router/v1/openflare/v1.go index 1b516196..4f3c38d4 100644 --- a/internal/router/v1/openflare/v1.go +++ b/backend/OpenFlare/plugins/server/router/v1/openflare/v1.go @@ -3,13 +3,13 @@ package openflare -import "github.com/gin-gonic/gin" +import "Wavelet/core" // V1BasePath is the OpenFlare console API prefix under /api/v1. const V1BasePath = "/api/v1/d" // RegisterV1Routes mounts OpenFlare management console APIs under /api/v1/d. -func RegisterV1Routes(apiV1Router *gin.RouterGroup) { +func RegisterV1Routes(apiV1Router core.RouterExtension) { group := apiV1Router.Group("/d") registerOptionRoutes(group) registerOriginRoutes(group) diff --git a/internal/router/v1/user.go b/backend/OpenFlare/plugins/server/router/v1/user.go similarity index 80% rename from internal/router/v1/user.go rename to backend/OpenFlare/plugins/server/router/v1/user.go index 662a8b23..9d780ce0 100644 --- a/internal/router/v1/user.go +++ b/backend/OpenFlare/plugins/server/router/v1/user.go @@ -5,16 +5,16 @@ package v1 import ( - capApp "github.com/Rain-kl/Wavelet/internal/apps/cap" - publicconfig "github.com/Rain-kl/Wavelet/internal/apps/config" - "github.com/Rain-kl/Wavelet/internal/apps/oauth" - "github.com/Rain-kl/Wavelet/internal/apps/upload" - "github.com/Rain-kl/Wavelet/internal/apps/user" - "github.com/gin-gonic/gin" + capApp "Wavelet/OpenFlare/plugins/server/cap" + publicconfig "Wavelet/OpenFlare/plugins/server/config" + "Wavelet/OpenFlare/plugins/server/oauth" + "Wavelet/OpenFlare/plugins/server/upload" + "Wavelet/OpenFlare/plugins/server/user" + "Wavelet/core" ) // RegisterUserRoutes registers all user-related, oauth, upload, and public routes. -func RegisterUserRoutes(apiV1Router *gin.RouterGroup, apiGroup *gin.RouterGroup) { +func RegisterUserRoutes(apiV1Router core.RouterExtension, apiGroup core.RouterExtension) { // 1. CAPTCHA registerCaptchaRoutes(apiGroup) @@ -31,7 +31,7 @@ func RegisterUserRoutes(apiV1Router *gin.RouterGroup, apiGroup *gin.RouterGroup) registerUploadRoutes(apiV1Router) } -func registerCaptchaRoutes(apiGroup *gin.RouterGroup) { +func registerCaptchaRoutes(apiGroup core.RouterExtension) { capGroup := apiGroup.Group("/cap") { capGroup.POST("/challenge", capApp.Challenge) @@ -39,14 +39,14 @@ func registerCaptchaRoutes(apiGroup *gin.RouterGroup) { } } -func registerConfigRoutes(apiV1Router *gin.RouterGroup) { +func registerConfigRoutes(apiV1Router core.RouterExtension) { configRouter := apiV1Router.Group("/config") { configRouter.GET("/public", publicconfig.GetPublicConfig) } } -func registerOAuthRoutes(apiV1Router *gin.RouterGroup) { +func registerOAuthRoutes(apiV1Router core.RouterExtension) { apiV1Router.GET("/oauth/sources", oauth.GetLoginSources) apiV1Router.GET("/oauth/login", oauth.GetLoginURL) apiV1Router.GET("/oauth/:source/authorize", oauth.Authorize) @@ -58,7 +58,7 @@ func registerOAuthRoutes(apiV1Router *gin.RouterGroup) { apiV1Router.POST("/oauth/external-accounts/:id/delete", oauth.LoginRequired(), oauth.DeleteExternalAccount) } -func registerUserRoutes(apiV1Router *gin.RouterGroup) { +func registerUserRoutes(apiV1Router core.RouterExtension) { userRouter := apiV1Router.Group("/user") { userRouter.POST("/login", capApp.VerifyMiddleware(capApp.GetDefaultManager(), "login"), user.Login) @@ -81,7 +81,7 @@ func registerUserRoutes(apiV1Router *gin.RouterGroup) { } } -func registerUploadRoutes(apiV1Router *gin.RouterGroup) { +func registerUploadRoutes(apiV1Router core.RouterExtension) { uploadRouter := apiV1Router.Group("/upload") uploadRouter.Use(oauth.LoginRequired()) { diff --git a/internal/router/v1/v1.go b/backend/OpenFlare/plugins/server/router/v1/v1.go similarity index 77% rename from internal/router/v1/v1.go rename to backend/OpenFlare/plugins/server/router/v1/v1.go index 33b0b51a..bd1385f0 100644 --- a/internal/router/v1/v1.go +++ b/backend/OpenFlare/plugins/server/router/v1/v1.go @@ -5,12 +5,12 @@ package v1 import ( - ofrouter "github.com/Rain-kl/Wavelet/internal/router/v1/openflare" - "github.com/gin-gonic/gin" + ofrouter "Wavelet/OpenFlare/plugins/server/router/v1/openflare" + "Wavelet/core" ) // RegisterV1Routes registers all routes under API V1. -func RegisterV1Routes(apiV1Router *gin.RouterGroup, apiGroup *gin.RouterGroup) { +func RegisterV1Routes(apiV1Router core.RouterExtension, apiGroup core.RouterExtension) { // 1. User & Public routes (OAuth, User, Upload, CAPTCHA, Health, Config) RegisterUserRoutes(apiV1Router, apiGroup) diff --git a/internal/shared/constants.go b/backend/OpenFlare/plugins/server/shared/constants.go similarity index 100% rename from internal/shared/constants.go rename to backend/OpenFlare/plugins/server/shared/constants.go diff --git a/internal/shared/errs.go b/backend/OpenFlare/plugins/server/shared/errs.go similarity index 100% rename from internal/shared/errs.go rename to backend/OpenFlare/plugins/server/shared/errs.go diff --git a/internal/testhelper/cleanup.go b/backend/OpenFlare/plugins/server/testhelper/cleanup.go similarity index 100% rename from internal/testhelper/cleanup.go rename to backend/OpenFlare/plugins/server/testhelper/cleanup.go diff --git a/internal/testhelper/gin.go b/backend/OpenFlare/plugins/server/testhelper/gin.go similarity index 89% rename from internal/testhelper/gin.go rename to backend/OpenFlare/plugins/server/testhelper/gin.go index edb9c9f9..29b8c19f 100644 --- a/internal/testhelper/gin.go +++ b/backend/OpenFlare/plugins/server/testhelper/gin.go @@ -4,7 +4,8 @@ package testhelper import ( - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/pkg/response" + "github.com/gin-gonic/gin" ) diff --git a/internal/testhelper/test_helper.go b/backend/OpenFlare/plugins/server/testhelper/test_helper.go similarity index 98% rename from internal/testhelper/test_helper.go rename to backend/OpenFlare/plugins/server/testhelper/test_helper.go index 988140cf..6abdc233 100644 --- a/internal/testhelper/test_helper.go +++ b/backend/OpenFlare/plugins/server/testhelper/test_helper.go @@ -9,11 +9,12 @@ import ( "context" "testing" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/repository/logstore" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + analyticsmodel "Wavelet/OpenFlare/plugins/server/model/analytics" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/repository/logstore" + "github.com/alicebob/miniredis/v2" "github.com/glebarez/sqlite" "github.com/redis/go-redis/v9" diff --git a/internal/apps/upload/cache/access_cache.go b/backend/OpenFlare/plugins/server/upload/cache/access_cache.go similarity index 90% rename from internal/apps/upload/cache/access_cache.go rename to backend/OpenFlare/plugins/server/upload/cache/access_cache.go index 4b39c1fd..698a27f4 100644 --- a/internal/apps/upload/cache/access_cache.go +++ b/backend/OpenFlare/plugins/server/upload/cache/access_cache.go @@ -11,13 +11,13 @@ import ( "sync" "time" - "github.com/Rain-kl/Wavelet/internal/apps/upload/shared" - uploadstorage "github.com/Rain-kl/Wavelet/internal/apps/upload/storage" - "github.com/Rain-kl/Wavelet/internal/infra/objectstore" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/pkg/util" + "Wavelet/OpenFlare/plugins/server/infra/objectstore" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/upload/shared" + uploadstorage "Wavelet/OpenFlare/plugins/server/upload/storage" + "Wavelet/pkg/util" ) const fileAccessInvalidationChannel = "upload:file_access_invalidation" diff --git a/internal/apps/upload/cache/access_cache_test.go b/backend/OpenFlare/plugins/server/upload/cache/access_cache_test.go similarity index 89% rename from internal/apps/upload/cache/access_cache_test.go rename to backend/OpenFlare/plugins/server/upload/cache/access_cache_test.go index 8c736803..a578c9dc 100644 --- a/internal/apps/upload/cache/access_cache_test.go +++ b/backend/OpenFlare/plugins/server/upload/cache/access_cache_test.go @@ -8,12 +8,12 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/apps/upload/shared" - uploadstorage "github.com/Rain-kl/Wavelet/internal/apps/upload/storage" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/testhelper" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/testhelper" + "Wavelet/OpenFlare/plugins/server/upload/shared" + uploadstorage "Wavelet/OpenFlare/plugins/server/upload/storage" ) func TestLoadMigrationAccessStateCachesResult(t *testing.T) { diff --git a/internal/apps/upload/cache/meta_cache.go b/backend/OpenFlare/plugins/server/upload/cache/meta_cache.go similarity index 94% rename from internal/apps/upload/cache/meta_cache.go rename to backend/OpenFlare/plugins/server/upload/cache/meta_cache.go index 809036ee..024240ee 100644 --- a/internal/apps/upload/cache/meta_cache.go +++ b/backend/OpenFlare/plugins/server/upload/cache/meta_cache.go @@ -9,11 +9,11 @@ import ( "fmt" "sync" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/pkg/cache/ram" - "github.com/Rain-kl/Wavelet/pkg/util" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/pkg/cache/ram" + "Wavelet/pkg/util" ) const ( diff --git a/internal/apps/upload/cache/meta_cache_test.go b/backend/OpenFlare/plugins/server/upload/cache/meta_cache_test.go similarity index 97% rename from internal/apps/upload/cache/meta_cache_test.go rename to backend/OpenFlare/plugins/server/upload/cache/meta_cache_test.go index 1b812a05..5ac6906f 100644 --- a/internal/apps/upload/cache/meta_cache_test.go +++ b/backend/OpenFlare/plugins/server/upload/cache/meta_cache_test.go @@ -9,9 +9,10 @@ import ( "testing" "time" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/testhelper" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/testhelper" + "gorm.io/gorm" ) diff --git a/internal/apps/upload/errs.go b/backend/OpenFlare/plugins/server/upload/errs.go similarity index 96% rename from internal/apps/upload/errs.go rename to backend/OpenFlare/plugins/server/upload/errs.go index bc6811d6..2bf53037 100644 --- a/internal/apps/upload/errs.go +++ b/backend/OpenFlare/plugins/server/upload/errs.go @@ -5,7 +5,7 @@ // Package upload 提供文件上传与下载功能 package upload -import "github.com/Rain-kl/Wavelet/internal/apps/upload/shared" +import "Wavelet/OpenFlare/plugins/server/upload/shared" // 文件管理常量 const ( diff --git a/internal/apps/upload/exports.go b/backend/OpenFlare/plugins/server/upload/exports.go similarity index 89% rename from internal/apps/upload/exports.go rename to backend/OpenFlare/plugins/server/upload/exports.go index cbc0dcc4..dbe337d8 100644 --- a/internal/apps/upload/exports.go +++ b/backend/OpenFlare/plugins/server/upload/exports.go @@ -4,15 +4,15 @@ package upload import ( - "github.com/Rain-kl/Wavelet/internal/apps/upload/cache" - "github.com/Rain-kl/Wavelet/internal/apps/upload/filesrv" - "github.com/Rain-kl/Wavelet/internal/apps/upload/handler" - "github.com/Rain-kl/Wavelet/internal/apps/upload/ingest" - "github.com/Rain-kl/Wavelet/internal/apps/upload/shared" - uploadstats "github.com/Rain-kl/Wavelet/internal/apps/upload/stats" - uploadtask "github.com/Rain-kl/Wavelet/internal/apps/upload/task" - "github.com/Rain-kl/Wavelet/internal/apps/upload/util" - "github.com/Rain-kl/Wavelet/internal/infra/task" + "Wavelet/OpenFlare/plugins/server/infra/task" + "Wavelet/OpenFlare/plugins/server/upload/cache" + "Wavelet/OpenFlare/plugins/server/upload/filesrv" + "Wavelet/OpenFlare/plugins/server/upload/handler" + "Wavelet/OpenFlare/plugins/server/upload/ingest" + "Wavelet/OpenFlare/plugins/server/upload/shared" + uploadstats "Wavelet/OpenFlare/plugins/server/upload/stats" + uploadtask "Wavelet/OpenFlare/plugins/server/upload/task" + "Wavelet/OpenFlare/plugins/server/upload/util" ) // HTTP handlers diff --git a/internal/apps/upload/filesrv/file_server.go b/backend/OpenFlare/plugins/server/upload/filesrv/file_server.go similarity index 94% rename from internal/apps/upload/filesrv/file_server.go rename to backend/OpenFlare/plugins/server/upload/filesrv/file_server.go index 113d81c1..7d908e30 100644 --- a/internal/apps/upload/filesrv/file_server.go +++ b/backend/OpenFlare/plugins/server/upload/filesrv/file_server.go @@ -15,17 +15,18 @@ import ( "strconv" "strings" - "github.com/Rain-kl/Wavelet/internal/apps/oauth" - "github.com/Rain-kl/Wavelet/internal/apps/upload/cache" - "github.com/Rain-kl/Wavelet/internal/apps/upload/shared" - uploadstorage "github.com/Rain-kl/Wavelet/internal/apps/upload/storage" - "github.com/Rain-kl/Wavelet/internal/apps/upload/util" - "github.com/Rain-kl/Wavelet/internal/infra/diskcache" - "github.com/Rain-kl/Wavelet/internal/model" - appshared "github.com/Rain-kl/Wavelet/internal/shared" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/OpenFlare/plugins/server/infra/diskcache" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/oauth" + appshared "Wavelet/OpenFlare/plugins/server/shared" + "Wavelet/OpenFlare/plugins/server/upload/cache" + "Wavelet/OpenFlare/plugins/server/upload/shared" + uploadstorage "Wavelet/OpenFlare/plugins/server/upload/storage" + "Wavelet/OpenFlare/plugins/server/upload/util" + "Wavelet/pkg/response" + + "Wavelet/pkg/logger" - "github.com/Rain-kl/Wavelet/pkg/logger" "github.com/gin-gonic/gin" "golang.org/x/sync/singleflight" "gorm.io/gorm" diff --git a/internal/apps/upload/filesrv/file_server_test.go b/backend/OpenFlare/plugins/server/upload/filesrv/file_server_test.go similarity index 94% rename from internal/apps/upload/filesrv/file_server_test.go rename to backend/OpenFlare/plugins/server/upload/filesrv/file_server_test.go index 1ac072ae..fdd19133 100644 --- a/internal/apps/upload/filesrv/file_server_test.go +++ b/backend/OpenFlare/plugins/server/upload/filesrv/file_server_test.go @@ -17,17 +17,18 @@ import ( "path/filepath" "testing" - "github.com/Rain-kl/Wavelet/internal/apps/upload/cache" - "github.com/Rain-kl/Wavelet/internal/apps/upload/shared" - "github.com/Rain-kl/Wavelet/internal/apps/upload/util" - "github.com/Rain-kl/Wavelet/internal/infra/diskcache" - "github.com/Rain-kl/Wavelet/internal/infra/objectstore" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - appshared "github.com/Rain-kl/Wavelet/internal/shared" - "github.com/Rain-kl/Wavelet/internal/shared/response" - "github.com/Rain-kl/Wavelet/internal/testhelper" + "Wavelet/OpenFlare/plugins/server/infra/diskcache" + "Wavelet/OpenFlare/plugins/server/infra/objectstore" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + appshared "Wavelet/OpenFlare/plugins/server/shared" + "Wavelet/OpenFlare/plugins/server/testhelper" + "Wavelet/OpenFlare/plugins/server/upload/cache" + "Wavelet/OpenFlare/plugins/server/upload/shared" + "Wavelet/OpenFlare/plugins/server/upload/util" + "Wavelet/pkg/response" + "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/cookie" "github.com/gin-gonic/gin" diff --git a/internal/apps/upload/handler/file_management.go b/backend/OpenFlare/plugins/server/upload/handler/file_management.go similarity index 96% rename from internal/apps/upload/handler/file_management.go rename to backend/OpenFlare/plugins/server/upload/handler/file_management.go index 5ca3b7dd..e582912b 100644 --- a/internal/apps/upload/handler/file_management.go +++ b/backend/OpenFlare/plugins/server/upload/handler/file_management.go @@ -8,13 +8,13 @@ import ( "net/http" "strconv" - "github.com/Rain-kl/Wavelet/internal/apps/oauth" - "github.com/Rain-kl/Wavelet/internal/apps/upload/ingest" - "github.com/Rain-kl/Wavelet/internal/apps/upload/shared" - uploadstorage "github.com/Rain-kl/Wavelet/internal/apps/upload/storage" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/oauth" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/upload/ingest" + "Wavelet/OpenFlare/plugins/server/upload/shared" + uploadstorage "Wavelet/OpenFlare/plugins/server/upload/storage" + "Wavelet/pkg/response" "github.com/gin-gonic/gin" ) diff --git a/internal/apps/upload/handler/file_management_test.go b/backend/OpenFlare/plugins/server/upload/handler/file_management_test.go similarity index 93% rename from internal/apps/upload/handler/file_management_test.go rename to backend/OpenFlare/plugins/server/upload/handler/file_management_test.go index 9a5edff5..bc47327b 100644 --- a/internal/apps/upload/handler/file_management_test.go +++ b/backend/OpenFlare/plugins/server/upload/handler/file_management_test.go @@ -9,8 +9,9 @@ import ( "net/http/httptest" "testing" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/testhelper" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/testhelper" + "github.com/gin-gonic/gin" ) diff --git a/internal/apps/upload/handler/logics.go b/backend/OpenFlare/plugins/server/upload/handler/logics.go similarity index 93% rename from internal/apps/upload/handler/logics.go rename to backend/OpenFlare/plugins/server/upload/handler/logics.go index c966b0fc..7171b618 100644 --- a/internal/apps/upload/handler/logics.go +++ b/backend/OpenFlare/plugins/server/upload/handler/logics.go @@ -8,9 +8,10 @@ import ( "errors" "sort" - "github.com/Rain-kl/Wavelet/internal/apps/upload/ingest" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/upload/ingest" + "gorm.io/gorm" ) diff --git a/internal/apps/upload/handler/routers.go b/backend/OpenFlare/plugins/server/upload/handler/routers.go similarity index 94% rename from internal/apps/upload/handler/routers.go rename to backend/OpenFlare/plugins/server/upload/handler/routers.go index 0fafb443..0d8a6338 100644 --- a/internal/apps/upload/handler/routers.go +++ b/backend/OpenFlare/plugins/server/upload/handler/routers.go @@ -22,16 +22,17 @@ import ( "strconv" "strings" - "github.com/Rain-kl/Wavelet/internal/apps/oauth" - "github.com/Rain-kl/Wavelet/internal/apps/upload/filesrv" - "github.com/Rain-kl/Wavelet/internal/apps/upload/ingest" - "github.com/Rain-kl/Wavelet/internal/apps/upload/shared" - uploadstorage "github.com/Rain-kl/Wavelet/internal/apps/upload/storage" - "github.com/Rain-kl/Wavelet/internal/apps/upload/util" - "github.com/Rain-kl/Wavelet/internal/model" - appshared "github.com/Rain-kl/Wavelet/internal/shared" - "github.com/Rain-kl/Wavelet/internal/shared/response" - "github.com/Rain-kl/Wavelet/pkg/logger" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/oauth" + appshared "Wavelet/OpenFlare/plugins/server/shared" + "Wavelet/OpenFlare/plugins/server/upload/filesrv" + "Wavelet/OpenFlare/plugins/server/upload/ingest" + "Wavelet/OpenFlare/plugins/server/upload/shared" + uploadstorage "Wavelet/OpenFlare/plugins/server/upload/storage" + "Wavelet/OpenFlare/plugins/server/upload/util" + "Wavelet/pkg/logger" + "Wavelet/pkg/response" + "github.com/gin-gonic/gin" "gorm.io/gorm" ) diff --git a/internal/apps/upload/handler/routers_test.go b/backend/OpenFlare/plugins/server/upload/handler/routers_test.go similarity index 98% rename from internal/apps/upload/handler/routers_test.go rename to backend/OpenFlare/plugins/server/upload/handler/routers_test.go index 3ce89d03..035fc19d 100644 --- a/internal/apps/upload/handler/routers_test.go +++ b/backend/OpenFlare/plugins/server/upload/handler/routers_test.go @@ -20,15 +20,16 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/apps/oauth" - "github.com/Rain-kl/Wavelet/internal/apps/upload/shared" - uploadstats "github.com/Rain-kl/Wavelet/internal/apps/upload/stats" - "github.com/Rain-kl/Wavelet/internal/infra/objectstore" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/shared/response" - "github.com/Rain-kl/Wavelet/internal/testhelper" + "Wavelet/OpenFlare/plugins/server/infra/objectstore" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/oauth" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/testhelper" + "Wavelet/OpenFlare/plugins/server/upload/shared" + uploadstats "Wavelet/OpenFlare/plugins/server/upload/stats" + "Wavelet/pkg/response" + "github.com/gin-gonic/gin" "gorm.io/gorm" ) diff --git a/internal/apps/upload/handler/stats.go b/backend/OpenFlare/plugins/server/upload/handler/stats.go similarity index 95% rename from internal/apps/upload/handler/stats.go rename to backend/OpenFlare/plugins/server/upload/handler/stats.go index e14536bc..1bdebd1d 100644 --- a/internal/apps/upload/handler/stats.go +++ b/backend/OpenFlare/plugins/server/upload/handler/stats.go @@ -7,9 +7,10 @@ import ( "net/http" "time" - "github.com/Rain-kl/Wavelet/internal/apps/upload/shared" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/upload/shared" + "Wavelet/pkg/response" + "github.com/gin-gonic/gin" ) diff --git a/internal/apps/upload/ingest/access.go b/backend/OpenFlare/plugins/server/upload/ingest/access.go similarity index 87% rename from internal/apps/upload/ingest/access.go rename to backend/OpenFlare/plugins/server/upload/ingest/access.go index e7b8944f..bf95076a 100644 --- a/internal/apps/upload/ingest/access.go +++ b/backend/OpenFlare/plugins/server/upload/ingest/access.go @@ -8,9 +8,9 @@ import ( "errors" "strings" - uploadcache "github.com/Rain-kl/Wavelet/internal/apps/upload/cache" - uploadstorage "github.com/Rain-kl/Wavelet/internal/apps/upload/storage" - "github.com/Rain-kl/Wavelet/internal/model" + "Wavelet/OpenFlare/plugins/server/model" + uploadcache "Wavelet/OpenFlare/plugins/server/upload/cache" + uploadstorage "Wavelet/OpenFlare/plugins/server/upload/storage" ) // GetActive loads an active upload record by ID through the upload metadata cache path. diff --git a/internal/apps/upload/ingest/access_test.go b/backend/OpenFlare/plugins/server/upload/ingest/access_test.go similarity index 100% rename from internal/apps/upload/ingest/access_test.go rename to backend/OpenFlare/plugins/server/upload/ingest/access_test.go diff --git a/internal/apps/upload/ingest/errors.go b/backend/OpenFlare/plugins/server/upload/ingest/errors.go similarity index 90% rename from internal/apps/upload/ingest/errors.go rename to backend/OpenFlare/plugins/server/upload/ingest/errors.go index 59ad4ed8..ec48618b 100644 --- a/internal/apps/upload/ingest/errors.go +++ b/backend/OpenFlare/plugins/server/upload/ingest/errors.go @@ -6,7 +6,7 @@ package ingest import ( "errors" - "github.com/Rain-kl/Wavelet/internal/apps/upload/shared" + "Wavelet/OpenFlare/plugins/server/upload/shared" ) // ErrForbidden indicates the caller is not allowed to mutate the upload record. diff --git a/internal/apps/upload/ingest/helpers.go b/backend/OpenFlare/plugins/server/upload/ingest/helpers.go similarity index 90% rename from internal/apps/upload/ingest/helpers.go rename to backend/OpenFlare/plugins/server/upload/ingest/helpers.go index 175e6abe..92e4e553 100644 --- a/internal/apps/upload/ingest/helpers.go +++ b/backend/OpenFlare/plugins/server/upload/ingest/helpers.go @@ -13,15 +13,15 @@ import ( "gorm.io/gorm" - uploadcache "github.com/Rain-kl/Wavelet/internal/apps/upload/cache" - "github.com/Rain-kl/Wavelet/internal/apps/upload/shared" - uploadstats "github.com/Rain-kl/Wavelet/internal/apps/upload/stats" - uploadstorage "github.com/Rain-kl/Wavelet/internal/apps/upload/storage" - "github.com/Rain-kl/Wavelet/internal/infra/objectstore" - "github.com/Rain-kl/Wavelet/internal/infra/persistence/idgen" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/pkg/logger" + "Wavelet/OpenFlare/plugins/server/infra/objectstore" + "Wavelet/OpenFlare/plugins/server/infra/persistence/idgen" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + uploadcache "Wavelet/OpenFlare/plugins/server/upload/cache" + "Wavelet/OpenFlare/plugins/server/upload/shared" + uploadstats "Wavelet/OpenFlare/plugins/server/upload/stats" + uploadstorage "Wavelet/OpenFlare/plugins/server/upload/storage" + "Wavelet/pkg/logger" ) func normalizeRequest(req *Request) { diff --git a/internal/apps/upload/ingest/ingest.go b/backend/OpenFlare/plugins/server/upload/ingest/ingest.go similarity index 94% rename from internal/apps/upload/ingest/ingest.go rename to backend/OpenFlare/plugins/server/upload/ingest/ingest.go index cdda6ca1..1a4462d5 100644 --- a/internal/apps/upload/ingest/ingest.go +++ b/backend/OpenFlare/plugins/server/upload/ingest/ingest.go @@ -7,8 +7,9 @@ import ( "context" "errors" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "gorm.io/gorm" ) diff --git a/internal/apps/upload/ingest/ingest_test.go b/backend/OpenFlare/plugins/server/upload/ingest/ingest_test.go similarity index 97% rename from internal/apps/upload/ingest/ingest_test.go rename to backend/OpenFlare/plugins/server/upload/ingest/ingest_test.go index 2fa30e2a..04a46864 100644 --- a/internal/apps/upload/ingest/ingest_test.go +++ b/backend/OpenFlare/plugins/server/upload/ingest/ingest_test.go @@ -15,12 +15,13 @@ import ( "testing" "time" - uploadcache "github.com/Rain-kl/Wavelet/internal/apps/upload/cache" - "github.com/Rain-kl/Wavelet/internal/apps/upload/shared" - "github.com/Rain-kl/Wavelet/internal/infra/objectstore" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/testhelper" + "Wavelet/OpenFlare/plugins/server/infra/objectstore" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/testhelper" + uploadcache "Wavelet/OpenFlare/plugins/server/upload/cache" + "Wavelet/OpenFlare/plugins/server/upload/shared" + "gorm.io/gorm" ) diff --git a/internal/apps/upload/ingest/local_file.go b/backend/OpenFlare/plugins/server/upload/ingest/local_file.go similarity index 98% rename from internal/apps/upload/ingest/local_file.go rename to backend/OpenFlare/plugins/server/upload/ingest/local_file.go index 91563eef..df4f3855 100644 --- a/internal/apps/upload/ingest/local_file.go +++ b/backend/OpenFlare/plugins/server/upload/ingest/local_file.go @@ -10,7 +10,7 @@ import ( "path/filepath" "strings" - "github.com/Rain-kl/Wavelet/internal/infra/objectstore" + "Wavelet/OpenFlare/plugins/server/infra/objectstore" ) // LocalFileCandidateRequest describes filesystem locations that may host a legacy blob. diff --git a/internal/apps/upload/ingest/local_file_test.go b/backend/OpenFlare/plugins/server/upload/ingest/local_file_test.go similarity index 100% rename from internal/apps/upload/ingest/local_file_test.go rename to backend/OpenFlare/plugins/server/upload/ingest/local_file_test.go diff --git a/internal/apps/upload/ingest/object.go b/backend/OpenFlare/plugins/server/upload/ingest/object.go similarity index 90% rename from internal/apps/upload/ingest/object.go rename to backend/OpenFlare/plugins/server/upload/ingest/object.go index 02fed0a1..b80dff9d 100644 --- a/internal/apps/upload/ingest/object.go +++ b/backend/OpenFlare/plugins/server/upload/ingest/object.go @@ -6,7 +6,7 @@ package ingest import ( "io" - "github.com/Rain-kl/Wavelet/internal/model" + "Wavelet/OpenFlare/plugins/server/model" ) // OpenedObject is the upload-domain view of a stored object stream. diff --git a/internal/apps/upload/ingest/remove.go b/backend/OpenFlare/plugins/server/upload/ingest/remove.go similarity index 88% rename from internal/apps/upload/ingest/remove.go rename to backend/OpenFlare/plugins/server/upload/ingest/remove.go index 8ebb6a7c..7b0465d3 100644 --- a/internal/apps/upload/ingest/remove.go +++ b/backend/OpenFlare/plugins/server/upload/ingest/remove.go @@ -8,11 +8,11 @@ import ( "gorm.io/gorm" - uploadcache "github.com/Rain-kl/Wavelet/internal/apps/upload/cache" - "github.com/Rain-kl/Wavelet/internal/apps/upload/shared" - uploadstats "github.com/Rain-kl/Wavelet/internal/apps/upload/stats" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + uploadcache "Wavelet/OpenFlare/plugins/server/upload/cache" + "Wavelet/OpenFlare/plugins/server/upload/shared" + uploadstats "Wavelet/OpenFlare/plugins/server/upload/stats" ) // Remove soft-deletes an ordinary upload and decrements incremental stats once. diff --git a/internal/apps/upload/ingest/types.go b/backend/OpenFlare/plugins/server/upload/ingest/types.go similarity index 96% rename from internal/apps/upload/ingest/types.go rename to backend/OpenFlare/plugins/server/upload/ingest/types.go index 00bc22b3..4e05cab1 100644 --- a/internal/apps/upload/ingest/types.go +++ b/backend/OpenFlare/plugins/server/upload/ingest/types.go @@ -7,7 +7,7 @@ package ingest import ( "io" - "github.com/Rain-kl/Wavelet/internal/model" + "Wavelet/OpenFlare/plugins/server/model" ) // Policy controls how ingest handles hash collisions and record creation. diff --git a/internal/apps/upload/shared/constants.go b/backend/OpenFlare/plugins/server/upload/shared/constants.go similarity index 100% rename from internal/apps/upload/shared/constants.go rename to backend/OpenFlare/plugins/server/upload/shared/constants.go diff --git a/internal/apps/upload/shared/errs.go b/backend/OpenFlare/plugins/server/upload/shared/errs.go similarity index 100% rename from internal/apps/upload/shared/errs.go rename to backend/OpenFlare/plugins/server/upload/shared/errs.go diff --git a/internal/apps/upload/stats/category.go b/backend/OpenFlare/plugins/server/upload/stats/category.go similarity index 95% rename from internal/apps/upload/stats/category.go rename to backend/OpenFlare/plugins/server/upload/stats/category.go index 52fdc583..2af02686 100644 --- a/internal/apps/upload/stats/category.go +++ b/backend/OpenFlare/plugins/server/upload/stats/category.go @@ -7,7 +7,7 @@ package stats import ( "strings" - "github.com/Rain-kl/Wavelet/internal/apps/upload/util" + "Wavelet/OpenFlare/plugins/server/upload/util" ) const ( diff --git a/internal/apps/upload/stats/stats_counter.go b/backend/OpenFlare/plugins/server/upload/stats/stats_counter.go similarity index 95% rename from internal/apps/upload/stats/stats_counter.go rename to backend/OpenFlare/plugins/server/upload/stats/stats_counter.go index 95342593..d405d19b 100644 --- a/internal/apps/upload/stats/stats_counter.go +++ b/backend/OpenFlare/plugins/server/upload/stats/stats_counter.go @@ -8,9 +8,9 @@ import ( "gorm.io/gorm" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/pkg/logger" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/pkg/logger" ) // ApplyUploadStatsAdd increments incremental stats for a newly active upload record. diff --git a/internal/apps/upload/stats/stats_counter_test.go b/backend/OpenFlare/plugins/server/upload/stats/stats_counter_test.go similarity index 94% rename from internal/apps/upload/stats/stats_counter_test.go rename to backend/OpenFlare/plugins/server/upload/stats/stats_counter_test.go index 850ff83f..1bce8b76 100644 --- a/internal/apps/upload/stats/stats_counter_test.go +++ b/backend/OpenFlare/plugins/server/upload/stats/stats_counter_test.go @@ -10,9 +10,9 @@ import ( "gorm.io/gorm" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/testhelper" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/testhelper" ) func TestApplyUploadStatsDeltaTxWithinTransaction(t *testing.T) { diff --git a/internal/apps/upload/storage/access_state.go b/backend/OpenFlare/plugins/server/upload/storage/access_state.go similarity index 93% rename from internal/apps/upload/storage/access_state.go rename to backend/OpenFlare/plugins/server/upload/storage/access_state.go index dceda766..609dba02 100644 --- a/internal/apps/upload/storage/access_state.go +++ b/backend/OpenFlare/plugins/server/upload/storage/access_state.go @@ -9,9 +9,9 @@ import ( "sync" "time" - "github.com/Rain-kl/Wavelet/internal/apps/upload/shared" - "github.com/Rain-kl/Wavelet/internal/infra/objectstore" - "github.com/Rain-kl/Wavelet/internal/model" + "Wavelet/OpenFlare/plugins/server/infra/objectstore" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/upload/shared" ) // MigrationAccessState captures cached migration maintenance state. diff --git a/internal/apps/upload/storage/migration.go b/backend/OpenFlare/plugins/server/upload/storage/migration.go similarity index 94% rename from internal/apps/upload/storage/migration.go rename to backend/OpenFlare/plugins/server/upload/storage/migration.go index 3fa6015b..57f6a81c 100644 --- a/internal/apps/upload/storage/migration.go +++ b/backend/OpenFlare/plugins/server/upload/storage/migration.go @@ -10,9 +10,9 @@ import ( "fmt" "strings" - "github.com/Rain-kl/Wavelet/internal/infra/objectstore" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/infra/objectstore" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" ) // StorageMigrationTask is the Asynq task name for storage migration. diff --git a/internal/apps/upload/storage/storage_ops.go b/backend/OpenFlare/plugins/server/upload/storage/storage_ops.go similarity index 83% rename from internal/apps/upload/storage/storage_ops.go rename to backend/OpenFlare/plugins/server/upload/storage/storage_ops.go index 892691ef..1eb14430 100644 --- a/internal/apps/upload/storage/storage_ops.go +++ b/backend/OpenFlare/plugins/server/upload/storage/storage_ops.go @@ -6,9 +6,9 @@ package storage import ( "context" - "github.com/Rain-kl/Wavelet/internal/infra/objectstore" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/pkg/logger" + "Wavelet/OpenFlare/plugins/server/infra/objectstore" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/pkg/logger" ) // ReadOnly checks if the storage system is in read-only maintenance mode. diff --git a/internal/apps/upload/task/cleanup.go b/backend/OpenFlare/plugins/server/upload/task/cleanup.go similarity index 92% rename from internal/apps/upload/task/cleanup.go rename to backend/OpenFlare/plugins/server/upload/task/cleanup.go index 84ff11de..f5e559b1 100644 --- a/internal/apps/upload/task/cleanup.go +++ b/backend/OpenFlare/plugins/server/upload/task/cleanup.go @@ -12,14 +12,14 @@ import ( "gorm.io/gorm" - "github.com/Rain-kl/Wavelet/internal/apps/upload/ingest" - "github.com/Rain-kl/Wavelet/internal/apps/upload/shared" - uploadstorage "github.com/Rain-kl/Wavelet/internal/apps/upload/storage" - "github.com/Rain-kl/Wavelet/internal/infra/task" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/repository/logstore" - "github.com/Rain-kl/Wavelet/pkg/logger" + "Wavelet/OpenFlare/plugins/server/infra/task" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/repository/logstore" + "Wavelet/OpenFlare/plugins/server/upload/ingest" + "Wavelet/OpenFlare/plugins/server/upload/shared" + uploadstorage "Wavelet/OpenFlare/plugins/server/upload/storage" + "Wavelet/pkg/logger" ) const ( diff --git a/internal/apps/upload/task/rebuild_stats.go b/backend/OpenFlare/plugins/server/upload/task/rebuild_stats.go similarity index 92% rename from internal/apps/upload/task/rebuild_stats.go rename to backend/OpenFlare/plugins/server/upload/task/rebuild_stats.go index 2d0b59cf..0497d825 100644 --- a/internal/apps/upload/task/rebuild_stats.go +++ b/backend/OpenFlare/plugins/server/upload/task/rebuild_stats.go @@ -7,9 +7,9 @@ import ( "context" "fmt" - uploadstats "github.com/Rain-kl/Wavelet/internal/apps/upload/stats" - "github.com/Rain-kl/Wavelet/internal/infra/task" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/infra/task" + "Wavelet/OpenFlare/plugins/server/repository" + uploadstats "Wavelet/OpenFlare/plugins/server/upload/stats" ) const ( diff --git a/internal/apps/upload/task/rebuild_stats_test.go b/backend/OpenFlare/plugins/server/upload/task/rebuild_stats_test.go similarity index 92% rename from internal/apps/upload/task/rebuild_stats_test.go rename to backend/OpenFlare/plugins/server/upload/task/rebuild_stats_test.go index 1dc3a85e..c54a58d6 100644 --- a/internal/apps/upload/task/rebuild_stats_test.go +++ b/backend/OpenFlare/plugins/server/upload/task/rebuild_stats_test.go @@ -8,9 +8,9 @@ import ( "testing" "time" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/testhelper" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/testhelper" ) func TestRebuildUploadStatsHandler_Execute(t *testing.T) { diff --git a/internal/apps/upload/task/storage_migration.go b/backend/OpenFlare/plugins/server/upload/task/storage_migration.go similarity index 96% rename from internal/apps/upload/task/storage_migration.go rename to backend/OpenFlare/plugins/server/upload/task/storage_migration.go index e4153511..a853daba 100644 --- a/internal/apps/upload/task/storage_migration.go +++ b/backend/OpenFlare/plugins/server/upload/task/storage_migration.go @@ -17,14 +17,14 @@ import ( "golang.org/x/sync/errgroup" - uploadstats "github.com/Rain-kl/Wavelet/internal/apps/upload/stats" - uploadstorage "github.com/Rain-kl/Wavelet/internal/apps/upload/storage" - "github.com/Rain-kl/Wavelet/internal/infra/objectstore" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/infra/task" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/pkg/util" + "Wavelet/OpenFlare/plugins/server/infra/objectstore" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/infra/task" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + uploadstats "Wavelet/OpenFlare/plugins/server/upload/stats" + uploadstorage "Wavelet/OpenFlare/plugins/server/upload/storage" + "Wavelet/pkg/util" ) const ( diff --git a/internal/apps/upload/task/storage_migration_task_test.go b/backend/OpenFlare/plugins/server/upload/task/storage_migration_task_test.go similarity index 97% rename from internal/apps/upload/task/storage_migration_task_test.go rename to backend/OpenFlare/plugins/server/upload/task/storage_migration_task_test.go index 43a101d2..c2cea461 100644 --- a/internal/apps/upload/task/storage_migration_task_test.go +++ b/backend/OpenFlare/plugins/server/upload/task/storage_migration_task_test.go @@ -16,10 +16,11 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/infra/objectstore" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/testhelper" + "Wavelet/OpenFlare/plugins/server/infra/objectstore" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/testhelper" + "github.com/alicebob/miniredis/v2" "github.com/redis/go-redis/v9" ) diff --git a/internal/apps/upload/task/tasks.go b/backend/OpenFlare/plugins/server/upload/task/tasks.go similarity index 95% rename from internal/apps/upload/task/tasks.go rename to backend/OpenFlare/plugins/server/upload/task/tasks.go index f21056ba..62d25958 100644 --- a/internal/apps/upload/task/tasks.go +++ b/backend/OpenFlare/plugins/server/upload/task/tasks.go @@ -12,10 +12,10 @@ import ( "strings" "sync" - "github.com/Rain-kl/Wavelet/internal/apps/upload/filesrv" - "github.com/Rain-kl/Wavelet/internal/apps/upload/shared" - "github.com/Rain-kl/Wavelet/internal/infra/task" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/infra/task" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/upload/filesrv" + "Wavelet/OpenFlare/plugins/server/upload/shared" ) const ( diff --git a/internal/apps/upload/task/tasks_test.go b/backend/OpenFlare/plugins/server/upload/task/tasks_test.go similarity index 95% rename from internal/apps/upload/task/tasks_test.go rename to backend/OpenFlare/plugins/server/upload/task/tasks_test.go index 08ca1d1d..f766385e 100644 --- a/internal/apps/upload/task/tasks_test.go +++ b/backend/OpenFlare/plugins/server/upload/task/tasks_test.go @@ -17,17 +17,18 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/repository" + + "Wavelet/OpenFlare/plugins/server/infra/diskcache" + "Wavelet/OpenFlare/plugins/server/infra/objectstore" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/infra/task" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/testhelper" + "Wavelet/OpenFlare/plugins/server/upload/filesrv" + "Wavelet/OpenFlare/plugins/server/upload/shared" + uploadstats "Wavelet/OpenFlare/plugins/server/upload/stats" - "github.com/Rain-kl/Wavelet/internal/apps/upload/filesrv" - "github.com/Rain-kl/Wavelet/internal/apps/upload/shared" - uploadstats "github.com/Rain-kl/Wavelet/internal/apps/upload/stats" - "github.com/Rain-kl/Wavelet/internal/infra/diskcache" - "github.com/Rain-kl/Wavelet/internal/infra/objectstore" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/infra/task" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/testhelper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/internal/apps/upload/util/media.go b/backend/OpenFlare/plugins/server/upload/util/media.go similarity index 95% rename from internal/apps/upload/util/media.go rename to backend/OpenFlare/plugins/server/upload/util/media.go index c712afae..14875936 100644 --- a/internal/apps/upload/util/media.go +++ b/backend/OpenFlare/plugins/server/upload/util/media.go @@ -7,7 +7,7 @@ import ( "slices" "strings" - "github.com/Rain-kl/Wavelet/internal/apps/upload/shared" + "Wavelet/OpenFlare/plugins/server/upload/shared" ) // IsImageExtension reports whether ext is a common image format. diff --git a/internal/apps/upload/util/utils.go b/backend/OpenFlare/plugins/server/upload/util/utils.go similarity index 96% rename from internal/apps/upload/util/utils.go rename to backend/OpenFlare/plugins/server/upload/util/utils.go index 1c9ffc31..81bca1d3 100644 --- a/internal/apps/upload/util/utils.go +++ b/backend/OpenFlare/plugins/server/upload/util/utils.go @@ -16,7 +16,8 @@ import ( "io" "strings" - "github.com/Rain-kl/Wavelet/internal/apps/upload/shared" + "Wavelet/OpenFlare/plugins/server/upload/shared" + "github.com/deepteams/webp" _ "golang.org/x/image/webp" // Register WebP decoder for image.Decode ) diff --git a/internal/apps/user/access_tokens.go b/backend/OpenFlare/plugins/server/user/access_tokens.go similarity index 96% rename from internal/apps/user/access_tokens.go rename to backend/OpenFlare/plugins/server/user/access_tokens.go index 047ea420..65e9cdfe 100644 --- a/internal/apps/user/access_tokens.go +++ b/backend/OpenFlare/plugins/server/user/access_tokens.go @@ -10,12 +10,13 @@ import ( "strconv" "strings" - "github.com/Rain-kl/Wavelet/internal/apps/oauth" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/oauth" + "Wavelet/OpenFlare/plugins/server/repository" + "github.com/gin-gonic/gin" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/pkg/response" ) type createTokenRequest struct { diff --git a/internal/apps/user/constants.go b/backend/OpenFlare/plugins/server/user/constants.go similarity index 100% rename from internal/apps/user/constants.go rename to backend/OpenFlare/plugins/server/user/constants.go diff --git a/internal/apps/user/errs.go b/backend/OpenFlare/plugins/server/user/errs.go similarity index 100% rename from internal/apps/user/errs.go rename to backend/OpenFlare/plugins/server/user/errs.go diff --git a/internal/apps/user/logics.go b/backend/OpenFlare/plugins/server/user/logics.go similarity index 97% rename from internal/apps/user/logics.go rename to backend/OpenFlare/plugins/server/user/logics.go index 5f354aaf..f2ecb056 100644 --- a/internal/apps/user/logics.go +++ b/backend/OpenFlare/plugins/server/user/logics.go @@ -15,12 +15,12 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/apps/oauth" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/infra/task" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - pkgu "github.com/Rain-kl/Wavelet/pkg/util" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/infra/task" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/oauth" + "Wavelet/OpenFlare/plugins/server/repository" + pkgu "Wavelet/pkg/util" ) // LoginEmailVerificationStatus 登录邮箱验证的处理结果。 diff --git a/internal/apps/user/logics_test.go b/backend/OpenFlare/plugins/server/user/logics_test.go similarity index 95% rename from internal/apps/user/logics_test.go rename to backend/OpenFlare/plugins/server/user/logics_test.go index f9a284c1..1719c5e2 100644 --- a/internal/apps/user/logics_test.go +++ b/backend/OpenFlare/plugins/server/user/logics_test.go @@ -8,10 +8,10 @@ import ( "testing" "time" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/testhelper" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/testhelper" ) func TestProcessLoginEmailVerificationSMTPFallback(t *testing.T) { diff --git a/internal/apps/user/routers.go b/backend/OpenFlare/plugins/server/user/routers.go similarity index 97% rename from internal/apps/user/routers.go rename to backend/OpenFlare/plugins/server/user/routers.go index 445176ba..6a90b303 100644 --- a/internal/apps/user/routers.go +++ b/backend/OpenFlare/plugins/server/user/routers.go @@ -8,13 +8,14 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/apps/oauth" - "github.com/Rain-kl/Wavelet/internal/infra/persistence/idgen" - "github.com/Rain-kl/Wavelet/internal/listener" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/shared/response" - "github.com/Rain-kl/Wavelet/pkg/logger" - pkgu "github.com/Rain-kl/Wavelet/pkg/util" + "Wavelet/OpenFlare/plugins/server/infra/persistence/idgen" + "Wavelet/OpenFlare/plugins/server/listener" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/oauth" + "Wavelet/pkg/logger" + "Wavelet/pkg/response" + pkgu "Wavelet/pkg/util" + "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" ) diff --git a/internal/apps/user/routers_test.go b/backend/OpenFlare/plugins/server/user/routers_test.go similarity index 98% rename from internal/apps/user/routers_test.go rename to backend/OpenFlare/plugins/server/user/routers_test.go index 4e6385ba..85df7c18 100644 --- a/internal/apps/user/routers_test.go +++ b/backend/OpenFlare/plugins/server/user/routers_test.go @@ -13,17 +13,18 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/apps/oauth" - "github.com/Rain-kl/Wavelet/internal/infra/config" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/testhelper" + "Wavelet/OpenFlare/plugins/server/infra/config" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/oauth" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/OpenFlare/plugins/server/testhelper" + "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/cookie" "github.com/gin-gonic/gin" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "Wavelet/pkg/response" ) func setupUserTestRouter(t *testing.T) *gin.Engine { diff --git a/internal/apps/user/tasks.go b/backend/OpenFlare/plugins/server/user/tasks.go similarity index 95% rename from internal/apps/user/tasks.go rename to backend/OpenFlare/plugins/server/user/tasks.go index 0849f326..e21727a1 100644 --- a/internal/apps/user/tasks.go +++ b/backend/OpenFlare/plugins/server/user/tasks.go @@ -11,10 +11,10 @@ import ( "strconv" "strings" - "github.com/Rain-kl/Wavelet/internal/infra/task" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/pkg/mail" + "Wavelet/OpenFlare/plugins/server/infra/task" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/repository" + "Wavelet/pkg/mail" ) // 异步任务名称与管理类型定义 diff --git a/backend/OpenFlare/share/README.md b/backend/OpenFlare/share/README.md new file mode 100644 index 00000000..02d3f078 --- /dev/null +++ b/backend/OpenFlare/share/README.md @@ -0,0 +1,26 @@ +# share — 跨插件共享资源层 + +本目录存放被 **两个及以上插件共同消费**、且无处安放的可复用资源: + +- 不能放 `Wavelet/pkg/`:该层是上游通用库,禁止包含业务语义。 +- 不能放进某个插件内部:Cordis 规则禁止插件之间互相 import 实现包。 + +因此 `share/` 是内核(`core/`)之外唯一允许被任意插件直接 import 的共享层。 +本目录内的包**禁止** import `Wavelet/OpenFlare/...`(下游业务)与 `Wavelet/plugins/...`(具体插件实现), +只允许 import 标准库、第三方库与 `Wavelet/core`、`Wavelet/pkg`。 + +## 当前内容 + +| 包 | 内容 | 消费方 | +| :-- | :-- | :-- | +| `share/protocol` | server 与边缘三进制的控制消息线格式 | `server`、`agent`、`relay`、`flared` | +| `share/geoip` | GeoIP 解析与 IP 工具(含 `iputil` 子包) | `server`、`agent` | +| `share/edge/logging` | 边缘守护进程统一日志初始化 | `agent`、`relay`、`flared` | + +## 所有权与上游同步 + +**本目录由 OpenFlare 拥有,不属于上游同步范围**:`scripts/sync-upstream.sh` 仅覆盖 +`backend/{core,pkg,plugins}`,从不写入 `share/` 与 `OpenFlare/`。 + +计划迁入的候选(随插件化进度):`wsclient`(需先合并 `pkg/util` 的仅存符号)、 +`render`(openresty 配置渲染)、`pagesarchive`(Pages 产物解包)。 diff --git a/internal/apps/edge/config/duration.go b/backend/OpenFlare/share/edge/config/duration.go similarity index 100% rename from internal/apps/edge/config/duration.go rename to backend/OpenFlare/share/edge/config/duration.go diff --git a/internal/apps/edge/config/duration_test.go b/backend/OpenFlare/share/edge/config/duration_test.go similarity index 100% rename from internal/apps/edge/config/duration_test.go rename to backend/OpenFlare/share/edge/config/duration_test.go diff --git a/internal/apps/edge/heartbeat/autoupdate.go b/backend/OpenFlare/share/edge/heartbeat/autoupdate.go similarity index 94% rename from internal/apps/edge/heartbeat/autoupdate.go rename to backend/OpenFlare/share/edge/heartbeat/autoupdate.go index 09cd69c0..1e25d234 100644 --- a/internal/apps/edge/heartbeat/autoupdate.go +++ b/backend/OpenFlare/share/edge/heartbeat/autoupdate.go @@ -9,7 +9,7 @@ import ( "log/slog" "strings" - edgeupdater "github.com/Rain-kl/Wavelet/internal/apps/edge/updater" + edgeupdater "Wavelet/OpenFlare/share/edge/updater" ) // AutoUpdateSettings defines the settings for automatic edge updates. diff --git a/internal/apps/edge/heartbeat/loop.go b/backend/OpenFlare/share/edge/heartbeat/loop.go similarity index 100% rename from internal/apps/edge/heartbeat/loop.go rename to backend/OpenFlare/share/edge/heartbeat/loop.go diff --git a/internal/apps/edge/heartbeat/loop_test.go b/backend/OpenFlare/share/edge/heartbeat/loop_test.go similarity index 100% rename from internal/apps/edge/heartbeat/loop_test.go rename to backend/OpenFlare/share/edge/heartbeat/loop_test.go diff --git a/internal/apps/edge/httpclient/client.go b/backend/OpenFlare/share/edge/httpclient/client.go similarity index 100% rename from internal/apps/edge/httpclient/client.go rename to backend/OpenFlare/share/edge/httpclient/client.go diff --git a/internal/apps/edge/logging/logging.go b/backend/OpenFlare/share/edge/logging/logging.go similarity index 100% rename from internal/apps/edge/logging/logging.go rename to backend/OpenFlare/share/edge/logging/logging.go diff --git a/internal/apps/edge/nodeip/nodeip.go b/backend/OpenFlare/share/edge/nodeip/nodeip.go similarity index 96% rename from internal/apps/edge/nodeip/nodeip.go rename to backend/OpenFlare/share/edge/nodeip/nodeip.go index 2a3a5e96..6a52a540 100644 --- a/internal/apps/edge/nodeip/nodeip.go +++ b/backend/OpenFlare/share/edge/nodeip/nodeip.go @@ -10,8 +10,8 @@ import ( "sync" "time" - "github.com/Rain-kl/Wavelet/pkg/geoip" - "github.com/Rain-kl/Wavelet/pkg/geoip/iputil" + "Wavelet/OpenFlare/share/geoip" + "Wavelet/OpenFlare/share/geoip/iputil" ) const ( diff --git a/internal/apps/edge/observability/linux.go b/backend/OpenFlare/share/edge/observability/linux.go similarity index 100% rename from internal/apps/edge/observability/linux.go rename to backend/OpenFlare/share/edge/observability/linux.go diff --git a/internal/apps/edge/observability/linux_test.go b/backend/OpenFlare/share/edge/observability/linux_test.go similarity index 100% rename from internal/apps/edge/observability/linux_test.go rename to backend/OpenFlare/share/edge/observability/linux_test.go diff --git a/internal/apps/edge/runner/wsreconnect.go b/backend/OpenFlare/share/edge/runner/wsreconnect.go similarity index 100% rename from internal/apps/edge/runner/wsreconnect.go rename to backend/OpenFlare/share/edge/runner/wsreconnect.go diff --git a/internal/apps/edge/updater/restart_unix.go b/backend/OpenFlare/share/edge/updater/restart_unix.go similarity index 100% rename from internal/apps/edge/updater/restart_unix.go rename to backend/OpenFlare/share/edge/updater/restart_unix.go diff --git a/internal/apps/edge/updater/restart_unix_test.go b/backend/OpenFlare/share/edge/updater/restart_unix_test.go similarity index 100% rename from internal/apps/edge/updater/restart_unix_test.go rename to backend/OpenFlare/share/edge/updater/restart_unix_test.go diff --git a/internal/apps/edge/updater/restart_windows.go b/backend/OpenFlare/share/edge/updater/restart_windows.go similarity index 100% rename from internal/apps/edge/updater/restart_windows.go rename to backend/OpenFlare/share/edge/updater/restart_windows.go diff --git a/internal/apps/edge/updater/service.go b/backend/OpenFlare/share/edge/updater/service.go similarity index 99% rename from internal/apps/edge/updater/service.go rename to backend/OpenFlare/share/edge/updater/service.go index 26ed56a9..10980889 100644 --- a/internal/apps/edge/updater/service.go +++ b/backend/OpenFlare/share/edge/updater/service.go @@ -19,7 +19,7 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/pkg/util" + "Wavelet/pkg/util" ) const ( diff --git a/internal/apps/edge/updater/service_test.go b/backend/OpenFlare/share/edge/updater/service_test.go similarity index 100% rename from internal/apps/edge/updater/service_test.go rename to backend/OpenFlare/share/edge/updater/service_test.go diff --git a/internal/apps/edge/wsclient/client.go b/backend/OpenFlare/share/edge/wsclient/client.go similarity index 97% rename from internal/apps/edge/wsclient/client.go rename to backend/OpenFlare/share/edge/wsclient/client.go index ce3e1de9..51aff3bd 100644 --- a/internal/apps/edge/wsclient/client.go +++ b/backend/OpenFlare/share/edge/wsclient/client.go @@ -8,8 +8,8 @@ import ( "context" "time" - pkgprotocol "github.com/Rain-kl/Wavelet/pkg/protocol" - shared "github.com/Rain-kl/Wavelet/pkg/wsclient" + pkgprotocol "Wavelet/OpenFlare/share/protocol" + shared "Wavelet/OpenFlare/share/wsclient" ) // WSMessage is an alias for the shared WebSocket message type. diff --git a/internal/apps/edge/wsclient/client_test.go b/backend/OpenFlare/share/edge/wsclient/client_test.go similarity index 100% rename from internal/apps/edge/wsclient/client_test.go rename to backend/OpenFlare/share/edge/wsclient/client_test.go diff --git a/pkg/geoip/country_centroid.go b/backend/OpenFlare/share/geoip/country_centroid.go similarity index 100% rename from pkg/geoip/country_centroid.go rename to backend/OpenFlare/share/geoip/country_centroid.go diff --git a/pkg/geoip/country_centroid_test.go b/backend/OpenFlare/share/geoip/country_centroid_test.go similarity index 100% rename from pkg/geoip/country_centroid_test.go rename to backend/OpenFlare/share/geoip/country_centroid_test.go diff --git a/pkg/geoip/emptyProvider.go b/backend/OpenFlare/share/geoip/emptyProvider.go similarity index 100% rename from pkg/geoip/emptyProvider.go rename to backend/OpenFlare/share/geoip/emptyProvider.go diff --git a/pkg/geoip/geoip.go b/backend/OpenFlare/share/geoip/geoip.go similarity index 100% rename from pkg/geoip/geoip.go rename to backend/OpenFlare/share/geoip/geoip.go diff --git a/pkg/geoip/geoip_test.go b/backend/OpenFlare/share/geoip/geoip_test.go similarity index 100% rename from pkg/geoip/geoip_test.go rename to backend/OpenFlare/share/geoip/geoip_test.go diff --git a/pkg/geoip/geojs.go b/backend/OpenFlare/share/geoip/geojs.go similarity index 100% rename from pkg/geoip/geojs.go rename to backend/OpenFlare/share/geoip/geojs.go diff --git a/pkg/geoip/ipapi.go b/backend/OpenFlare/share/geoip/ipapi.go similarity index 100% rename from pkg/geoip/ipapi.go rename to backend/OpenFlare/share/geoip/ipapi.go diff --git a/pkg/geoip/ipinfo.go b/backend/OpenFlare/share/geoip/ipinfo.go similarity index 100% rename from pkg/geoip/ipinfo.go rename to backend/OpenFlare/share/geoip/ipinfo.go diff --git a/pkg/geoip/iputil/iputil.go b/backend/OpenFlare/share/geoip/iputil/iputil.go similarity index 100% rename from pkg/geoip/iputil/iputil.go rename to backend/OpenFlare/share/geoip/iputil/iputil.go diff --git a/pkg/geoip/iputil/iputil_test.go b/backend/OpenFlare/share/geoip/iputil/iputil_test.go similarity index 100% rename from pkg/geoip/iputil/iputil_test.go rename to backend/OpenFlare/share/geoip/iputil/iputil_test.go diff --git a/pkg/geoip/mmdb.go b/backend/OpenFlare/share/geoip/mmdb.go similarity index 100% rename from pkg/geoip/mmdb.go rename to backend/OpenFlare/share/geoip/mmdb.go diff --git a/pkg/geoip/outboundip.go b/backend/OpenFlare/share/geoip/outboundip.go similarity index 99% rename from pkg/geoip/outboundip.go rename to backend/OpenFlare/share/geoip/outboundip.go index 50990dba..49196350 100644 --- a/pkg/geoip/outboundip.go +++ b/backend/OpenFlare/share/geoip/outboundip.go @@ -14,7 +14,7 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/pkg/geoip/iputil" + "Wavelet/OpenFlare/share/geoip/iputil" ) const defaultOutboundIPLookupTimeout = 5 * time.Second diff --git a/pkg/geoip/outboundip_test.go b/backend/OpenFlare/share/geoip/outboundip_test.go similarity index 100% rename from pkg/geoip/outboundip_test.go rename to backend/OpenFlare/share/geoip/outboundip_test.go diff --git a/pkg/pagesarchive/entry.go b/backend/OpenFlare/share/pagesarchive/entry.go similarity index 100% rename from pkg/pagesarchive/entry.go rename to backend/OpenFlare/share/pagesarchive/entry.go diff --git a/pkg/pagesarchive/extract.go b/backend/OpenFlare/share/pagesarchive/extract.go similarity index 100% rename from pkg/pagesarchive/extract.go rename to backend/OpenFlare/share/pagesarchive/extract.go diff --git a/pkg/pagesarchive/format.go b/backend/OpenFlare/share/pagesarchive/format.go similarity index 100% rename from pkg/pagesarchive/format.go rename to backend/OpenFlare/share/pagesarchive/format.go diff --git a/pkg/pagesarchive/inspect.go b/backend/OpenFlare/share/pagesarchive/inspect.go similarity index 100% rename from pkg/pagesarchive/inspect.go rename to backend/OpenFlare/share/pagesarchive/inspect.go diff --git a/pkg/pagesarchive/limits.go b/backend/OpenFlare/share/pagesarchive/limits.go similarity index 100% rename from pkg/pagesarchive/limits.go rename to backend/OpenFlare/share/pagesarchive/limits.go diff --git a/pkg/pagesarchive/list.go b/backend/OpenFlare/share/pagesarchive/list.go similarity index 100% rename from pkg/pagesarchive/list.go rename to backend/OpenFlare/share/pagesarchive/list.go diff --git a/pkg/pagesarchive/pagesarchive_test.go b/backend/OpenFlare/share/pagesarchive/pagesarchive_test.go similarity index 100% rename from pkg/pagesarchive/pagesarchive_test.go rename to backend/OpenFlare/share/pagesarchive/pagesarchive_test.go diff --git a/pkg/pagesarchive/path.go b/backend/OpenFlare/share/pagesarchive/path.go similarity index 100% rename from pkg/pagesarchive/path.go rename to backend/OpenFlare/share/pagesarchive/path.go diff --git a/pkg/pagesarchive/security_test.go b/backend/OpenFlare/share/pagesarchive/security_test.go similarity index 100% rename from pkg/pagesarchive/security_test.go rename to backend/OpenFlare/share/pagesarchive/security_test.go diff --git a/pkg/protocol/agent.go b/backend/OpenFlare/share/protocol/agent.go similarity index 100% rename from pkg/protocol/agent.go rename to backend/OpenFlare/share/protocol/agent.go diff --git a/pkg/protocol/agent_test.go b/backend/OpenFlare/share/protocol/agent_test.go similarity index 100% rename from pkg/protocol/agent_test.go rename to backend/OpenFlare/share/protocol/agent_test.go diff --git a/pkg/protocol/server.go b/backend/OpenFlare/share/protocol/server.go similarity index 100% rename from pkg/protocol/server.go rename to backend/OpenFlare/share/protocol/server.go diff --git a/pkg/protocol/toml.go b/backend/OpenFlare/share/protocol/toml.go similarity index 100% rename from pkg/protocol/toml.go rename to backend/OpenFlare/share/protocol/toml.go diff --git a/pkg/protocol/toml_test.go b/backend/OpenFlare/share/protocol/toml_test.go similarity index 100% rename from pkg/protocol/toml_test.go rename to backend/OpenFlare/share/protocol/toml_test.go diff --git a/pkg/protocol/waf_ip_group_snapshot.go b/backend/OpenFlare/share/protocol/waf_ip_group_snapshot.go similarity index 100% rename from pkg/protocol/waf_ip_group_snapshot.go rename to backend/OpenFlare/share/protocol/waf_ip_group_snapshot.go diff --git a/pkg/protocol/waf_ip_group_snapshot_test.go b/backend/OpenFlare/share/protocol/waf_ip_group_snapshot_test.go similarity index 100% rename from pkg/protocol/waf_ip_group_snapshot_test.go rename to backend/OpenFlare/share/protocol/waf_ip_group_snapshot_test.go diff --git a/pkg/render/openresty/origin_error_page.go b/backend/OpenFlare/share/render/openresty/origin_error_page.go similarity index 100% rename from pkg/render/openresty/origin_error_page.go rename to backend/OpenFlare/share/render/openresty/origin_error_page.go diff --git a/pkg/render/openresty/origin_error_page_test.go b/backend/OpenFlare/share/render/openresty/origin_error_page_test.go similarity index 100% rename from pkg/render/openresty/origin_error_page_test.go rename to backend/OpenFlare/share/render/openresty/origin_error_page_test.go diff --git a/pkg/render/openresty/render.go b/backend/OpenFlare/share/render/openresty/render.go similarity index 100% rename from pkg/render/openresty/render.go rename to backend/OpenFlare/share/render/openresty/render.go diff --git a/pkg/render/openresty/render_route.go b/backend/OpenFlare/share/render/openresty/render_route.go similarity index 100% rename from pkg/render/openresty/render_route.go rename to backend/OpenFlare/share/render/openresty/render_route.go diff --git a/pkg/render/openresty/render_test.go b/backend/OpenFlare/share/render/openresty/render_test.go similarity index 100% rename from pkg/render/openresty/render_test.go rename to backend/OpenFlare/share/render/openresty/render_test.go diff --git a/pkg/render/openresty/service_worker.go b/backend/OpenFlare/share/render/openresty/service_worker.go similarity index 100% rename from pkg/render/openresty/service_worker.go rename to backend/OpenFlare/share/render/openresty/service_worker.go diff --git a/pkg/render/openresty/service_worker_test.go b/backend/OpenFlare/share/render/openresty/service_worker_test.go similarity index 100% rename from pkg/render/openresty/service_worker_test.go rename to backend/OpenFlare/share/render/openresty/service_worker_test.go diff --git a/pkg/render/openresty/status_codes.go b/backend/OpenFlare/share/render/openresty/status_codes.go similarity index 100% rename from pkg/render/openresty/status_codes.go rename to backend/OpenFlare/share/render/openresty/status_codes.go diff --git a/pkg/render/openresty/status_codes_test.go b/backend/OpenFlare/share/render/openresty/status_codes_test.go similarity index 100% rename from pkg/render/openresty/status_codes_test.go rename to backend/OpenFlare/share/render/openresty/status_codes_test.go diff --git a/pkg/render/openresty/types.go b/backend/OpenFlare/share/render/openresty/types.go similarity index 100% rename from pkg/render/openresty/types.go rename to backend/OpenFlare/share/render/openresty/types.go diff --git a/pkg/wsclient/client.go b/backend/OpenFlare/share/wsclient/client.go similarity index 99% rename from pkg/wsclient/client.go rename to backend/OpenFlare/share/wsclient/client.go index 9af9dca3..dd501c84 100644 --- a/pkg/wsclient/client.go +++ b/backend/OpenFlare/share/wsclient/client.go @@ -16,7 +16,8 @@ import ( "sync" "time" - "github.com/Rain-kl/Wavelet/pkg/util" + "Wavelet/pkg/util" + "golang.org/x/net/websocket" ) diff --git a/backend/OpenFlare/upstream-patches.md b/backend/OpenFlare/upstream-patches.md new file mode 100644 index 00000000..39f17549 --- /dev/null +++ b/backend/OpenFlare/upstream-patches.md @@ -0,0 +1,28 @@ +# 上游本地补丁清单 + +`scripts/sync-upstream.sh` 会用上游覆盖 `backend/{core,pkg,plugins}`。以下文件带有 +OpenFlare 侧必需的内核补丁:同步后若丢失,`go build ./...` 或用例集会失败, +按本表重新应用,并应回流 Wavelet 上游后删除本条记录。 + +- `core/extpoints/router.go` +- `core/scoped_extpoints.go` + +## core:RouterExtension 增加 HandleRaw / BasePath + +`Handle` 经 `cleanPath` 归一化会剥掉尾部斜杠,无法表达 `/resource` 与 `/resource/` +两条不同路由;OpenFlare 的 20 个历史 list 端点两者都注册且部署关闭了 +`RedirectTrailingSlash`,缺失即 404。 + +- `HandleRaw(method, path, handlers...)`:与组前缀拼接但**保留**尾部斜杠; +- `BasePath()`:返回组绝对前缀(根注册表为空串); +- 作用域包装器为 `HandleRaw` 同样登记 `OnDispose` 反注册; +- 用例:`core/extpoints/router_raw_test.go`。 + +## 回流状态 + +上述内核补丁与 `pkg/util` 新增助手已提交到 Wavelet 分支 +`feat/cordis-router-raw-routes`(worktree `/Users/ryan/Code/Go/Wavelet-router-raw`, +提交 `cb339ab`、`bad6fa7`、`8ff017b`)。**合并进 Wavelet main 后**:重跑 +`scripts/sync-upstream.sh`,确认 `sync-upstream.sh --check` 零差异,然后清空本清单。 + +`pkg/util` 的版本比较 / 网络 / 格式化助手已回流,当前零漂移。 diff --git a/backend/cmd/agent/main.go b/backend/cmd/agent/main.go new file mode 100644 index 00000000..b84d3344 --- /dev/null +++ b/backend/cmd/agent/main.go @@ -0,0 +1,41 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Command agent runs the OpenFlare edge agent daemon. +package main + +import ( + "flag" + "log/slog" + "os" + "time" + + agentplugin "Wavelet/OpenFlare/plugins/agent" + "Wavelet/OpenFlare/plugins/agent/logging" + "Wavelet/core" +) + +// shutdownTimeout 为 openresty 收敛与在途配置同步预留的退出窗口。 +const shutdownTimeout = 60 * time.Second + +func main() { + logging.Setup() + + configPath := flag.String("config", "./agent.json", "agent config path") + flag.Parse() + + app := core.NewApp( + core.WithProfile(core.Profile(agentplugin.DriverTypeAgent)), + core.WithShutdownTimeout(shutdownTimeout), + ) + app.Use(agentplugin.New(*configPath)) + + if err := app.Prepare(); err != nil { + slog.Error("agent startup failed", "error", err) + os.Exit(1) + } + if err := app.Run(); err != nil { + slog.Error("agent process exited with error", "error", err) + os.Exit(1) + } +} diff --git a/internal/cmd/all.go b/backend/cmd/all.go similarity index 64% rename from internal/cmd/all.go rename to backend/cmd/all.go index 9da6e689..f9959934 100644 --- a/internal/cmd/all.go +++ b/backend/cmd/all.go @@ -9,10 +9,10 @@ import ( "log" "sync" - "github.com/Rain-kl/Wavelet/internal/infra/task/scheduler" - "github.com/Rain-kl/Wavelet/internal/infra/task/worker" - "github.com/Rain-kl/Wavelet/internal/platform/bootstrap" - "github.com/Rain-kl/Wavelet/internal/router" + "Wavelet/OpenFlare/plugins/server/infra/task/scheduler" + "Wavelet/OpenFlare/plugins/server/infra/task/worker" + "Wavelet/OpenFlare/plugins/server/platform/bootstrap" + "github.com/spf13/cobra" ) @@ -26,14 +26,6 @@ var allCmd = &cobra.Command{ var wg sync.WaitGroup - // 启动 API HTTP 服务 - wg.Go(func() { - log.Println("[All] 启动 API 服务") - router.Serve(func() { - printStartupBanner(startupState{mode: "API + Worker + Scheduler", relationalDB: latestMigrationState.relationalDB, clickHouseDB: latestMigrationState.clickHouseDB, listensForHTTP: true}) - }) - }) - // 启动 Asynq Worker 任务处理服务 wg.Go(func() { log.Println("[All] 启动 Worker 服务") @@ -50,6 +42,8 @@ var allCmd = &cobra.Command{ } }) + // API 服务持有前台阻塞与退出信号处理(runHTTPApp 返回后即已优雅退出) + runHTTPApp("API + Worker + Scheduler") wg.Wait() }, } diff --git a/internal/cmd/api.go b/backend/cmd/api.go similarity index 52% rename from internal/cmd/api.go rename to backend/cmd/api.go index 1562cdf1..b3e1ca51 100644 --- a/internal/cmd/api.go +++ b/backend/cmd/api.go @@ -5,8 +5,8 @@ package cmd import ( - "github.com/Rain-kl/Wavelet/internal/platform/bootstrap" - "github.com/Rain-kl/Wavelet/internal/router" + "Wavelet/OpenFlare/plugins/server/platform/bootstrap" + "github.com/spf13/cobra" ) @@ -16,8 +16,6 @@ var apiCmd = &cobra.Command{ Run: func(_ *cobra.Command, _ []string) { bootstrap.RegisterAPI() runBootstrap(bootstrap.Options{API: true}) - router.Serve(func() { - printStartupBanner(startupState{mode: "API", relationalDB: latestMigrationState.relationalDB, clickHouseDB: latestMigrationState.clickHouseDB, listensForHTTP: true}) - }) + runHTTPApp("API") }, } diff --git a/internal/cmd/banner.go b/backend/cmd/banner.go similarity index 90% rename from internal/cmd/banner.go rename to backend/cmd/banner.go index ae354361..dfdb90ee 100644 --- a/internal/cmd/banner.go +++ b/backend/cmd/banner.go @@ -9,9 +9,9 @@ import ( "runtime" "strings" - "github.com/Rain-kl/Wavelet/internal/buildinfo" - "github.com/Rain-kl/Wavelet/internal/infra/config" - "github.com/Rain-kl/Wavelet/internal/infra/persistence/migrator" + "Wavelet/OpenFlare/plugins/server/infra/config" + "Wavelet/OpenFlare/plugins/server/infra/persistence/migrator" + "Wavelet/pkg/buildinfo" ) type startupState struct { diff --git a/internal/cmd/banner_test.go b/backend/cmd/banner_test.go similarity index 88% rename from internal/cmd/banner_test.go rename to backend/cmd/banner_test.go index 4b4797e7..4d029dab 100644 --- a/internal/cmd/banner_test.go +++ b/backend/cmd/banner_test.go @@ -7,9 +7,9 @@ import ( "strings" "testing" - "github.com/Rain-kl/Wavelet/internal/buildinfo" - "github.com/Rain-kl/Wavelet/internal/infra/config" - "github.com/Rain-kl/Wavelet/internal/infra/persistence/migrator" + "Wavelet/OpenFlare/plugins/server/infra/config" + "Wavelet/OpenFlare/plugins/server/infra/persistence/migrator" + "Wavelet/pkg/buildinfo" ) func TestFormatStartupBanner(t *testing.T) { diff --git a/internal/cmd/bootstrap.go b/backend/cmd/bootstrap.go similarity index 72% rename from internal/cmd/bootstrap.go rename to backend/cmd/bootstrap.go index 2460cdaf..421c0337 100644 --- a/internal/cmd/bootstrap.go +++ b/backend/cmd/bootstrap.go @@ -6,8 +6,8 @@ package cmd import ( "context" - "github.com/Rain-kl/Wavelet/internal/platform/bootstrap" - "github.com/Rain-kl/Wavelet/pkg/trace" + "Wavelet/OpenFlare/plugins/server/platform/bootstrap" + "Wavelet/pkg/trace" ) func runBootstrap(opts bootstrap.Options) { diff --git a/backend/cmd/flared/main.go b/backend/cmd/flared/main.go new file mode 100644 index 00000000..249cdd3f --- /dev/null +++ b/backend/cmd/flared/main.go @@ -0,0 +1,41 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Command flared runs the OpenFlare tunnel client daemon. +package main + +import ( + "flag" + "log/slog" + "os" + "time" + + flaredplugin "Wavelet/OpenFlare/plugins/flared" + edgelogging "Wavelet/OpenFlare/share/edge/logging" + "Wavelet/core" +) + +// shutdownTimeout 为 frpc 子进程收敛预留的退出窗口。 +const shutdownTimeout = 60 * time.Second + +func main() { + edgelogging.Setup(edgelogging.Options{}) + + configPath := flag.String("config", "./flared.json", "flared config path") + flag.Parse() + + app := core.NewApp( + core.WithProfile(core.Profile(flaredplugin.DriverTypeFlared)), + core.WithShutdownTimeout(shutdownTimeout), + ) + app.Use(flaredplugin.New(*configPath)) + + if err := app.Prepare(); err != nil { + slog.Error("flared startup failed", "error", err) + os.Exit(1) + } + if err := app.Run(); err != nil { + slog.Error("flared process exited with error", "error", err) + os.Exit(1) + } +} diff --git a/backend/cmd/http_server.go b/backend/cmd/http_server.go new file mode 100644 index 00000000..0fe41a8a --- /dev/null +++ b/backend/cmd/http_server.go @@ -0,0 +1,66 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "context" + "log" + "time" + + "Wavelet/OpenFlare/plugins/server" + "Wavelet/OpenFlare/plugins/server/infra/config" + "Wavelet/OpenFlare/plugins/server/platform/bootstrap" + "Wavelet/OpenFlare/plugins/server/router" + "Wavelet/core" + "Wavelet/plugins/drivers/driver_http" +) + +// defaultShutdownTimeout 在配置未给出优雅退出超时时兜底。 +const defaultShutdownTimeout = 10 * time.Second + +// runHTTPApp 以 Cordis 方式启动控制面:装配根持有 gin engine(引擎级中间件与前端 +// SPA 兜底),server 插件经 ctx.Router() 声明路由,driver_http 负责监听与优雅退出。 +// 本函数阻塞至收到退出信号。 +func runHTTPApp(mode string) { + engine := router.BuildEngine() + timeout := shutdownTimeout() + + app := core.NewApp( + core.WithProfile(core.ProfileAPI), + core.WithShutdownTimeout(timeout), + ) + httpDriver := driver_http.New( + driver_http.WithEngine(engine), + driver_http.WithAddr(config.Config.App.Addr), + ) + app.Use(server.New()) + app.Use(httpDriver) + + if err := app.Prepare(); err != nil { + log.Fatalf("[API] prepare failed: %v\n", err) + } + + printStartupBanner(startupState{ + mode: mode, + relationalDB: latestMigrationState.relationalDB, + clickHouseDB: latestMigrationState.clickHouseDB, + listensForHTTP: true, + }) + + if err := app.Run(); err != nil { + log.Printf("[API] server failed: %v\n", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + bootstrap.Stop(ctx) + log.Println("[API] server exited") +} + +func shutdownTimeout() time.Duration { + if config.Config.App.GracefulShutdownTimeout <= 0 { + return defaultShutdownTimeout + } + return time.Duration(config.Config.App.GracefulShutdownTimeout) * time.Second +} diff --git a/backend/cmd/relay/main.go b/backend/cmd/relay/main.go new file mode 100644 index 00000000..393e234f --- /dev/null +++ b/backend/cmd/relay/main.go @@ -0,0 +1,41 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Command relay runs the OpenFlare relay node daemon. +package main + +import ( + "flag" + "log/slog" + "os" + "time" + + relayplugin "Wavelet/OpenFlare/plugins/relay" + edgelogging "Wavelet/OpenFlare/share/edge/logging" + "Wavelet/core" +) + +// shutdownTimeout 为 frps 子进程收敛预留的退出窗口。 +const shutdownTimeout = 60 * time.Second + +func main() { + edgelogging.Setup(edgelogging.Options{}) + + configPath := flag.String("config", "./relay.json", "relay config path") + flag.Parse() + + app := core.NewApp( + core.WithProfile(core.Profile(relayplugin.DriverTypeRelay)), + core.WithShutdownTimeout(shutdownTimeout), + ) + app.Use(relayplugin.New(*configPath)) + + if err := app.Prepare(); err != nil { + slog.Error("relay startup failed", "error", err) + os.Exit(1) + } + if err := app.Run(); err != nil { + slog.Error("relay process exited with error", "error", err) + os.Exit(1) + } +} diff --git a/internal/cmd/reset_passwd.go b/backend/cmd/reset_passwd.go similarity index 89% rename from internal/cmd/reset_passwd.go rename to backend/cmd/reset_passwd.go index 2b8c39ed..944f4cd9 100644 --- a/internal/cmd/reset_passwd.go +++ b/backend/cmd/reset_passwd.go @@ -13,12 +13,13 @@ import ( "os" "strings" - "github.com/Rain-kl/Wavelet/internal/apps/oauth" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/infra/persistence/migrator" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/platform/bootstrap" - "github.com/Rain-kl/Wavelet/internal/repository" + db "Wavelet/OpenFlare/plugins/server/infra/persistence" + "Wavelet/OpenFlare/plugins/server/infra/persistence/migrator" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/oauth" + "Wavelet/OpenFlare/plugins/server/platform/bootstrap" + "Wavelet/OpenFlare/plugins/server/repository" + "github.com/spf13/cobra" "gorm.io/gorm" ) diff --git a/internal/cmd/reset_passwd_test.go b/backend/cmd/reset_passwd_test.go similarity index 98% rename from internal/cmd/reset_passwd_test.go rename to backend/cmd/reset_passwd_test.go index 2acae0e8..d95254e0 100644 --- a/internal/cmd/reset_passwd_test.go +++ b/backend/cmd/reset_passwd_test.go @@ -10,8 +10,8 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/testhelper" + "Wavelet/OpenFlare/plugins/server/model" + "Wavelet/OpenFlare/plugins/server/testhelper" ) func TestResetPasswdCmd_WithUserAndPassword(t *testing.T) { diff --git a/internal/cmd/root.go b/backend/cmd/root.go similarity index 90% rename from internal/cmd/root.go rename to backend/cmd/root.go index d30ea4b7..93c48f74 100644 --- a/internal/cmd/root.go +++ b/backend/cmd/root.go @@ -9,11 +9,12 @@ import ( "log" "time" - "github.com/Rain-kl/Wavelet/internal/buildinfo" - "github.com/Rain-kl/Wavelet/internal/infra/config" - "github.com/Rain-kl/Wavelet/internal/infra/persistence/migrator" - "github.com/Rain-kl/Wavelet/pkg/logger" - "github.com/Rain-kl/Wavelet/pkg/trace" + "Wavelet/OpenFlare/plugins/server/infra/config" + "Wavelet/OpenFlare/plugins/server/infra/persistence/migrator" + "Wavelet/pkg/buildinfo" + "Wavelet/pkg/logger" + "Wavelet/pkg/trace" + "github.com/spf13/cobra" ) diff --git a/internal/cmd/scheduler.go b/backend/cmd/scheduler.go similarity index 84% rename from internal/cmd/scheduler.go rename to backend/cmd/scheduler.go index 75f0a022..53e6aa21 100644 --- a/internal/cmd/scheduler.go +++ b/backend/cmd/scheduler.go @@ -7,8 +7,8 @@ package cmd import ( "log" - "github.com/Rain-kl/Wavelet/internal/infra/task/scheduler" - "github.com/Rain-kl/Wavelet/internal/platform/bootstrap" + "Wavelet/OpenFlare/plugins/server/infra/task/scheduler" + "Wavelet/OpenFlare/plugins/server/platform/bootstrap" "github.com/spf13/cobra" ) diff --git a/internal/cmd/worker.go b/backend/cmd/worker.go similarity index 84% rename from internal/cmd/worker.go rename to backend/cmd/worker.go index 0c4d42be..d968fec0 100644 --- a/internal/cmd/worker.go +++ b/backend/cmd/worker.go @@ -7,8 +7,8 @@ package cmd import ( "log" - "github.com/Rain-kl/Wavelet/internal/infra/task/worker" - "github.com/Rain-kl/Wavelet/internal/platform/bootstrap" + "Wavelet/OpenFlare/plugins/server/infra/task/worker" + "Wavelet/OpenFlare/plugins/server/platform/bootstrap" "github.com/spf13/cobra" ) diff --git a/backend/core/app.go b/backend/core/app.go new file mode 100644 index 00000000..89af124c --- /dev/null +++ b/backend/core/app.go @@ -0,0 +1,697 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package core + +import ( + "context" + "errors" + "fmt" + "os" + "os/signal" + "strings" + "sync" + "syscall" + "time" +) + +const ( + defaultShutdownTimeout = 10 * time.Second +) + +// AppOption configures an App instance during construction. +type AppOption func(*App) + +// WithContext sets a custom root Context for the App. +func WithContext(ctx *Context) AppOption { + return func(a *App) { + if ctx != nil { + a.ctx = ctx + } + } +} + +// WithProfile sets the runtime profile for the App. +func WithProfile(profile Profile) AppOption { + return func(a *App) { + a.profile = normalizeProfile(profile) + } +} + +// WithPlugins registers initial plugins for the App. +func WithPlugins(plugins ...Plugin) AppOption { + return func(a *App) { + a.Use(plugins...) + } +} + +// WithMigrationEngine sets the database migration engine for the App. +func WithMigrationEngine(engine MigrationEngine) AppOption { + return func(a *App) { + a.migrationEngine = engine + } +} + +// WithMigrationRunner sets the migration runner function for the App. +func WithMigrationRunner(runner MigrationRunner) AppOption { + return func(a *App) { + a.migrationEngine = runner + } +} + +// WithShutdownTimeout sets the fallback timeout for graceful application shutdown. +func WithShutdownTimeout(timeout time.Duration) AppOption { + return func(a *App) { + if timeout > 0 { + a.shutdownTimeout = timeout + } + } +} + +// WithConfigSource installs the raw configuration source adapter, typically built by an +// infrastructure package outside the kernel, before any plugin is applied. +func WithConfigSource(src ConfigSource) AppOption { + return func(a *App) { + if src == nil { + return + } + // Installed during Prepare so the option order, including WithContext, is irrelevant. + a.configSource = src + } +} + +// WithConfigDecl lets the composition root declare the configuration it reads itself, +// so host-level values take part in conflict validation and the redacted report. The +// bindings are registered during Prepare, so option order does not matter. +func WithConfigDecl(pluginID string, bindings ...ConfigBinding) AppOption { + return func(a *App) { + if len(bindings) == 0 { + return + } + if a.hostDeclOwner == "" { + a.hostDeclOwner = pluginID + } + a.hostDeclBindings = append(a.hostDeclBindings, bindings...) + } +} + +// App is the unified assembly entrypoint and runtime aspect dispatcher of the Cordis micro-kernel. +// It manages plugin collection, dependency mounting, migration execution, profile-based driver startup, +// and graceful signal-driven LIFO shutdown. +type App struct { + mu sync.RWMutex + ctx *Context + profile Profile + plugins []Plugin + pluginMap map[string]Plugin + fibers []*Fiber + fiberMap map[string]*Fiber + applied bool + running bool + startedDrivers []Driver + migrationEngine MigrationEngine + shutdownTimeout time.Duration + configSource ConfigSource + hostDeclOwner string + hostDeclBindings []ConfigBinding + prepared bool + applyErr error +} + +// NewApp creates a new Cordis application instance with default options. +func NewApp(opts ...AppOption) *App { + app := &App{ + ctx: NewContext(context.Background()), + profile: ProfileAll, + pluginMap: make(map[string]Plugin), + fiberMap: make(map[string]*Fiber), + shutdownTimeout: defaultShutdownTimeout, + } + + for _, opt := range opts { + if opt != nil { + opt(app) + } + } + + return app +} + +// Context returns the root micro-kernel Context of the application. +func (a *App) Context() *Context { + return a.ctx +} + +// Profile returns the current runtime profile of the application. +func (a *App) Profile() Profile { + a.mu.RLock() + defer a.mu.RUnlock() + return a.profile +} + +// WithProfile sets the application runtime profile and returns the App for fluent chaining. +func (a *App) WithProfile(profile Profile) *App { + a.mu.Lock() + defer a.mu.Unlock() + a.profile = normalizeProfile(profile) + return a +} + +// SetProfile sets the application runtime profile. +func (a *App) SetProfile(profile Profile) *App { + return a.WithProfile(profile) +} + +// Use registers one or more plugins into the application in registration order. +// Duplicate plugins (by Name) update existing registrations in-place to preserve order. +func (a *App) Use(plugins ...Plugin) *App { + a.mu.Lock() + defer a.mu.Unlock() + + for _, p := range plugins { + if p == nil { + continue + } + name := p.Name() + if name == "" { + continue + } + + if _, exists := a.pluginMap[name]; exists { + for i, existing := range a.plugins { + if existing.Name() == name { + a.plugins[i] = p + break + } + } + if existingFiber, ok := a.fiberMap[name]; ok { + existingFiber.plugin = p + } + } else { + a.plugins = append(a.plugins, p) + f := NewFiber(a.ctx, p) + a.fibers = append(a.fibers, f) + a.fiberMap[name] = f + } + a.pluginMap[name] = p + + if gated, ok := p.(ConfigGatedPlugin); ok && a.applyErr == nil { + // Gates are evaluated before Apply, so their keys must be declared at mount time. + a.applyErr = a.ctx.Config().Declare(name, gated.DeclareConfig()...) + } + } + + return a +} + +// Plugins returns a copy of all registered plugins in registration order. +func (a *App) Plugins() []Plugin { + a.mu.RLock() + defer a.mu.RUnlock() + + res := make([]Plugin, len(a.plugins)) + copy(res, a.plugins) + return res +} + +// Plugin retrieves a registered plugin by its unique name. +func (a *App) Plugin(name string) (Plugin, bool) { + a.mu.RLock() + defer a.mu.RUnlock() + + p, ok := a.pluginMap[name] + return p, ok +} + +// Fibers returns a copy of all plugin Fibers. +func (a *App) Fibers() []*Fiber { + a.mu.RLock() + defer a.mu.RUnlock() + + res := make([]*Fiber, len(a.fibers)) + copy(res, a.fibers) + return res +} + +// Fiber retrieves a Fiber by its unique plugin name. +func (a *App) Fiber(name string) (*Fiber, bool) { + a.mu.RLock() + defer a.mu.RUnlock() + + f, ok := a.fiberMap[name] + return f, ok +} + +// SetMigrationEngine sets the migration engine for the application. +func (a *App) SetMigrationEngine(engine MigrationEngine) *App { + a.mu.Lock() + defer a.mu.Unlock() + a.migrationEngine = engine + return a +} + +// SetMigrationRunner sets the migration runner function for the application. +func (a *App) SetMigrationRunner(runner MigrationRunner) *App { + return a.SetMigrationEngine(runner) +} + +// Reconcile evaluates all pending Fibers and reactively transitions them to ACTIVE +// as their declared dependencies become satisfied. +func (a *App) Reconcile() error { + a.mu.Lock() + defer a.mu.Unlock() + return a.reconcileLocked() +} + +func (a *App) reconcileLocked() error { + if err := a.prepareLocked(); err != nil { + return err + } + + for { + progress := false + for _, f := range a.fibers { + if f.State() != FiberPending { + continue + } + + gated, skip, err := a.evaluateGateLocked(f) + if err != nil { + return err + } + if gated && skip { + if err := f.Skip(); err != nil { + return fmt.Errorf("core: skip gated fiber %q failed: %w", f.Name(), err) + } + continue + } + + if f.DependenciesSatisfied(a.ctx) { + if err := f.Load(); err != nil { + return fmt.Errorf("core: load fiber %q failed: %w", f.Name(), err) + } + progress = true + } + } + if !progress { + break + } + } + + var unsatisfied []string + for _, f := range a.fibers { + if f.State() == FiberPending { + unsatisfied = append(unsatisfied, fmt.Sprintf("%s (waiting for %v)", f.Name(), f.Dependencies())) + } + } + if len(unsatisfied) > 0 { + return fmt.Errorf("core: unsatisfied dependencies for plugins: %s", strings.Join(unsatisfied, ", ")) + } + + return nil +} + +// evaluateGateLocked reports whether a configuration-gated plugin is excluded by the +// resolved values. Plugins that do not implement the gate interface are never skipped. +func (a *App) evaluateGateLocked(f *Fiber) (gated bool, skip bool, err error) { + gatedPlugin, ok := f.plugin.(ConfigGatedPlugin) + if !ok { + return false, false, nil + } + + view := a.ctx.Config() + if !view.Resolved() { + return true, false, fmt.Errorf( + "core: plugin %q is configuration-gated but the App has no ConfigSource; "+ + "pass core.WithConfigSource or remove DeclareConfig", f.Name()) + } + + return true, !gatedPlugin.ConfigEnabled(view), nil +} + +// ApplyPlugins applies all registered plugins on the application Context via reactive reconciliation. +// It is idempotent and only applies plugins once per App instance. +func (a *App) ApplyPlugins() error { + a.mu.Lock() + if a.applied { + a.mu.Unlock() + return nil + } + a.applied = true + + declaredErr, prepareErr := a.applyErr, a.prepareLocked() + a.mu.Unlock() + + if declaredErr != nil { + return declaredErr + } + if prepareErr != nil { + return prepareErr + } + + return a.Reconcile() +} + +// Prepare resolves declared configuration and establishes the resolution barrier that +// gates and plugin Bind calls depend on. It is idempotent and runs implicitly from +// ApplyPlugins; callers that need resolved values earlier — for example to size a +// shutdown budget — invoke it explicitly right after mounting plugins. +func (a *App) Prepare() error { + a.mu.Lock() + defer a.mu.Unlock() + + if a.applyErr != nil { + return a.applyErr + } + return a.prepareLocked() +} + +// prepareLocked installs the injected source, registers host declarations and resolves +// every declared key once. An App without a ConfigSource leaves configuration unused, +// so kernel-level usage stays opt-in for embedders that configure nothing. +func (a *App) prepareLocked() error { + if a.prepared { + return nil + } + if a.configSource == nil { + a.prepared = true + return nil + } + + config := a.ctx.Config() + config.SetSource(a.configSource) + + if err := config.Declare(a.hostDeclOwner, a.hostDeclBindings...); err != nil { + return err + } + if err := config.Resolve(); err != nil { + return err + } + a.prepared = true + + return nil +} + +// ShutdownTimeout returns the graceful shutdown budget for the application. +func (a *App) ShutdownTimeout() time.Duration { + a.mu.RLock() + defer a.mu.RUnlock() + return a.shutdownTimeout +} + +// SetShutdownTimeout replaces the graceful shutdown budget, ignoring non-positive +// values so a missing configuration key can never shrink the kernel fallback to zero. +func (a *App) SetShutdownTimeout(timeout time.Duration) *App { + a.mu.Lock() + defer a.mu.Unlock() + if timeout > 0 { + a.shutdownTimeout = timeout + } + return a +} + +// RunMigrations dispatches migration execution across all registered plugin migration entries. +func (a *App) RunMigrations() error { + entries := a.ctx.Migrations().Entries() + if len(entries) == 0 { + return nil + } + + a.mu.RLock() + engine := a.migrationEngine + a.mu.RUnlock() + + if engine == nil { + // Attempt to resolve from IoC container + if resolved, err := Inject[MigrationEngine](a.ctx); err == nil && resolved != nil { + engine = resolved + } + } + + if engine == nil { + return nil + } + + if err := engine.Migrate(a.ctx, entries); err != nil { + return fmt.Errorf("core: migration failed: %w", err) + } + + return nil +} + +// Start executes the application boot pipeline: +// 1. Applies all registered plugins to populate services, routes, tasks, and drivers. +// 2. Dispatches database migrations via MigrationEngine. +// 3. Filters and starts drivers matching the active Profile. +// 4. Emits "app:ready" on the EventBus. +func (a *App) Start(ctx ...context.Context) error { + a.mu.Lock() + if a.running { + a.mu.Unlock() + return ErrAppRunning + } + a.running = true + a.mu.Unlock() + + var baseCtx context.Context + switch { + case len(ctx) > 0 && ctx[0] != nil: + baseCtx = ctx[0] + case a.ctx != nil: + baseCtx = a.ctx.GoContext() + default: + baseCtx = context.Background() + } + + // 1. Apply plugins + if err := a.ApplyPlugins(); err != nil { + a.mu.Lock() + a.running = false + a.mu.Unlock() + return err + } + + // 2. Run migrations + if err := a.RunMigrations(); err != nil { + a.mu.Lock() + a.running = false + a.mu.Unlock() + return err + } + + // 3. Filter drivers matching active profile + a.mu.RLock() + prof := a.profile + a.mu.RUnlock() + + allDrivers := a.ctx.Drivers() + var driversToStart []Driver + for _, d := range allDrivers { + if matchesProfile(prof, d.Type()) { + driversToStart = append(driversToStart, d) + } + } + + // 4. Start matching drivers + for _, d := range driversToStart { + if err := d.Start(baseCtx); err != nil { + // Rollback already started drivers in reverse order + a.mu.Lock() + started := a.startedDrivers + a.startedDrivers = nil + a.running = false + a.mu.Unlock() + + for i := len(started) - 1; i >= 0; i-- { + _ = started[i].Stop(context.Background()) + } + + return fmt.Errorf("core: start driver %s failed: %w", d.Type(), err) + } + + a.mu.Lock() + a.startedDrivers = append(a.startedDrivers, d) + a.mu.Unlock() + } + + // 5. Emit app:ready event + _ = a.ctx.Events().Emit(baseCtx, "app:ready", a) + + return nil +} + +// Stop gracefully shuts down the application: +// 1. Emits "app:stopping" on the EventBus. +// 2. Stops all started drivers in LIFO (reverse) order. +// 3. Disposes the Context (running registered OnDispose callbacks in LIFO order). +// 4. Emits "app:stopped" on the EventBus. +func (a *App) Stop(ctx ...context.Context) error { + a.mu.Lock() + if !a.running { + a.mu.Unlock() + return nil + } + a.running = false + started := a.startedDrivers + a.startedDrivers = nil + timeout := a.shutdownTimeout + a.mu.Unlock() + + var shutdownCtx context.Context + if len(ctx) > 0 && ctx[0] != nil { + shutdownCtx = ctx[0] + } else { + var cancel context.CancelFunc + shutdownCtx, cancel = context.WithTimeout(context.Background(), timeout) + defer cancel() + } + + _ = a.ctx.Events().Emit(shutdownCtx, "app:stopping", a) + + var errs []error + + // 1. Stop drivers in reverse order + for i := len(started) - 1; i >= 0; i-- { + d := started[i] + if err := d.Stop(shutdownCtx); err != nil { + errs = append(errs, fmt.Errorf("core: stop driver %s failed: %w", d.Type(), err)) + } + } + + // 2. Unload fibers in reverse order + a.mu.RLock() + fibers := make([]*Fiber, len(a.fibers)) + copy(fibers, a.fibers) + a.mu.RUnlock() + + for i := len(fibers) - 1; i >= 0; i-- { + if err := fibers[i].Unload(); err != nil { + errs = append(errs, fmt.Errorf("core: unload fiber %s failed: %w", fibers[i].Name(), err)) + } + } + + // 3. Dispose root context + if a.ctx != nil && !a.ctx.IsDisposed() { + if err := a.ctx.Dispose(); err != nil { + errs = append(errs, fmt.Errorf("core: dispose context failed: %w", err)) + } + } + + _ = a.ctx.Events().Emit(shutdownCtx, "app:stopped", a) + + return errors.Join(errs...) +} + +// Run starts the application and blocks until an OS signal (SIGINT, SIGTERM) or context cancellation is received, +// then executes graceful shutdown. It forwards a sigCtx derived from the caller's context to Start. +// +//nolint:contextcheck // the caller's ctx does reach Start via sigCtx; the rule cannot follow Run's variadic context parameter +func (a *App) Run(ctx ...context.Context) error { + var parent context.Context + switch { + case len(ctx) > 0 && ctx[0] != nil: + parent = ctx[0] + case a.ctx != nil: + parent = a.ctx.GoContext() + default: + parent = context.Background() + } + + sigCtx, stopSignals := signal.NotifyContext(parent, syscall.SIGINT, syscall.SIGTERM, os.Interrupt) + defer stopSignals() + + if err := a.Start(sigCtx); err != nil { + return err + } + + // Wait for OS signal or context cancellation + <-sigCtx.Done() + + shutdownCtx, cancel := context.WithTimeout(context.Background(), a.shutdownTimeout) + defer cancel() + + return a.Stop(shutdownCtx) +} + +// IsRunning returns whether the application is currently running. +func (a *App) IsRunning() bool { + a.mu.RLock() + defer a.mu.RUnlock() + return a.running +} + +// StartedDrivers returns a copy of currently running drivers. +func (a *App) StartedDrivers() []Driver { + a.mu.RLock() + defer a.mu.RUnlock() + + res := make([]Driver, len(a.startedDrivers)) + copy(res, a.startedDrivers) + return res +} + +// ExecuteCLI parses CLI arguments to configure the profile and runs the application. +func (a *App) ExecuteCLI(args ...string) error { + var ctx context.Context + if a.ctx != nil { + ctx = a.ctx.GoContext() + } else { + ctx = context.Background() + } + return a.ExecuteCLIWithContext(ctx, args...) +} + +// ExecuteCLIWithContext parses CLI arguments, configures the profile, and runs the application with the given context. +func (a *App) ExecuteCLIWithContext(ctx context.Context, args ...string) error { + cliArgs := args + if len(cliArgs) == 0 { + cliArgs = os.Args[1:] + } + + profile := ProfileAll + if len(cliArgs) > 0 { + first := strings.TrimSpace(cliArgs[0]) + switch { + case strings.HasPrefix(first, "--profile="): + profile = Profile(strings.TrimPrefix(first, "--profile=")) + case strings.HasPrefix(first, "-p="): + profile = Profile(strings.TrimPrefix(first, "-p=")) + case !strings.HasPrefix(first, "-"): + profile = Profile(first) + } + } + + a.WithProfile(profile) + return a.Run(ctx) +} + +func matchesProfile(profile Profile, dt DriverType) bool { + norm := normalizeProfile(profile) + switch norm { + case ProfileAll, "": + return true + case ProfileAPI: + return dt == DriverTypeHTTP + case ProfileWorker: + return dt == DriverTypeWorker + case ProfileSchedule: + return dt == DriverTypeScheduler + default: + return string(norm) == string(dt) + } +} + +func normalizeProfile(p Profile) Profile { + switch strings.ToLower(strings.TrimSpace(string(p))) { + case "api", "http": + return ProfileAPI + case "worker": + return ProfileWorker + case "schedule", "scheduler", "cron": + return ProfileSchedule + case "all", "fused", "full", "": + return ProfileAll + default: + return p + } +} diff --git a/backend/core/app_test.go b/backend/core/app_test.go new file mode 100644 index 00000000..f30f5fa8 --- /dev/null +++ b/backend/core/app_test.go @@ -0,0 +1,596 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package core_test + +import ( + "Wavelet/core" + "Wavelet/core/extpoints" + "context" + "errors" + "sync" + "testing" + "testing/fstest" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// appMockDriver is a test driver tracking its start/stop lifecycle. +type appMockDriver struct { + mu sync.Mutex + driverType core.DriverType + startCalled bool + stopCalled bool + startErr error + stopErr error +} + +func newAppMockDriver(dt core.DriverType) *appMockDriver { + return &appMockDriver{driverType: dt} +} + +func (m *appMockDriver) Type() core.DriverType { + return m.driverType +} + +func (m *appMockDriver) Start(_ context.Context) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.startErr != nil { + return m.startErr + } + m.startCalled = true + return nil +} + +func (m *appMockDriver) Stop(_ context.Context) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.stopErr != nil { + return m.stopErr + } + m.stopCalled = true + return nil +} + +func (m *appMockDriver) isStarted() bool { + m.mu.Lock() + defer m.mu.Unlock() + return m.startCalled +} + +func (m *appMockDriver) isStopped() bool { + m.mu.Lock() + defer m.mu.Unlock() + return m.stopCalled +} + +// appMockPlugin is a test plugin. +type appMockPlugin struct { + name string + applyFn func(ctx *core.Context) error +} + +func (p *appMockPlugin) Name() string { + return p.name +} + +func (p *appMockPlugin) Apply(ctx *core.Context) error { + if p.applyFn != nil { + return p.applyFn(ctx) + } + return nil +} + +func TestAppNewAndConfiguration(t *testing.T) { + customCtx := core.NewContext(context.Background()) + p1 := &appMockPlugin{name: "plugin1"} + p2 := &appMockPlugin{name: "plugin2"} + + app := core.NewApp( + core.WithContext(customCtx), + core.WithProfile(core.ProfileAPI), + core.WithPlugins(p1, p2), + core.WithShutdownTimeout(5*time.Second), + ) + + assert.Equal(t, customCtx, app.Context()) + assert.Equal(t, core.ProfileAPI, app.Profile()) + assert.Len(t, app.Plugins(), 2) + + retrieved, ok := app.Plugin("plugin1") + assert.True(t, ok) + assert.Equal(t, p1, retrieved) + + _, ok = app.Plugin("non_existent") + assert.False(t, ok) + + // Update existing plugin in-place + p1Updated := &appMockPlugin{name: "plugin1"} + app.Use(p1Updated, nil) + assert.Len(t, app.Plugins(), 2) + retrieved, ok = app.Plugin("plugin1") + assert.True(t, ok) + assert.Equal(t, p1Updated, retrieved) + + // Test SetProfile + app.SetProfile(core.ProfileWorker) + assert.Equal(t, core.ProfileWorker, app.Profile()) +} + +func TestAppProfileDispatch(t *testing.T) { + tests := []struct { + name string + profile core.Profile + expectedHTTP bool + expectedWorker bool + expectedCron bool + expectedCustom bool + }{ + { + name: "ProfileAPI only starts HTTP driver", + profile: core.ProfileAPI, + expectedHTTP: true, + expectedWorker: false, + expectedCron: false, + expectedCustom: false, + }, + { + name: "ProfileWorker only starts Worker driver", + profile: core.ProfileWorker, + expectedHTTP: false, + expectedWorker: true, + expectedCron: false, + expectedCustom: false, + }, + { + name: "ProfileSchedule only starts Schedule driver", + profile: core.ProfileSchedule, + expectedHTTP: false, + expectedWorker: false, + expectedCron: true, + expectedCustom: false, + }, + { + name: "Profile 'scheduler' alias starts Schedule driver", + profile: core.Profile("scheduler"), + expectedHTTP: false, + expectedWorker: false, + expectedCron: true, + expectedCustom: false, + }, + { + name: "ProfileAll starts all drivers", + profile: core.ProfileAll, + expectedHTTP: true, + expectedWorker: true, + expectedCron: true, + expectedCustom: true, + }, + { + name: "Custom profile starts custom driver", + profile: core.Profile("custom_rpc"), + expectedHTTP: false, + expectedWorker: false, + expectedCron: false, + expectedCustom: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + httpD := newAppMockDriver(core.DriverTypeHTTP) + workerD := newAppMockDriver(core.DriverTypeWorker) + cronD := newAppMockDriver(core.DriverTypeScheduler) + customD := newAppMockDriver(core.DriverType("custom_rpc")) + + p := &appMockPlugin{ + name: "drivers_plugin", + applyFn: func(ctx *core.Context) error { + _ = ctx.RegisterDriver(httpD) + _ = ctx.RegisterDriver(workerD) + _ = ctx.RegisterDriver(cronD) + _ = ctx.RegisterDriver(customD) + return nil + }, + } + + app := core.NewApp( + core.WithProfile(tt.profile), + core.WithPlugins(p), + ) + + err := app.Start(context.Background()) + require.NoError(t, err) + + assert.Equal(t, tt.expectedHTTP, httpD.isStarted(), "HTTP driver start mismatch") + assert.Equal(t, tt.expectedWorker, workerD.isStarted(), "Worker driver start mismatch") + assert.Equal(t, tt.expectedCron, cronD.isStarted(), "Cron driver start mismatch") + assert.Equal(t, tt.expectedCustom, customD.isStarted(), "Custom driver start mismatch") + + err = app.Stop(context.Background()) + require.NoError(t, err) + }) + } +} + +func TestAppLifecycleStartStop(t *testing.T) { + var stopOrder []string + var stopOrderMu sync.Mutex + + httpD := newAppMockDriver(core.DriverTypeHTTP) + workerD := newAppMockDriver(core.DriverTypeWorker) + + httpD.stopErr = nil + workerD.stopErr = nil + + // Wrap stop to record order + origHttpStop := httpD.Stop + _ = origHttpStop + + p := &appMockPlugin{ + name: "test_plugin", + applyFn: func(ctx *core.Context) error { + _ = ctx.RegisterDriver(httpD) + _ = ctx.RegisterDriver(workerD) + + ctx.OnDispose(func() error { + stopOrderMu.Lock() + stopOrder = append(stopOrder, "ctx_disposer") + stopOrderMu.Unlock() + return nil + }) + return nil + }, + } + + app := core.NewApp( + core.WithProfile(core.ProfileAll), + core.WithPlugins(p), + ) + + var readyReceived, stoppingReceived, stoppedReceived bool + app.Context().Events().On("app:ready", func() { + readyReceived = true + }) + app.Context().Events().On("app:stopping", func() { + stoppingReceived = true + }) + app.Context().Events().On("app:stopped", func() { + stoppedReceived = true + }) + + err := app.Start(context.Background()) + require.NoError(t, err) + assert.True(t, app.IsRunning()) + assert.Len(t, app.StartedDrivers(), 2) + assert.True(t, readyReceived) + + err = app.Stop(context.Background()) + require.NoError(t, err) + assert.False(t, app.IsRunning()) + assert.Empty(t, app.StartedDrivers()) + assert.True(t, stoppingReceived) + assert.True(t, stoppedReceived) + + assert.True(t, httpD.isStopped()) + assert.True(t, workerD.isStopped()) + assert.True(t, app.Context().IsDisposed()) + + stopOrderMu.Lock() + assert.Contains(t, stopOrder, "ctx_disposer") + stopOrderMu.Unlock() +} + +func TestAppStartDriverFailureRollback(t *testing.T) { + driver1 := newAppMockDriver(core.DriverTypeHTTP) + driver2 := newAppMockDriver(core.DriverTypeWorker) + driver2.startErr = errors.New("worker listen port conflict") + driver3 := newAppMockDriver(core.DriverTypeScheduler) + + p := &appMockPlugin{ + name: "fail_driver_plugin", + applyFn: func(ctx *core.Context) error { + _ = ctx.RegisterDriver(driver1) + _ = ctx.RegisterDriver(driver2) + _ = ctx.RegisterDriver(driver3) + return nil + }, + } + + app := core.NewApp( + core.WithProfile(core.ProfileAll), + core.WithPlugins(p), + ) + + err := app.Start(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "worker listen port conflict") + assert.False(t, app.IsRunning()) + + // Driver 1 was started then rolled back (stopped) + assert.True(t, driver1.isStarted()) + assert.True(t, driver1.isStopped()) + + // Driver 3 was never started + assert.False(t, driver3.isStarted()) +} + +func TestAppMigrationEngineExecution(t *testing.T) { + var migratedEntries []extpoints.MigrationEntry + runner := core.MigrationRunner(func(ctx *core.Context, entries []extpoints.MigrationEntry) error { + migratedEntries = entries + return nil + }) + + sqlFS := fstest.MapFS{ + "migrations/001_init.sql": &fstest.MapFile{Data: []byte("CREATE TABLE users(id int);")}, + } + + p := &appMockPlugin{ + name: "auth", + applyFn: func(ctx *core.Context) error { + ctx.Migrations().Register("auth", sqlFS) + return nil + }, + } + + app := core.NewApp( + core.WithProfile(core.ProfileAll), + core.WithPlugins(p), + core.WithMigrationRunner(runner), + ) + + err := app.Start(context.Background()) + require.NoError(t, err) + defer func() { _ = app.Stop(context.Background()) }() + + require.Len(t, migratedEntries, 1) + assert.Equal(t, "auth", migratedEntries[0].PluginID) +} + +func TestAppMigrationEngineFromIoCContainer(t *testing.T) { + var executed bool + runner := core.MigrationRunner(func(ctx *core.Context, entries []extpoints.MigrationEntry) error { + executed = true + return nil + }) + + sqlFS := fstest.MapFS{ + "migrations/001_init.sql": &fstest.MapFile{Data: []byte("CREATE TABLE logs(id int);")}, + } + + p := &appMockPlugin{ + name: "logstore", + applyFn: func(ctx *core.Context) error { + ctx.Migrations().Register("logstore", sqlFS) + core.Provide[core.MigrationEngine](ctx, runner) + return nil + }, + } + + app := core.NewApp( + core.WithProfile(core.ProfileAll), + core.WithPlugins(p), + ) + + err := app.Start(context.Background()) + require.NoError(t, err) + defer func() { _ = app.Stop(context.Background()) }() + + assert.True(t, executed) +} + +func TestAppRunContextCancellation(t *testing.T) { + d := newAppMockDriver(core.DriverTypeHTTP) + p := &appMockPlugin{ + name: "http_plugin", + applyFn: func(ctx *core.Context) error { + return ctx.RegisterDriver(d) + }, + } + + app := core.NewApp( + core.WithProfile(core.ProfileAPI), + core.WithPlugins(p), + core.WithShutdownTimeout(1*time.Second), + ) + + ctx, cancel := context.WithCancel(context.Background()) + + errCh := make(chan error, 1) + go func() { + errCh <- app.Run(ctx) + }() + + // Wait for app and driver to become ready + assert.Eventually(t, func() bool { + return app.IsRunning() && d.isStarted() + }, 2*time.Second, 10*time.Millisecond) + + cancel() + + select { + case err := <-errCh: + assert.NoError(t, err) + assert.False(t, app.IsRunning()) + assert.True(t, d.isStopped()) + case <-time.After(3 * time.Second): + t.Fatal("app.Run did not terminate upon context cancellation") + } +} + +func TestAppExecuteCLI(t *testing.T) { + // Test CLI argument parsing logic + tests := []struct { + args []string + expectedProfile core.Profile + }{ + {args: []string{"api"}, expectedProfile: core.ProfileAPI}, + {args: []string{"worker"}, expectedProfile: core.ProfileWorker}, + {args: []string{"scheduler"}, expectedProfile: core.ProfileSchedule}, + {args: []string{"schedule"}, expectedProfile: core.ProfileSchedule}, + {args: []string{"all"}, expectedProfile: core.ProfileAll}, + {args: []string{"--profile=worker"}, expectedProfile: core.ProfileWorker}, + {args: []string{"-p=api"}, expectedProfile: core.ProfileAPI}, + } + + for _, tt := range tests { + t.Run(tt.args[0], func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately + + // Use custom root context to control cancellation + customApp := core.NewApp(core.WithContext(core.NewContext(ctx))) + _ = customApp.ExecuteCLI(tt.args...) + assert.Equal(t, tt.expectedProfile, customApp.Profile()) + }) + } +} + +func TestAppIdempotencyAndErrorStates(t *testing.T) { + app := core.NewApp() + + // Double start returns error + err := app.Start(context.Background()) + require.NoError(t, err) + + err = app.Start(context.Background()) + assert.ErrorIs(t, err, core.ErrAppRunning) + + // Stop clears running state + err = app.Stop(context.Background()) + require.NoError(t, err) + + // Double stop succeeds + err = app.Stop(context.Background()) + require.NoError(t, err) + + // Plugin apply failure + failPlugin := &appMockPlugin{ + name: "failing_plugin", + applyFn: func(ctx *core.Context) error { + return errors.New("plugin init boom") + }, + } + app2 := core.NewApp(core.WithPlugins(failPlugin)) + err = app2.Start(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "plugin init boom") + assert.False(t, app2.IsRunning()) + + // Migration failure + migFailRunner := core.MigrationRunner(func(ctx *core.Context, entries []extpoints.MigrationEntry) error { + return errors.New("sql migrate error") + }) + sqlFS := fstest.MapFS{ + "migrations/001.sql": &fstest.MapFile{Data: []byte("...")}, + } + migPlugin := &appMockPlugin{ + name: "db_plugin", + applyFn: func(ctx *core.Context) error { + ctx.Migrations().Register("db_plugin", sqlFS) + return nil + }, + } + app3 := core.NewApp( + core.WithPlugins(migPlugin), + core.WithMigrationRunner(migFailRunner), + ) + err = app3.Start(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "sql migrate error") + assert.False(t, app3.IsRunning()) +} + +// newGateSource builds a configuration source whose only key decides the test gates. +func newGateSource(enabled bool) *mapSource { + return &mapSource{ + values: map[string]any{"gate.enabled": enabled}, + env: map[string]string{}, + } +} + +func TestAppPrepareResolvesThenGatesDuringReconcile(t *testing.T) { + primary := &gatedPlugin{name: "cache", enabled: true} + fallback := &gatedPlugin{name: "cache_memory", enabled: false} + + app := core.NewApp(core.WithConfigSource(newGateSource(true))) + app.Use(primary, fallback) + require.NoError(t, app.Prepare()) + + cacheFiber, ok := app.Fiber("cache") + require.True(t, ok) + require.Equal(t, core.FiberPending, cacheFiber.State(), "Prepare only builds the resolution barrier") + assert.True(t, app.Context().Config().Resolved()) + + require.NoError(t, app.Reconcile()) + + assert.Equal(t, core.FiberActive, cacheFiber.State()) + + memoryFiber, ok := app.Fiber("cache_memory") + require.True(t, ok) + assert.Equal(t, core.FiberSkipped, memoryFiber.State()) + assert.False(t, fallback.applied, "the gated-out provider must never reach Apply") +} + +func TestAppGatesPluginsMountedAfterPrepare(t *testing.T) { + app := core.NewApp(core.WithConfigSource(newGateSource(true))) + require.NoError(t, app.Prepare()) + + late := &gatedPlugin{name: "cache_memory", enabled: false} + app.Use(late) + require.NoError(t, app.Reconcile()) + + fiber, ok := app.Fiber("cache_memory") + require.True(t, ok) + assert.Equal(t, core.FiberSkipped, fiber.State(), + "plugins mounted after Prepare must still be gated") +} + +func TestAppApplyPluginsGatesImplicitly(t *testing.T) { + app := core.NewApp(core.WithConfigSource(newGateSource(false))) + app.Use(&gatedPlugin{name: "cache", enabled: true}) + + require.NoError(t, app.ApplyPlugins()) + + fiber, ok := app.Fiber("cache") + require.True(t, ok) + assert.Equal(t, core.FiberSkipped, fiber.State(), + "ApplyPlugins must resolve and gate without an explicit Prepare call") +} + +func TestAppPrepareReportsConfigurationErrors(t *testing.T) { + src := &mapSource{ + values: map[string]any{"gate.enabled": "yes"}, + env: map[string]string{}, + } + app := core.NewApp(core.WithConfigSource(src)) + app.Use(&gatedPlugin{name: "cache", enabled: true}) + + err := app.Prepare() + require.Error(t, err) + assert.Contains(t, err.Error(), "gate.enabled") +} + +func TestAppGatedPluginWithoutConfigSourceFailsFast(t *testing.T) { + app := core.NewApp() + app.Use(&gatedPlugin{name: "cache", enabled: true}) + + err := app.ApplyPlugins() + require.Error(t, err) + assert.Contains(t, err.Error(), "cache") + assert.Contains(t, err.Error(), "ConfigSource") +} + +func TestAppSetShutdownTimeoutIgnoresNonPositive(t *testing.T) { + app := core.NewApp() + + app.SetShutdownTimeout(0) + assert.Equal(t, 10*time.Second, app.ShutdownTimeout(), "zero must not shrink the kernel fallback") + + app.SetShutdownTimeout(45 * time.Second) + assert.Equal(t, 45*time.Second, app.ShutdownTimeout()) +} diff --git a/backend/core/config.go b/backend/core/config.go new file mode 100644 index 00000000..a52bde7a --- /dev/null +++ b/backend/core/config.go @@ -0,0 +1,100 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package core + +import ( + "fmt" + "strings" + + "Wavelet/core/extpoints" +) + +// ConfigGet reads one resolved configuration value with its declared type. It is the +// generic counterpart of the fallback accessors on ConfigView, used when a caller must +// distinguish "unset" from "set to the zero value". +func ConfigGet[T any](view extpoints.ConfigView, key string) (T, error) { + var zero T + if view == nil { + return zero, extpoints.ErrConfigNotResolved + } + + raw, ok := view.Value(key) + if !ok { + return zero, fmt.Errorf("%w: %s", extpoints.ErrConfigUnknownKey, key) + } + + value, ok := raw.(T) + if !ok { + return zero, fmt.Errorf("%w: key %q holds %T, want %T", extpoints.ErrConfigType, key, raw, zero) + } + return value, nil +} + +// MapSource implements ConfigSource backed by an in-memory map, ideal for unit tests. +type MapSource struct { + values map[string]any + env map[string]string +} + +// NewMapSource creates a new MapSource with the provided key-value mappings. +func NewMapSource(values map[string]any) *MapSource { + vals := make(map[string]any, len(values)) + for k, v := range values { + vals[k] = v + } + return &MapSource{ + values: vals, + env: make(map[string]string), + } +} + +// Lookup returns the value at the given path, supporting both flat keys and nested maps. +func (m *MapSource) Lookup(path string) (any, bool) { + if m == nil || m.values == nil { + return nil, false + } + if v, ok := m.values[path]; ok { + return v, true + } + parts := strings.Split(path, ".") + var cur any = m.values + for _, part := range parts { + mCur, ok := cur.(map[string]any) + if !ok { + return nil, false + } + cur, ok = mCur[part] + if !ok { + return nil, false + } + } + return cur, true +} + +// LookupEnv returns the environment variable value. +func (m *MapSource) LookupEnv(name string) (string, bool) { + if m == nil || m.env == nil { + return "", false + } + v, ok := m.env[name] + return v, ok +} + +// SetEnv sets an environment variable for testing. +func (m *MapSource) SetEnv(name, value string) { + if m.env == nil { + m.env = make(map[string]string) + } + m.env[name] = value +} + +// Describe describes the MapSource. +func (m *MapSource) Describe() string { + return "" +} + +// WithConfigValues returns an AppOption that installs a MapSource with the given key-value mappings. +func WithConfigValues(values map[string]any) AppOption { + return WithConfigSource(NewMapSource(values)) +} diff --git a/backend/core/config_test.go b/backend/core/config_test.go new file mode 100644 index 00000000..826b3609 --- /dev/null +++ b/backend/core/config_test.go @@ -0,0 +1,92 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package core_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "Wavelet/core" + "Wavelet/core/extpoints" +) + +// mapSource implements extpoints.ConfigSource over static maps. +type mapSource struct { + values map[string]any + env map[string]string +} + +func (m *mapSource) Lookup(path string) (any, bool) { + v, ok := m.values[path] + return v, ok +} + +func (m *mapSource) LookupEnv(name string) (string, bool) { + v, ok := m.env[name] + return v, ok +} + +func (m *mapSource) Describe() string { return "map" } + +type otelConfig struct { + SamplingRate float64 `config:"sampling_rate" env:"OTEL_SAMPLING_RATE"` +} + +// newOtelRegistry declares the otel section against a source carrying the given file values. +func newOtelRegistry(t *testing.T, values map[string]any) extpoints.ConfigExtension { + t.Helper() + + r := extpoints.NewConfigRegistry(&mapSource{values: values, env: map[string]string{}}) + require.NoError(t, r.Declare("host", extpoints.ConfigBinding{Prefix: "otel", Target: &otelConfig{}})) + require.NoError(t, r.Resolve()) + return r +} + +func TestConfigGetReturnsDeclaredType(t *testing.T) { + view := newOtelRegistry(t, map[string]any{"otel.sampling_rate": 0.25}) + + rate, err := core.ConfigGet[float64](view, "otel.sampling_rate") + require.NoError(t, err) + assert.Equal(t, 0.25, rate) +} + +func TestConfigGetRejectsTypeMismatch(t *testing.T) { + view := newOtelRegistry(t, map[string]any{"otel.sampling_rate": 0.25}) + + text, err := core.ConfigGet[string](view, "otel.sampling_rate") + require.ErrorIs(t, err, extpoints.ErrConfigType) + assert.Empty(t, text) +} + +func TestConfigGetRejectsUndeclaredKey(t *testing.T) { + view := newOtelRegistry(t, nil) + + _, err := core.ConfigGet[float64](view, "otel.unregistered") + require.ErrorIs(t, err, extpoints.ErrConfigUnknownKey) +} + +func TestConfigGetRejectsNilView(t *testing.T) { + _, err := core.ConfigGet[float64](nil, "otel.sampling_rate") + require.ErrorIs(t, err, extpoints.ErrConfigNotResolved) +} + +func TestContextConfigIsSharedAcrossForks(t *testing.T) { + ctx := core.NewContext(nil) + child := ctx.Fork() + + require.NotNil(t, ctx.Config()) + assert.Same(t, ctx.Config(), child.Config(), "configuration declarations are process-wide facts") + + require.NoError(t, child.Config().Declare("cache", + extpoints.ConfigBinding{Prefix: "otel", Target: &otelConfig{}})) + + declared := false + for _, entry := range ctx.Config().Entries() { + declared = declared || entry.Key == "otel.sampling_rate" + } + assert.True(t, declared, "a declaration made in a plugin scope must be visible to the root") + assert.False(t, ctx.Config().Resolved()) +} diff --git a/backend/core/container.go b/backend/core/container.go new file mode 100644 index 00000000..ed783572 --- /dev/null +++ b/backend/core/container.go @@ -0,0 +1,225 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package core provides the micro-kernel service bus, generic IoC container, and runtime extensions. +package core + +import ( + "errors" + "fmt" + "reflect" + "sync" +) + +// Container manages service registration and resolution using Go reflection and generics. +type Container struct { + mu sync.RWMutex + parent *Container + services map[reflect.Type]any + listeners map[reflect.Type][]func(any) +} + +// NewContainer creates a new IoC container instance with an optional parent container. +func NewContainer(parent *Container) *Container { + return &Container{ + parent: parent, + services: make(map[reflect.Type]any), + listeners: make(map[reflect.Type][]func(any)), + } +} + +func isNil(i any) bool { + if i == nil { + return true + } + v := reflect.ValueOf(i) + switch v.Kind() { + case reflect.Chan, reflect.Func, reflect.Map, reflect.Pointer, reflect.UnsafePointer, reflect.Interface, reflect.Slice: + return v.IsNil() + default: + return false + } +} + +func (c *Container) remove(targetType reflect.Type) { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.services, targetType) +} + +// Provide registers a typed service implementation into the Context hierarchy's root IoC container. +func Provide[T any](ctx *Context, service T) { + if ctx == nil { + panic("core: nil context provided to Provide") + } + if isNil(service) { + panic("core: cannot provide nil service") + } + + targetType := reflect.TypeFor[T]() + targetContainer := ctx.Root().Container() + targetContainer.provide(targetType, service) + + ctx.OnDispose(func() error { + targetContainer.remove(targetType) + return nil + }) +} + +// ProvideScoped registers a typed service implementation strictly in the local Context container. +func ProvideScoped[T any](ctx *Context, service T) { + if ctx == nil { + panic("core: nil context provided to ProvideScoped") + } + if isNil(service) { + panic("core: cannot provide nil service") + } + + targetType := reflect.TypeFor[T]() + targetContainer := ctx.Container() + targetContainer.provide(targetType, service) + + ctx.OnDispose(func() error { + targetContainer.remove(targetType) + return nil + }) +} + +func (c *Container) provide(targetType reflect.Type, service any) { + c.mu.Lock() + c.services[targetType] = service + + // Collect any matching listeners to invoke outside the lock + var callbacks []func(any) + svcType := reflect.TypeOf(service) + for lType, cbs := range c.listeners { + if lType == targetType || (lType.Kind() == reflect.Interface && svcType.Implements(lType)) { + callbacks = append(callbacks, cbs...) + } + } + c.mu.Unlock() + + for _, cb := range callbacks { + cb(service) + } +} + +// Inject resolves a registered service of type T from the Context. +func Inject[T any](ctx *Context) (T, error) { + var zero T + if ctx == nil { + return zero, ErrNilContext + } + + targetType := reflect.TypeFor[T]() + val, err := ctx.Container().resolve(targetType) + if err != nil { + return zero, err + } + + typedVal, ok := val.(T) + if !ok { + return zero, fmt.Errorf("%w: cannot cast %T to %v", ErrServiceNotFound, val, targetType) + } + return typedVal, nil +} + +func (c *Container) resolve(targetType reflect.Type) (any, error) { + c.mu.RLock() + // 1. Direct type match + if val, ok := c.services[targetType]; ok { + c.mu.RUnlock() + return val, nil + } + + // 2. Interface assignment scan + if targetType.Kind() == reflect.Interface { + for _, val := range c.services { + if reflect.TypeOf(val).Implements(targetType) { + c.mu.RUnlock() + return val, nil + } + } + } + c.mu.RUnlock() + + // 3. Fallback to parent container + if c.parent != nil { + return c.parent.resolve(targetType) + } + + return nil, fmt.Errorf("%w: %v", ErrServiceNotFound, targetType) +} + +// MustInject resolves a service of type T or panics if the service is not found. +func MustInject[T any](ctx *Context) T { + s, err := Inject[T](ctx) + if err != nil { + panic(fmt.Sprintf("core: failed to inject service %v: %v", reflect.TypeFor[T](), err)) + } + return s +} + +// Has returns true if a service of type T is registered and resolvable in the Context. +func Has[T any](ctx *Context) bool { + _, err := Inject[T](ctx) + return err == nil +} + +// Using executes the given function synchronously if the required dependency is ready. +func Using[T1 any](ctx *Context, fn func(s1 T1)) error { + s1, err := Inject[T1](ctx) + if err != nil { + return fmt.Errorf("%w: %w", ErrServiceNotReady, err) + } + fn(s1) + return nil +} + +// Using2 executes the given function synchronously if both required dependencies are ready. +func Using2[T1, T2 any](ctx *Context, fn func(s1 T1, s2 T2)) error { + s1, err1 := Inject[T1](ctx) + s2, err2 := Inject[T2](ctx) + if err := errors.Join(err1, err2); err != nil { + return fmt.Errorf("%w: %w", ErrServiceNotReady, err) + } + fn(s1, s2) + return nil +} + +// Using3 executes the given function synchronously if all 3 required dependencies are ready. +func Using3[T1, T2, T3 any](ctx *Context, fn func(s1 T1, s2 T2, s3 T3)) error { + s1, err1 := Inject[T1](ctx) + s2, err2 := Inject[T2](ctx) + s3, err3 := Inject[T3](ctx) + if err := errors.Join(err1, err2, err3); err != nil { + return fmt.Errorf("%w: %w", ErrServiceNotReady, err) + } + fn(s1, s2, s3) + return nil +} + +// When registers a reactive hook that is called immediately if T is already provided, +// or called as soon as T is provided in the future. +func When[T any](ctx *Context, fn func(s T)) { + if ctx == nil { + panic("core: nil context provided to When") + } + + targetType := reflect.TypeFor[T]() + c := ctx.Container() + + // If already ready, execute immediately + if s, err := Inject[T](ctx); err == nil { + fn(s) + } + + // Also register listener for future calls / updates + c.mu.Lock() + defer c.mu.Unlock() + c.listeners[targetType] = append(c.listeners[targetType], func(val any) { + if typed, ok := val.(T); ok { + fn(typed) + } + }) +} diff --git a/backend/core/context.go b/backend/core/context.go new file mode 100644 index 00000000..671fddde --- /dev/null +++ b/backend/core/context.go @@ -0,0 +1,387 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package core + +import ( + "Wavelet/core/extpoints" + "context" + "errors" + "fmt" + "sync" + "time" +) + +// Context is the central micro-kernel service bus and runtime lifecycle container. +// It embeds Go standard context.Context compatibility, hierarchical scoping, +// service resolution, and LIFO disposer teardown. +type Context struct { + goCtx context.Context + cancel context.CancelFunc + parent *Context + container *Container + + events *EventBus + router extpoints.RouterExtension + migrations extpoints.MigrationExtension + tasks extpoints.TaskExtension + schedules extpoints.ScheduleExtension + settings extpoints.SettingExtension + config extpoints.ConfigExtension + + mu sync.RWMutex + children []*Context + disposers []Disposer + drivers []Driver + values map[any]any + disposed bool +} + +// NewContext creates a new root Context wrapping a standard Go context. +// If base is nil, context.Background() is used by default. +// +//nolint:contextcheck +func NewContext(base context.Context) *Context { + if base == nil { + base = context.Background() + } + ctx, cancel := context.WithCancel(base) + + return &Context{ + goCtx: ctx, + cancel: cancel, + container: NewContainer(nil), + events: NewEventBus(), + router: extpoints.NewRouterRegistry(), + migrations: extpoints.NewMigrationRegistry(), + tasks: extpoints.NewTaskRegistry(), + schedules: extpoints.NewScheduleRegistry(), + settings: extpoints.NewSettingRegistry(), + config: extpoints.NewConfigRegistry(nil), + values: make(map[any]any), + } +} + +// Deadline returns the time when work done on behalf of this context should be canceled. +func (c *Context) Deadline() (deadline time.Time, ok bool) { + return c.goCtx.Deadline() +} + +// Done returns a channel that's closed when work done on behalf of this context should be canceled. +func (c *Context) Done() <-chan struct{} { + return c.goCtx.Done() +} + +// Err returns a non-nil error value after Done is closed. +func (c *Context) Err() error { + return c.goCtx.Err() +} + +// Value returns the value associated with key, searching the local values map, +// the underlying Go context, and fallback parent Contexts. +func (c *Context) Value(key any) any { + c.mu.RLock() + if v, ok := c.values[key]; ok { + c.mu.RUnlock() + return v + } + c.mu.RUnlock() + + if v := c.goCtx.Value(key); v != nil { + return v + } + + if c.parent != nil { + return c.parent.Value(key) + } + + return nil +} + +// GoContext returns the underlying standard Go context.Context. +func (c *Context) GoContext() context.Context { + return c.goCtx +} + +// Set stores an arbitrary key-value pair in this Context's local storage. +func (c *Context) Set(key, val any) { + c.mu.Lock() + defer c.mu.Unlock() + if c.values == nil { + c.values = make(map[any]any) + } + c.values[key] = val +} + +// Get retrieves a key-value pair from this Context's local storage. +func (c *Context) Get(key any) (any, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + if c.values == nil { + return nil, false + } + v, ok := c.values[key] + return v, ok +} + +// Container returns the underlying IoC container for this Context. +func (c *Context) Container() *Container { + return c.container +} + +// Parent returns the parent Context, or nil if this is a root Context. +func (c *Context) Parent() *Context { + return c.parent +} + +// Root returns the root Context in the hierarchy. +func (c *Context) Root() *Context { + curr := c + for curr.parent != nil { + curr = curr.parent + } + return curr +} + +// Fork creates a child Context with its own scoped IoC container and values, +// linked to this Context for hierarchical fallback resolution and cascading teardown. +func (c *Context) Fork() *Context { + return c.ForkWithContext(c.goCtx) +} + +// ForkWithContext creates a child Context using a specific standard Go context. +// +//nolint:contextcheck +func (c *Context) ForkWithContext(base context.Context) *Context { + if base == nil { + base = c.goCtx + } + ctx, cancel := context.WithCancel(base) + + child := &Context{ + goCtx: ctx, + cancel: cancel, + parent: c, + container: NewContainer(c.container), + events: c.events, + router: c.router, + migrations: c.migrations, + tasks: c.tasks, + schedules: c.schedules, + settings: c.settings, + config: c.config, + values: make(map[any]any), + } + + c.mu.Lock() + c.children = append(c.children, child) + c.mu.Unlock() + + return child +} + +// Events returns the domain EventBus associated with this Context hierarchy. +func (c *Context) Events() *EventBus { + return c.events +} + +// On registers an event listener on the EventBus and automatically attaches its Disposer +// to this Context's teardown stack for automatic revocation when disposed. +func (c *Context) On(topic string, handler any) Disposer { + disposer := c.events.On(topic, handler) + c.OnDispose(disposer) + return disposer +} + +// Effect registers a reversible side-effect cleanup callback on this Context. +func (c *Context) Effect(fn any) { + c.OnDispose(fn) +} + +// Router returns the scoped RouterExtension registry with automatic disposer tracking. +func (c *Context) Router() extpoints.RouterExtension { + return newScopedRouterExtension(c, c.router) +} + +// Migrations returns the MigrationExtension registry. +func (c *Context) Migrations() extpoints.MigrationExtension { + return c.migrations +} + +// Tasks returns the scoped TaskExtension registry with automatic disposer tracking. +func (c *Context) Tasks() extpoints.TaskExtension { + return newScopedTaskExtension(c, c.tasks) +} + +// Task is an alias for Tasks(). +func (c *Context) Task() extpoints.TaskExtension { + return c.Tasks() +} + +// Schedules returns the scoped ScheduleExtension registry with automatic disposer tracking. +func (c *Context) Schedules() extpoints.ScheduleExtension { + return newScopedScheduleExtension(c, c.schedules) +} + +// Schedule is an alias for Schedules(). +func (c *Context) Schedule() extpoints.ScheduleExtension { + return c.Schedules() +} + +// Settings returns the scoped SettingExtension registry with automatic disposer tracking. +func (c *Context) Settings() extpoints.SettingExtension { + return newScopedSettingExtension(c, c.settings) +} + +// Setting is an alias for Settings(). +func (c *Context) Setting() extpoints.SettingExtension { + return c.Settings() +} + +// Config returns the process-level configuration extension point. The registry is +// shared by every fork because configuration declarations are global facts, and it +// carries no per-scope disposers: values are resolved once before Apply runs. +func (c *Context) Config() extpoints.ConfigExtension { + return c.config +} + +// OnDispose registers a cleanup callback function to be executed when this Context is disposed. +// It accepts func() error, func(), or Disposer. +func (c *Context) OnDispose(fn any) { + if fn == nil { + return + } + + var d Disposer + switch f := fn.(type) { + case Disposer: + d = f + case func() error: + d = f + case func(): + d = func() error { + f() + return nil + } + default: + panic(fmt.Sprintf("core: OnDispose expects func() error or func(), got %T", fn)) + } + + c.mu.Lock() + defer c.mu.Unlock() + c.disposers = append(c.disposers, d) +} + +// Dispose shuts down this Context and all child Contexts, running registered disposers in LIFO order. +func (c *Context) Dispose() error { + c.mu.Lock() + if c.disposed { + c.mu.Unlock() + return nil + } + c.disposed = true + + // Copy children and disposers under lock + children := make([]*Context, len(c.children)) + copy(children, c.children) + + disposers := make([]Disposer, len(c.disposers)) + copy(disposers, c.disposers) + c.mu.Unlock() + + var errs []error + + // 1. Dispose all child contexts in reverse order + for i := len(children) - 1; i >= 0; i-- { + if err := children[i].Dispose(); err != nil { + errs = append(errs, err) + } + } + + // 2. Run local disposers in LIFO order + for i := len(disposers) - 1; i >= 0; i-- { + if err := disposers[i](); err != nil { + errs = append(errs, err) + } + } + + // 3. Cancel the Go context + if c.cancel != nil { + c.cancel() + } + + // 4. Detach from parent + if c.parent != nil { + c.parent.removeChild(c) + } + + return errors.Join(errs...) +} + +func (c *Context) removeChild(target *Context) { + c.mu.Lock() + defer c.mu.Unlock() + for i, child := range c.children { + if child == target { + c.children = append(c.children[:i], c.children[i+1:]...) + break + } + } +} + +// IsDisposed returns true if this Context has been disposed. +func (c *Context) IsDisposed() bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.disposed +} + +// RegisterDriver registers a runtime driver engine on this Context hierarchy. +func (c *Context) RegisterDriver(d Driver) error { + if d == nil { + return ErrNilService + } + + root := c.Root() + root.mu.Lock() + root.drivers = append(root.drivers, d) + root.mu.Unlock() + + c.OnDispose(func() error { + root.mu.Lock() + defer root.mu.Unlock() + for i, drv := range root.drivers { + if drv == d { + root.drivers = append(root.drivers[:i], root.drivers[i+1:]...) + break + } + } + return nil + }) + return nil +} + +// Drivers returns a copy of all drivers registered on this Context. +func (c *Context) Drivers() []Driver { + root := c.Root() + root.mu.RLock() + defer root.mu.RUnlock() + + result := make([]Driver, len(root.drivers)) + copy(result, root.drivers) + return result +} + +// Driver looks up a registered driver by its driver type. +func (c *Context) Driver(driverType DriverType) (Driver, bool) { + root := c.Root() + root.mu.RLock() + defer root.mu.RUnlock() + + for _, d := range root.drivers { + if d.Type() == driverType { + return d, true + } + } + return nil, false +} diff --git a/backend/core/context_test.go b/backend/core/context_test.go new file mode 100644 index 00000000..d6c66bc6 --- /dev/null +++ b/backend/core/context_test.go @@ -0,0 +1,560 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package core_test + +import ( + "Wavelet/core" + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Sample services for testing +type SampleService interface { + Greet(name string) string +} + +type sampleServiceImpl struct { + prefix string +} + +func (s *sampleServiceImpl) Greet(name string) string { + if s.prefix != "" { + return s.prefix + " " + name + } + return "Hello, " + name +} + +type LogService interface { + Log(msg string) +} + +type logServiceImpl struct { + logs []string +} + +func (l *logServiceImpl) Log(msg string) { + l.logs = append(l.logs, msg) +} + +type ConfigService interface { + Get(key string) string +} + +type configServiceImpl struct { + data map[string]string +} + +func (c *configServiceImpl) Get(key string) string { + return c.data[key] +} + +// Sample plugin for testing +type samplePlugin struct { + name string +} + +func (p *samplePlugin) Name() string { + return p.name +} + +func (p *samplePlugin) Apply(ctx *core.Context) error { + core.Provide[SampleService](ctx, &sampleServiceImpl{prefix: "Plugin:"}) + return nil +} + +func (p *samplePlugin) Manifest() core.Manifest { + return core.Manifest{ + Name: p.name, + Version: "1.0.0", + Description: "Sample plugin", + } +} + +// Sample driver for testing +type mockDriver struct { + driverType core.DriverType + started bool + stopped bool +} + +func (m *mockDriver) Type() core.DriverType { + return m.driverType +} + +func (m *mockDriver) Start(ctx context.Context) error { + m.started = true + return nil +} + +func (m *mockDriver) Stop(ctx context.Context) error { + m.stopped = true + return nil +} + +func TestContextProvideAndInject(t *testing.T) { + ctx := core.NewContext(context.Background()) + + // Before providing, Inject should fail + _, err := core.Inject[SampleService](ctx) + require.Error(t, err) + assert.True(t, errors.Is(err, core.ErrServiceNotFound)) + assert.False(t, core.Has[SampleService](ctx)) + + // MustInject should panic + assert.Panics(t, func() { + core.MustInject[SampleService](ctx) + }) + + // Provide service + svcImpl := &sampleServiceImpl{prefix: "Hello,"} + core.Provide[SampleService](ctx, svcImpl) + + // Inject should succeed + assert.True(t, core.Has[SampleService](ctx)) + svc, err := core.Inject[SampleService](ctx) + require.NoError(t, err) + assert.Equal(t, "Hello, Wavelet", svc.Greet("Wavelet")) + + // MustInject should succeed + mustSvc := core.MustInject[SampleService](ctx) + assert.Equal(t, "Hello, Cordis", mustSvc.Greet("Cordis")) +} + +func TestContextProvideNilPanics(t *testing.T) { + ctx := core.NewContext(context.Background()) + + assert.Panics(t, func() { + core.Provide[SampleService](nil, &sampleServiceImpl{}) + }) + + assert.Panics(t, func() { + var nilSvc SampleService + core.Provide[SampleService](ctx, nilSvc) + }) + + assert.Panics(t, func() { + var nilImpl *sampleServiceImpl + core.Provide[*sampleServiceImpl](ctx, nilImpl) + }) + + // Inject with nil context + var nilCtx *core.Context + _, err := core.Inject[SampleService](nilCtx) + assert.ErrorIs(t, err, core.ErrNilContext) +} + +func TestContextUsing(t *testing.T) { + ctx := core.NewContext(context.Background()) + var called bool + + // Using when service not ready should return ErrServiceNotReady + err := core.Using(ctx, func(s SampleService) { + called = true + assert.Equal(t, "Hello, Cordis", s.Greet("Cordis")) + }) + assert.Error(t, err) + assert.True(t, errors.Is(err, core.ErrServiceNotReady)) + assert.False(t, called) + + // Provide service and try Using again + core.Provide[SampleService](ctx, &sampleServiceImpl{}) + err = core.Using(ctx, func(s SampleService) { + called = true + assert.Equal(t, "Hello, Cordis", s.Greet("Cordis")) + }) + assert.NoError(t, err) + assert.True(t, called) +} + +func TestContextUsingMultiple(t *testing.T) { + ctx := core.NewContext(context.Background()) + + // Using2 with missing dependencies + var called2 bool + err := core.Using2(ctx, func(s SampleService, l LogService) { + called2 = true + }) + assert.Error(t, err) + assert.False(t, called2) + + // Provide 1 of 2 + core.Provide[SampleService](ctx, &sampleServiceImpl{}) + err = core.Using2(ctx, func(s SampleService, l LogService) { + called2 = true + }) + assert.Error(t, err) + assert.False(t, called2) + + // Provide 2 of 2 + logSvc := &logServiceImpl{} + core.Provide[LogService](ctx, logSvc) + err = core.Using2(ctx, func(s SampleService, l LogService) { + called2 = true + l.Log(s.Greet("World")) + }) + assert.NoError(t, err) + assert.True(t, called2) + assert.Equal(t, []string{"Hello, World"}, logSvc.logs) + + // Using3 test - error condition + err = core.Using3(ctx, func(s SampleService, l LogService, c ConfigService) {}) + assert.Error(t, err) + + // Using3 test - success condition + var called3 bool + cfgSvc := &configServiceImpl{data: map[string]string{"env": "test"}} + core.Provide[ConfigService](ctx, cfgSvc) + + err = core.Using3(ctx, func(s SampleService, l LogService, c ConfigService) { + called3 = true + assert.Equal(t, "test", c.Get("env")) + }) + assert.NoError(t, err) + assert.True(t, called3) +} + +// UsingN must keep every dependency failure reachable through the error chain, +// not just report that something went wrong. +func TestContextUsingMultipleErrorChain(t *testing.T) { + ctx := core.NewContext(context.Background()) + + err := core.Using2(ctx, func(s SampleService, l LogService) { + t.Fatal("callback must not run when dependencies are missing") + }) + require.Error(t, err) + assert.ErrorIs(t, err, core.ErrServiceNotReady) + assert.ErrorIs(t, err, core.ErrServiceNotFound) + + // Only LogService is missing now, so exactly one joined cause must be present. + core.Provide[SampleService](ctx, &sampleServiceImpl{}) + err = core.Using2(ctx, func(s SampleService, l LogService) { + t.Fatal("callback must not run when a dependency is missing") + }) + assert.ErrorIs(t, err, core.ErrServiceNotReady) + assert.ErrorIs(t, err, core.ErrServiceNotFound) + + err = core.Using3(ctx, func(s SampleService, l LogService, c ConfigService) { + t.Fatal("callback must not run when a dependency is missing") + }) + assert.ErrorIs(t, err, core.ErrServiceNotReady) + assert.ErrorIs(t, err, core.ErrServiceNotFound) +} + +func TestContextHierarchyAndFork(t *testing.T) { + parent := core.NewContext(nil) // nil base context test + core.Provide[SampleService](parent, &sampleServiceImpl{prefix: "Parent:"}) + + child := parent.ForkWithContext(nil) // nil child context test + require.NotNil(t, child) + assert.Equal(t, parent, child.Parent()) + + // Child can resolve service from parent + svc, err := core.Inject[SampleService](child) + require.NoError(t, err) + assert.Equal(t, "Parent: Ryan", svc.Greet("Ryan")) + + // Child provides LogService + childLog := &logServiceImpl{} + core.ProvideScoped[LogService](child, childLog) + + // Child has LogService, parent does not + assert.True(t, core.Has[LogService](child)) + assert.False(t, core.Has[LogService](parent)) + + // Child overrides SampleService locally + core.ProvideScoped[SampleService](child, &sampleServiceImpl{prefix: "Child:"}) + childSvc, err := core.Inject[SampleService](child) + require.NoError(t, err) + assert.Equal(t, "Child: Ryan", childSvc.Greet("Ryan")) + + parentSvc, err := core.Inject[SampleService](parent) + require.NoError(t, err) + assert.Equal(t, "Parent: Ryan", parentSvc.Greet("Ryan")) +} + +func TestContextReactiveWhen(t *testing.T) { + ctx := core.NewContext(context.Background()) + + assert.Panics(t, func() { + core.When[SampleService](nil, func(s SampleService) {}) + }) + + var whenCalled atomic.Bool + var greeted string + + // Register When before service is provided + core.When[SampleService](ctx, func(s SampleService) { + whenCalled.Store(true) + greeted = s.Greet("Reactive") + }) + + assert.False(t, whenCalled.Load()) + + // Now Provide the service - listener should trigger + core.Provide[SampleService](ctx, &sampleServiceImpl{}) + + assert.True(t, whenCalled.Load()) + assert.Equal(t, "Hello, Reactive", greeted) + + // Register another When after service is already provided - should trigger immediately + var immediateCalled bool + core.When[SampleService](ctx, func(s SampleService) { + immediateCalled = true + }) + assert.True(t, immediateCalled) +} + +func TestContextDisposerLifecycle(t *testing.T) { + parent := core.NewContext(context.Background()) + child := parent.Fork() + + var order []string + + // Test nil disposer + parent.OnDispose(nil) + + // Test Disposer type + var customDisposer core.Disposer = func() error { + order = append(order, "parent-custom") + return nil + } + parent.OnDispose(customDisposer) + + parent.OnDispose(func() error { + order = append(order, "parent-1") + return nil + }) + parent.OnDispose(func() { + order = append(order, "parent-2") + }) + + child.OnDispose(func() error { + order = append(order, "child-1") + return errors.New("child-1 error") + }) + child.OnDispose(func() { + order = append(order, "child-2") + }) + + assert.Panics(t, func() { + parent.OnDispose("invalid-func") + }) + + assert.False(t, parent.IsDisposed()) + assert.False(t, child.IsDisposed()) + + // Disposing parent should cascade to children first, and execute disposers in LIFO order + err := parent.Dispose() + assert.Error(t, err) // child-1 error should be joined + assert.Contains(t, err.Error(), "child-1 error") + + assert.True(t, parent.IsDisposed()) + assert.True(t, child.IsDisposed()) + + // Child disposers run in LIFO: child-2, child-1 + // Parent disposers run in LIFO: parent-2, parent-1, parent-custom + expected := []string{"child-2", "child-1", "parent-2", "parent-1", "parent-custom"} + assert.Equal(t, expected, order) + + // Disposing again should be idempotent and return nil + err = parent.Dispose() + assert.NoError(t, err) +} + +func TestContextStandardGoContext(t *testing.T) { + baseCtx, cancel := context.WithDeadline(context.Background(), time.Now().Add(5*time.Second)) + defer cancel() + + parentCtx := core.NewContext(baseCtx) + parentCtx.Set("parent_key", "parent_val") + + childCtx := parentCtx.Fork() + + // Deadline + dl, ok := childCtx.Deadline() + assert.True(t, ok) + assert.False(t, dl.IsZero()) + + // Value fallback: child has no key, falls back to parentCtx + assert.Equal(t, "parent_val", childCtx.Value("parent_key")) + + // GoContext getter + assert.NotNil(t, childCtx.GoContext()) + + // Value not found in either + assert.Nil(t, childCtx.Value("non_existent_key")) + + // Cancellation propagation + select { + case <-childCtx.Done(): + t.Fatal("ctx should not be done yet") + default: + } + + cancel() + + select { + case <-childCtx.Done(): + assert.Equal(t, context.Canceled, childCtx.Err()) + case <-time.After(100 * time.Millisecond): + t.Fatal("ctx should be cancelled") + } +} + +func TestManifestValidation(t *testing.T) { + mValid := core.Manifest{ + Name: "auth", + Version: "1.0.0", + Description: "Auth plugin", + } + assert.NoError(t, mValid.Validate()) + + mInvalid := core.Manifest{ + Version: "1.0.0", + } + assert.Error(t, mInvalid.Validate()) +} + +func TestDriverRegistration(t *testing.T) { + ctx := core.NewContext(context.Background()) + + // Register nil driver returns error + assert.ErrorIs(t, ctx.RegisterDriver(nil), core.ErrNilService) + + dHTTP := &mockDriver{driverType: core.DriverTypeHTTP} + dWorker := &mockDriver{driverType: core.DriverTypeWorker} + + require.NoError(t, ctx.RegisterDriver(dHTTP)) + require.NoError(t, ctx.RegisterDriver(dWorker)) + + drivers := ctx.Drivers() + assert.Len(t, drivers, 2) + + foundHTTP, ok := ctx.Driver(core.DriverTypeHTTP) + assert.True(t, ok) + assert.Equal(t, dHTTP, foundHTTP) + + foundWorker, ok := ctx.Driver(core.DriverTypeWorker) + assert.True(t, ok) + assert.Equal(t, dWorker, foundWorker) + + _, ok = ctx.Driver(core.DriverTypeScheduler) + assert.False(t, ok) +} + +func TestPluginInterfaces(t *testing.T) { + ctx := core.NewContext(context.Background()) + var p core.Plugin = &samplePlugin{name: "sample"} + assert.Equal(t, "sample", p.Name()) + require.NoError(t, p.Apply(ctx)) + + svc, err := core.Inject[SampleService](ctx) + require.NoError(t, err) + assert.Equal(t, "Plugin: Ryan", svc.Greet("Ryan")) + + var pwm core.PluginWithManifest = &samplePlugin{name: "sample"} + manifest := pwm.Manifest() + assert.Equal(t, "sample", manifest.Name) + assert.Equal(t, "1.0.0", manifest.Version) +} + +func TestConcurrentAccess(t *testing.T) { + ctx := core.NewContext(context.Background()) + var wg sync.WaitGroup + + // Concurrently provide, inject, fork, set, and get + for i := 0; i < 50; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + ctx.Set(fmt.Sprintf("key-%d", idx), idx) + _, _ = ctx.Get(fmt.Sprintf("key-%d", idx)) + + child := ctx.Fork() + child.Set("child_key", idx) + }(i) + } + + core.Provide[SampleService](ctx, &sampleServiceImpl{}) + + for i := 0; i < 50; i++ { + wg.Add(1) + go func() { + defer wg.Done() + svc, err := core.Inject[SampleService](ctx) + if err == nil { + _ = svc.Greet("Concurrency") + } + _ = core.Using(ctx, func(s SampleService) { + _ = s.Greet("Safe") + }) + }() + } + + wg.Wait() +} + +func TestContextExtensionPointsAccessors(t *testing.T) { + ctx := core.NewContext(nil) + assert.NotNil(t, ctx.Events()) + assert.NotNil(t, ctx.Router()) + assert.NotNil(t, ctx.Migrations()) + assert.NotNil(t, ctx.Tasks()) + assert.NotNil(t, ctx.Task()) + assert.NotNil(t, ctx.Schedules()) + assert.NotNil(t, ctx.Schedule()) + assert.NotNil(t, ctx.Settings()) + assert.NotNil(t, ctx.Setting()) + + child := ctx.Fork() + assert.Equal(t, ctx.Events(), child.Events()) + assert.Equal(t, ctx.Migrations(), child.Migrations()) + assert.NotNil(t, child.Router()) + assert.NotNil(t, child.Tasks()) + assert.NotNil(t, child.Task()) + assert.NotNil(t, child.Schedules()) + assert.NotNil(t, child.Schedule()) + assert.NotNil(t, child.Settings()) + assert.NotNil(t, child.Setting()) +} + +func TestContext_ScopedExtpoints_RevertibleEffects(t *testing.T) { + root := core.NewContext(context.Background()) + child := root.Fork() + + // Register route, task, schedule, setting, event on child + child.Router().GET("/test-route", func() {}) + assert.Equal(t, 1, len(root.Router().Routes())) + + child.Tasks().Register("test:task", func() {}) + assert.Equal(t, 1, len(root.Tasks().Tasks())) + + child.Schedules().RegisterCron("@hourly", "test:cron", nil) + assert.Equal(t, 1, len(root.Schedules().Schedules())) + + child.Settings().Register(core.SettingSchema{Key: "test.key", Default: "val"}) + assert.Equal(t, 1, len(root.Settings().Schemas())) + + child.On("test:event", func() {}) + assert.Equal(t, 1, root.Events().Listeners("test:event")) + + // Dispose child + err := child.Dispose() + assert.NoError(t, err) + + // All child effects should be cleanly revoked in LIFO order + assert.Equal(t, 0, len(root.Router().Routes())) + assert.Equal(t, 0, len(root.Tasks().Tasks())) + assert.Equal(t, 0, len(root.Schedules().Schedules())) + assert.Equal(t, 0, len(root.Settings().Schemas())) + assert.Equal(t, 0, root.Events().Listeners("test:event")) +} diff --git a/backend/core/contracts/auth.go b/backend/core/contracts/auth.go new file mode 100644 index 00000000..456b6770 --- /dev/null +++ b/backend/core/contracts/auth.go @@ -0,0 +1,143 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package contracts defines unified service interfaces and DTOs for cross-plugin communication. +package contracts + +import ( + "context" + "time" +) + +// UserDTO represents a unified user data transfer object across plugins. +type UserDTO struct { + ID uint64 `json:"id,string"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Email string `json:"email"` + AvatarURL string `json:"avatar_url"` + IsActive bool `json:"is_active"` + IsAdmin bool `json:"is_admin"` + NeedChangePassword bool `json:"need_change_password,omitempty"` + Bio string `json:"bio,omitempty"` + Phone string `json:"phone,omitempty"` + Gender string `json:"gender,omitempty"` + Website string `json:"website,omitempty"` + Location string `json:"location,omitempty"` + LastLoginAt time.Time `json:"last_login_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// OAuthUserInfoDTO contains user identity claims obtained from an OAuth provider. +type OAuthUserInfoDTO struct { + ID uint64 `json:"id"` + Sub string `json:"sub"` + Username string `json:"username"` + PreferredUsername string `json:"preferred_username"` + Email string `json:"email"` + Name string `json:"name"` + Active bool `json:"active"` + AvatarURL string `json:"avatar_url"` +} + +// AuthSourceDTO represents an OAuth / OIDC authentication source. +type AuthSourceDTO struct { + ID uint64 `json:"id,string"` + Name string `json:"name"` + Type string `json:"type"` + DisplayName string `json:"display_name"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret,omitempty"` + OpenIDDiscoveryURL string `json:"openid_discovery_url"` + Scopes string `json:"scopes"` + IconURL string `json:"icon_url"` + IsActive bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// AuthSourceViewDTO is a sanitized view of an AuthSource for admin display. +type AuthSourceViewDTO struct { + ID uint64 `json:"id,string"` + Name string `json:"name"` + Type string `json:"type"` + DisplayName string `json:"display_name"` + IsActive bool `json:"is_active"` + IconURL string `json:"icon_url"` + ClientSecretConfigured bool `json:"client_secret_configured"` +} + +// OAuthProvider defines the pluggable OAuth provider contract. +type OAuthProvider interface { + Name() string + GetAuthURL(state string) string + ExchangeCode(ctx context.Context, code string) (*OAuthUserInfoDTO, error) +} + +// AuthService defines the contract for authentication, session verification, and token management. +type AuthService interface { + // RequireAuthMiddleware returns a middleware handler (compatible with gin.HandlerFunc or standard middleware). + RequireAuthMiddleware() any + + // RequireAdminMiddleware returns an admin authorization middleware. + RequireAdminMiddleware() any + + // GetCurrentUser retrieves the authenticated UserDTO from context. + GetCurrentUser(ctx context.Context) (*UserDTO, error) + + // GetCurrentUserID retrieves the authenticated user ID from session/context. + GetCurrentUserID(ctx context.Context) (uint64, error) + + // VerifyToken validates an access token and returns the associated user DTO. + VerifyToken(ctx context.Context, token string) (*UserDTO, error) + + // CreateSession establishes an authenticated session for the given user ID. + CreateSession(ctx context.Context, userID uint64, extras map[string]any) (string, error) + + // RevokeToken invalidates a specific access token by its hash. + RevokeToken(ctx context.Context, tokenHash string) error + + // RevokeUserSessions revokes all active sessions and cached tokens for a user. + RevokeUserSessions(ctx context.Context, userID uint64) error + + // InvalidateCachedUser invalidates cached user profile data. + InvalidateCachedUser(ctx context.Context, userID uint64) + + // InvalidateCachedToken invalidates cached access token data. + InvalidateCachedToken(ctx context.Context, tokenHash string) + + // ListAuthSources lists all configured authentication sources. + ListAuthSources(ctx context.Context) ([]AuthSourceViewDTO, error) + + // CreateAuthSource creates a new authentication source. + CreateAuthSource(ctx context.Context, source AuthSourceDTO) (*AuthSourceDTO, error) + + // UpdateAuthSource updates an authentication source. + UpdateAuthSource(ctx context.Context, id uint64, source AuthSourceDTO) (*AuthSourceDTO, error) + + // DeleteAuthSource removes an authentication source. + DeleteAuthSource(ctx context.Context, id uint64) error + + // ToggleAuthSource toggles the active state of an authentication source. + ToggleAuthSource(ctx context.Context, id uint64) (*AuthSourceDTO, error) + + // DisallowTokenAuthMiddleware returns a middleware that rejects requests authenticated via access token. + DisallowTokenAuthMiddleware() any +} + +// AuthRegistry allows downstream and domain plugins to register custom authentication providers. +type AuthRegistry interface { + RegisterOAuthProvider(name string, provider OAuthProvider) + GetOAuthProvider(name string) (OAuthProvider, bool) + ListOAuthProviders() []string +} + +// Auth context keys — stored in Gin context by auth middleware, consumed by domain plugins. +const ( + AuthUserIDKey = "user_id" + AuthUserNameKey = "username" + AuthUserObjKey = "user_obj" + AuthTokenAuthKey = "token_auth" // marks if request uses access token auth + AuthTokenAdminKey = "token_admin" // whether the access token has admin privileges +) diff --git a/backend/core/contracts/cache.go b/backend/core/contracts/cache.go new file mode 100644 index 00000000..9760c465 --- /dev/null +++ b/backend/core/contracts/cache.go @@ -0,0 +1,32 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package contracts defines unified service interfaces and DTOs for cross-plugin communication. +package contracts + +import ( + "context" + "errors" + "time" +) + +// ErrCacheMiss is returned when an item is not found in the cache. +var ErrCacheMiss = errors.New("contracts/cache: key not found") + +// CacheService defines the contract for multi-layer cache operations (RAM L1 + Redis L2 + Pub/Sub invalidation). +type CacheService interface { + // Get retrieves an item from cache into target. Returns ErrCacheMiss if not found. + Get(ctx context.Context, key string, target any) error + + // Set stores an item into cache with a specified time-to-live duration. + Set(ctx context.Context, key string, value any, ttl time.Duration) error + + // Delete evicts a key from local and remote cache tiers and broadcasts invalidation. + Delete(ctx context.Context, key string) error + + // GetOrSet retrieves an item from cache, or calls loader to populate and return if missing. + GetOrSet(ctx context.Context, key string, target any, ttl time.Duration, loader func() (any, error)) error + + // Invalidate is a semantic alias for Delete. + Invalidate(ctx context.Context, key string) error +} diff --git a/backend/core/contracts/database.go b/backend/core/contracts/database.go new file mode 100644 index 00000000..693ffe0e --- /dev/null +++ b/backend/core/contracts/database.go @@ -0,0 +1,23 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package contracts defines unified service interfaces and DTOs for cross-plugin communication. +package contracts + +import ( + "context" + + "gorm.io/gorm" +) + +// DBService defines the standard contract for relational database access and multi-datasource routing. +type DBService interface { + // GORM returns the underlying GORM database instance. + GORM() *gorm.DB + + // DB returns the GORM database instance bound to the given context. + DB(ctx context.Context) *gorm.DB + + // Named returns a named database connection if multiple data sources or replicas are configured. + Named(name string) *gorm.DB +} diff --git a/backend/core/contracts/events.go b/backend/core/contracts/events.go new file mode 100644 index 00000000..8de13c0a --- /dev/null +++ b/backend/core/contracts/events.go @@ -0,0 +1,153 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package contracts defines unified service interfaces and DTOs for cross-plugin communication. +package contracts + +// ====================================================================== +// Domain Event Topic Constants +// ====================================================================== +// +// All cross-plugin domain event topics MUST be declared here so that +// producers and consumers share the same string values without importing +// each other's implementation packages. +// ====================================================================== + +// --- Auth & User Events --- +const ( + // EventTopicAdminLoggedIn fires when an admin user logs in. + EventTopicAdminLoggedIn = "admin:logged_in" + + // EventTopicUserCreated fires when a new user account is created. + EventTopicUserCreated = "user:created" + + // EventTopicUserUpdated fires when a user profile is updated. + EventTopicUserUpdated = "user:updated" + + // EventTopicUserDeleted fires when a user account is deleted. + EventTopicUserDeleted = "user:deleted" + + // EventTopicUserStatusChanged fires when a user account active status changes. + EventTopicUserStatusChanged = "user:status_changed" + + // EventTopicTokenRevoked fires when an access token is revoked. + // #nosec G101 + EventTopicTokenRevoked = "auth:token_revoked" +) + +// --- Admin & System Events --- +const ( + // EventTopicConfigChanged fires when a system configuration value changes. + EventTopicConfigChanged = "admin:config_changed" + + // EventTopicSystemCleanup fires when a periodic system cleanup completes. + EventTopicSystemCleanup = "admin:system_cleanup" +) + +// --- Task Events --- +const ( + // EventTopicTaskCompleted fires when an asynchronous background task execution finishes. + EventTopicTaskCompleted = "task:completed" +) + +// TaskCompletedEvent carries task execution outcome details. +type TaskCompletedEvent struct { + TaskID string `json:"task_id"` + TaskName string `json:"task_name"` + TaskType string `json:"task_type"` + Status string `json:"status"` + Duration int64 `json:"duration"` + ErrorMsg string `json:"error_msg,omitempty"` + ResultMsg string `json:"result_msg,omitempty"` + Payload string `json:"payload,omitempty"` + Detail string `json:"detail,omitempty"` +} + +// --- Upload / Storage Events --- +const ( + // EventTopicUploadCreated fires when a new file upload is recorded. + EventTopicUploadCreated = "upload:created" + + // EventTopicUploadDeleted fires when a file upload is removed. + EventTopicUploadDeleted = "upload:deleted" + + // EventTopicIngestComplete fires when a programmatic file ingest finishes. + EventTopicIngestComplete = "upload:ingest_complete" +) + +// --- Message Gateway Events --- +const ( + // EventTopicNotificationSent fires when a push notification is dispatched. + EventTopicNotificationSent = "message:notification_sent" + + // EventTopicChannelBound fires when a user binds a messaging channel. + EventTopicChannelBound = "message:channel_bound" + + // EventTopicChannelUnbound fires when a user unbinds a messaging channel. + EventTopicChannelUnbound = "message:channel_unbound" +) + +// --- Risk Control Events --- +const ( + // EventTopicAccessLogRecorded fires when a user access log entry is recorded. + EventTopicAccessLogRecorded = "risk:access_log_recorded" +) + +// ====================================================================== +// Domain Event Payload DTOs +// ====================================================================== + +// AdminLoggedIn 管理员登录领域事件载荷 +type AdminLoggedIn struct { + User *UserDTO `json:"user"` + IP string `json:"ip"` +} + +// UserCreatedEvent fires when a new user account is created. +type UserCreatedEvent struct { + User *UserDTO `json:"user"` + Password string `json:"-"` +} + +// ConfigChangedEvent fires when a system configuration value changes. +type ConfigChangedEvent struct { + Key string `json:"key"` + OldVal any `json:"old_val,omitempty"` + NewVal any `json:"new_val,omitempty"` +} + +// UploadCreatedEvent fires when a new file upload is recorded. +type UploadCreatedEvent struct { + UploadID uint64 `json:"upload_id,string"` + UserID uint64 `json:"user_id,string"` + FileName string `json:"file_name"` + FileSize int64 `json:"file_size"` + MimeType string `json:"mime_type"` +} + +// NotificationSentEvent fires when a push notification is dispatched. +type NotificationSentEvent struct { + UserID uint64 `json:"user_id,string"` + Channel string `json:"channel"` + Title string `json:"title"` + Success bool `json:"success"` + ErrorInfo string `json:"error_info,omitempty"` +} + +// UserStatusChangedEvent fires when a user status is enabled/disabled. +type UserStatusChangedEvent struct { + UserID uint64 `json:"user_id,string"` + IsActive bool `json:"is_active"` +} + +// TokenRevokedEvent fires when an access token is revoked. +type TokenRevokedEvent struct { + UserID uint64 `json:"user_id,string"` + TokenHash string `json:"token_hash"` +} + +// UserDeletedEvent fires when a user account is deleted. +type UserDeletedEvent struct { + CurrentUserID uint64 `json:"current_user_id,string"` + TargetUserID uint64 `json:"target_user_id,string"` +} diff --git a/backend/core/contracts/logger.go b/backend/core/contracts/logger.go new file mode 100644 index 00000000..186c9c60 --- /dev/null +++ b/backend/core/contracts/logger.go @@ -0,0 +1,39 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package contracts defines unified service interfaces and DTOs for cross-plugin communication. +package contracts + +import ( + "context" +) + +// LoggerService defines the contract for structured logging with trace ID and context correlation. +type LoggerService interface { + // Debug logs a debug message with optional key-value structured fields. + Debug(ctx context.Context, msg string, keysAndValues ...any) + + // Info logs an informational message with optional key-value structured fields. + Info(ctx context.Context, msg string, keysAndValues ...any) + + // Warn logs a warning message with optional key-value structured fields. + Warn(ctx context.Context, msg string, keysAndValues ...any) + + // Error logs an error message with optional key-value structured fields. + Error(ctx context.Context, msg string, keysAndValues ...any) + + // Debugf logs a formatted debug message. + Debugf(ctx context.Context, format string, args ...any) + + // Infof logs a formatted informational message. + Infof(ctx context.Context, format string, args ...any) + + // Warnf logs a formatted warning message. + Warnf(ctx context.Context, format string, args ...any) + + // Errorf logs a formatted error message. + Errorf(ctx context.Context, format string, args ...any) + + // With returns a child logger enriched with additional key-value attributes. + With(keysAndValues ...any) LoggerService +} diff --git a/backend/core/contracts/risk_control.go b/backend/core/contracts/risk_control.go new file mode 100644 index 00000000..e7ebb92a --- /dev/null +++ b/backend/core/contracts/risk_control.go @@ -0,0 +1,66 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package contracts defines unified service interfaces and DTOs for cross-plugin communication. +package contracts + +import ( + "context" + "time" +) + +// AccessLogFilterDTO defines filter criteria for querying user access logs. +type AccessLogFilterDTO struct { + UserIDs []uint64 + Path string + StartTime *time.Time + EndTime *time.Time +} + +// AccessLogDTO represents a single access log entry. +type AccessLogDTO struct { + ID uint64 `json:"id"` + UserID uint64 `json:"user_id"` + IP string `json:"ip"` + UserAgent string `json:"user_agent"` + Method string `json:"method"` + Path string `json:"path"` + Status int32 `json:"status"` + Latency int64 `json:"latency"` + CreatedAt time.Time `json:"created_at"` +} + +// AccessLogDailyStatsDTO represents aggregate access statistics for a single day. +type AccessLogDailyStatsDTO struct { + Date string `json:"date"` + PV uint64 `json:"pv"` + UV uint64 `json:"uv"` + IPCount uint64 `json:"ip_count"` + ErrorCount uint64 `json:"error_count"` + AvgLatencyMs int64 `json:"avg_latency_ms"` + SlowReqCount uint64 `json:"slow_req_count"` + MaxLatencyMs int64 `json:"max_latency_ms"` + P95LatencyMs int64 `json:"p95_latency_ms"` + P99LatencyMs int64 `json:"p99_latency_ms"` +} + +// RiskControlService defines the contract for accessing security risk control and audit logstore. +type RiskControlService interface { + // QueryAccessLogs retrieves paginated access logs matching the filter. + QueryAccessLogs(ctx context.Context, filter AccessLogFilterDTO, page, pageSize int) ([]AccessLogDTO, uint64, error) + + // QueryAccessLogStats returns aggregate daily statistics for the last N days. + QueryAccessLogStats(ctx context.Context, days int) ([]AccessLogDailyStatsDTO, error) + + // ActiveLogEngine returns the current active logstore engine name. + ActiveLogEngine(ctx context.Context) string + + // IsLogEngineMigrating reports whether a log engine migration is in progress. + IsLogEngineMigrating(ctx context.Context) bool + + // Drain flushes pending in-flight log buffers. + Drain(ctx context.Context) error + + // SwitchLogEngine migrates and switches the active log storage engine. + SwitchLogEngine(ctx context.Context, targetEngine string) error +} diff --git a/backend/core/contracts/storage.go b/backend/core/contracts/storage.go new file mode 100644 index 00000000..81d43213 --- /dev/null +++ b/backend/core/contracts/storage.go @@ -0,0 +1,112 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package contracts defines unified service interfaces and DTOs for cross-plugin communication. +package contracts + +import ( + "context" + "io" +) + +// StorageObject represents a retrieved file object from the storage backend. +type StorageObject struct { + Key string + CachePath string + Body io.ReadCloser + ContentLength int64 + ContentType string +} + +// StoragePutResult describes the output of a successful Put operation. +type StoragePutResult struct { + Key string + Bucket string +} + +// IngestOptions configures programmatic ingest of files into the platform storage. +type IngestOptions struct { + UserID uint64 + Type string + FileName string + MimeType string + Extension string + Size int64 + Policy int + Metadata map[string]any +} + +// IngestResult reports the outcome of a programmatic file ingest operation. +type IngestResult struct { + ID uint64 + Key string + URL string + Created bool + Stored bool + Resolved bool +} + +// StorageDriver identifies a supported storage backend. +type StorageDriver string + +// Storage drivers supported by the platform. Values persist in storage configs. +const ( + StorageDriverLocal StorageDriver = "local" + StorageDriverS3 StorageDriver = "s3" + StorageDriverR2 StorageDriver = "r2" + StorageDriverMinIO StorageDriver = "minio" + StorageDriverOSS StorageDriver = "oss" + StorageDriverWebDAV StorageDriver = "webdav" +) + +// LocalStorageConfigDTO configures local filesystem storage. +type LocalStorageConfigDTO struct { + Root string `json:"root"` +} + +// ObjectStorageConfigDTO configures S3-compatible or OSS object storage. +type ObjectStorageConfigDTO struct { + Endpoint string `json:"endpoint"` + Region string `json:"region"` + Bucket string `json:"bucket"` + AccessKeyID string `json:"access_key_id"` + SecretAccessKey string `json:"secret_access_key"` + AccountID string `json:"account_id,omitempty"` + PathStyle bool `json:"path_style"` + KeyPrefix string `json:"key_prefix"` + CDNURL string `json:"cdn_url"` +} + +// WebDAVStorageConfigDTO configures WebDAV storage. +type WebDAVStorageConfigDTO struct { + URL string `json:"url"` + Username string `json:"username"` + Password string `json:"password"` + Root string `json:"root"` +} + +// StorageConfigDTO encapsulates full storage configuration across all backends. +type StorageConfigDTO struct { + Driver StorageDriver `json:"driver"` + Local LocalStorageConfigDTO `json:"local"` + S3 ObjectStorageConfigDTO `json:"s3"` + R2 ObjectStorageConfigDTO `json:"r2"` + MinIO ObjectStorageConfigDTO `json:"minio"` + OSS ObjectStorageConfigDTO `json:"oss"` + WebDAV WebDAVStorageConfigDTO `json:"webdav"` +} + +// StorageService defines the contract for unified object storage and managed file ingestion. +type StorageService interface { + // Put writes an object to storage. + Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) (StoragePutResult, error) + + // Get retrieves an object from storage. + Get(ctx context.Context, key string) (*StorageObject, error) + + // Delete removes an object from storage. + Delete(ctx context.Context, key string) error + + // Ingest performs managed file ingestion into the platform storage domain with deduplication and metadata tracking. + Ingest(ctx context.Context, reader io.Reader, opts IngestOptions) (*IngestResult, error) +} diff --git a/backend/core/contracts/task.go b/backend/core/contracts/task.go new file mode 100644 index 00000000..3fe1c01c --- /dev/null +++ b/backend/core/contracts/task.go @@ -0,0 +1,79 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package contracts defines unified service interfaces and DTOs for cross-plugin communication. +package contracts + +import ( + "context" + "time" +) + +// TaskParamDTO describes a parameter accepted by a background task. +type TaskParamDTO struct { + Name string `json:"name"` + Label string `json:"label"` + Type string `json:"type"` + Required bool `json:"required"` + Placeholder string `json:"placeholder,omitempty"` + Description string `json:"description,omitempty"` + Default any `json:"default,omitempty"` +} + +// TaskMetaDTO describes the metadata and configuration of a registered background task. +type TaskMetaDTO struct { + Type string `json:"type"` + AsynqTask string `json:"asynq_task"` + Name string `json:"name"` + DisplayName string `json:"display_name,omitempty"` + Description string `json:"description"` + Category string `json:"category,omitempty"` + SupportsTime bool `json:"supports_time"` + Params []TaskParamDTO `json:"params,omitempty"` + MaxRetry int `json:"max_retry"` + Timeout time.Duration `json:"timeout,omitempty"` + Queue string `json:"queue"` + Retryable bool `json:"retryable"` + Schedule string `json:"schedule,omitempty"` +} + +// TaskResultDTO represents the outcome of a background task execution. +type TaskResultDTO struct { + Message string `json:"message"` + Detail any `json:"detail,omitempty"` +} + +// TaskExecutionDTO represents a single task execution record. +type TaskExecutionDTO struct { + ID uint64 `json:"id,string"` + TaskID string `json:"task_id"` + TaskType string `json:"task_type"` + TaskName string `json:"task_name"` + Status string `json:"status"` + Retryable bool `json:"retryable"` + MaxRetry int `json:"max_retry"` + RetryCount int `json:"retry_count"` + Log string `json:"log"` + ErrorMessage string `json:"error_message"` + Result string `json:"result"` + StartedAt *time.Time `json:"started_at"` + FinishedAt *time.Time `json:"finished_at"` + Duration int64 `json:"duration"` + Payload string `json:"payload"` + TriggeredBy string `json:"triggered_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TaskService defines the unified contract for dispatching and tracking background tasks. +type TaskService interface { + Dispatch(ctx context.Context, taskType string, payload []byte, triggeredBy string) (string, error) + Retry(ctx context.Context, id uint64) (string, error) + ListTasks() []TaskMetaDTO + GetTaskMeta(taskType string) (TaskMetaDTO, bool) + ValidatePayload(taskType string, payload []byte) ([]byte, error) + ReloadScheduler() error + AppendLog(ctx context.Context, format string, args ...any) + ListExecutions(ctx context.Context, taskType, status string, page, pageSize int) ([]TaskExecutionDTO, int64, error) + GetExecution(ctx context.Context, id uint64) (*TaskExecutionDTO, error) +} diff --git a/backend/core/contracts/user.go b/backend/core/contracts/user.go new file mode 100644 index 00000000..f3fbb51a --- /dev/null +++ b/backend/core/contracts/user.go @@ -0,0 +1,134 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package contracts defines unified service interfaces and DTOs for cross-plugin communication. +package contracts + +import ( + "context" +) + +// CreateUserRequest contains fields to register or create a new user. +type CreateUserRequest struct { + Username string `json:"username"` + Password string `json:"password"` + Nickname string `json:"nickname"` + Email string `json:"email"` + IsAdmin bool `json:"is_admin"` +} + +// UpdateUserProfileRequest contains fields for updating a user's profile. +type UpdateUserProfileRequest struct { + Nickname *string `json:"nickname,omitempty"` + Email *string `json:"email,omitempty"` + AvatarURL *string `json:"avatar_url,omitempty"` + Bio *string `json:"bio,omitempty"` + Phone *string `json:"phone,omitempty"` + Gender *string `json:"gender,omitempty"` + Website *string `json:"website,omitempty"` + Location *string `json:"location,omitempty"` +} + +// AdminListUsersFilter contains query parameters for filtering users in admin panel. +type AdminListUsersFilter struct { + Page int + PageSize int + UserID *uint64 + Username string + Email string +} + +// AdminCreateUserRequest contains fields for admin to create a user. +type AdminCreateUserRequest struct { + Username string `json:"username"` + Password string `json:"password"` + Nickname string `json:"nickname"` + Email string `json:"email"` + IsActive bool `json:"is_active"` + IsAdmin bool `json:"is_admin"` +} + +// AdminUpdateUserRequest contains fields for admin to update a user. +type AdminUpdateUserRequest struct { + ID uint64 `json:"id,string"` + Nickname string `json:"nickname"` + Email string `json:"email"` + IsAdmin bool `json:"is_admin"` + Password string `json:"password,omitempty"` +} + +// UserService defines the contract for user account management and profile queries. +type UserService interface { + // GetUserByID retrieves a user by ID. + GetUserByID(ctx context.Context, id uint64) (*UserDTO, error) + + // GetUsersByIDs retrieves several users in one round-trip. An empty ids + // slice yields no results and touches no storage. + GetUsersByIDs(ctx context.Context, ids []uint64) ([]*UserDTO, error) + + // GetUserByUsername retrieves a user by username. + GetUserByUsername(ctx context.Context, username string) (*UserDTO, error) + + // GetUserByEmail retrieves a user by email. + GetUserByEmail(ctx context.Context, email string) (*UserDTO, error) + + // CreateUser registers or creates a new user account. + CreateUser(ctx context.Context, req CreateUserRequest) (*UserDTO, error) + + // UpdateProfile updates the profile of the specified user. + UpdateProfile(ctx context.Context, id uint64, req UpdateUserProfileRequest) (*UserDTO, error) + + // UpdatePassword updates the password for the specified user after verifying the old password. + UpdatePassword(ctx context.Context, id uint64, oldPassword, newPassword string) error + + // VerifyPassword verifies if the given password matches the user's password. + VerifyPassword(ctx context.Context, id uint64, password string) bool + + // UpdateLastLogin updates the user's last login timestamp. + UpdateLastLogin(ctx context.Context, id uint64, ip string) error + + // ListUsers returns a paginated list of users with optional keyword search. + ListUsers(ctx context.Context, page, pageSize int, keyword string) ([]*UserDTO, int64, error) + + // SetUserActive sets the active/banned status for a user. + SetUserActive(ctx context.Context, id uint64, active bool) error + + // SetUserAdmin sets the admin role status for a user. + SetUserAdmin(ctx context.Context, id uint64, admin bool) error + + // VerifyAccessToken verifies an access token hash and returns the user DTO and isAdmin flag. + VerifyAccessToken(ctx context.Context, tokenHash string) (*UserDTO, bool, error) + + // DeleteUser removes a user and related access tokens. + DeleteUser(ctx context.Context, id uint64) error + + // CountUsers returns total user count. + CountUsers(ctx context.Context) (int64, error) + + // CountActiveUsers returns active user count. + CountActiveUsers(ctx context.Context) (int64, error) + + // GetFirstAdminUser returns the earliest admin user. + GetFirstAdminUser(ctx context.Context) (*UserDTO, error) + + // UniqueUsername generates a unique username candidate based on base. + UniqueUsername(ctx context.Context, base string) (string, error) + + // AdminListUsers returns a filtered paginated list of users for admin management. + AdminListUsers(ctx context.Context, filter AdminListUsersFilter) (int64, []*UserDTO, error) + + // AdminGetUser retrieves complete user details by ID for admin management. + AdminGetUser(ctx context.Context, id uint64) (*UserDTO, error) + + // AdminCreateUser creates a user with admin specified options. + AdminCreateUser(ctx context.Context, req AdminCreateUserRequest) (*UserDTO, error) + + // AdminUpdateUser updates user details, email, nickname, admin role, and optional password. + AdminUpdateUser(ctx context.Context, currentUserID uint64, req AdminUpdateUserRequest) error + + // AdminUpdateUserStatus updates a user's active status (with admin protection). + AdminUpdateUserStatus(ctx context.Context, id uint64, active bool) error + + // AdminDeleteUser deletes a user (with self and admin protection, cascading tokens and accounts). + AdminDeleteUser(ctx context.Context, currentUserID, targetID uint64) error +} diff --git a/backend/core/events.go b/backend/core/events.go new file mode 100644 index 00000000..d30be096 --- /dev/null +++ b/backend/core/events.go @@ -0,0 +1,472 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package core + +import ( + "context" + "errors" + "fmt" + "reflect" + "sync" + "sync/atomic" +) + +const maxHandlerParams = 2 + +var ( + ctxInterfaceType = reflect.TypeFor[context.Context]() + errInterfaceType = reflect.TypeFor[error]() +) + +type eventListener struct { + id uint64 + fnVal reflect.Value + numIn int + numOut int + hasCtx bool + hasPayload bool + argType reflect.Type + returnsErr bool + returnsVal bool +} + +// EventBus is a thread-safe, strongly-typed in-process domain event bus supporting +// Emit, Waterfall, Parallel, and Serial dispatch semantics. +type EventBus struct { + mu sync.RWMutex + nextID atomic.Uint64 + handlers map[string][]eventListener +} + +// NewEventBus creates a new EventBus instance. +func NewEventBus() *EventBus { + return &EventBus{ + handlers: make(map[string][]eventListener), + } +} + +// On registers an event handler for the given topic. +// +// Supported handler signatures: +// - func(ctx context.Context, event T) (T, error) +// - func(ctx context.Context, event T) T +// - func(event T) (T, error) +// - func(event T) T +// - func(ctx context.Context, event T) error +// - func(ctx context.Context, event T) +// - func(event T) error +// - func(event T) +// - func(ctx context.Context) error +// - func(ctx context.Context) +// - func() error +// - func() +// +// Returns a Disposer function that unregisters the handler when called. +func (b *EventBus) On(topic string, handler any) Disposer { + if handler == nil { + panic("core/events: handler cannot be nil") + } + + fnVal := reflect.ValueOf(handler) + fnType := fnVal.Type() + + if fnType.Kind() != reflect.Func { + panic(fmt.Sprintf("core/events: expected func, got %s", fnType.Kind())) + } + + numIn := fnType.NumIn() + if numIn > maxHandlerParams { + panic(fmt.Sprintf("core/events: handler has %d parameters, maximum 2 supported (ctx, event)", numIn)) + } + + const maxHandlerReturnValues = 2 + numOut := fnType.NumOut() + if numOut > maxHandlerReturnValues { + panic(fmt.Sprintf("core/events: handler has %d return values, maximum 2 supported (value, error)", numOut)) + } + + returnsErr := false + returnsVal := false + + switch numOut { + case 1: + out0 := fnType.Out(0) + if out0.Implements(errInterfaceType) { + returnsErr = true + } else { + returnsVal = true + } + case 2: + out1 := fnType.Out(1) + if !out1.Implements(errInterfaceType) { + panic(fmt.Sprintf("core/events: second return value must be error, got %v", out1)) + } + returnsVal = true + returnsErr = true + } + + listener := eventListener{ + id: b.nextID.Add(1), + fnVal: fnVal, + numIn: numIn, + numOut: numOut, + returnsErr: returnsErr, + returnsVal: returnsVal, + } + + switch numIn { + case 0: + // func() or func() error + case 1: + in0 := fnType.In(0) + if in0.Implements(ctxInterfaceType) { + listener.hasCtx = true + } else { + listener.hasPayload = true + listener.argType = in0 + } + case 2: + in0 := fnType.In(0) + if !in0.Implements(ctxInterfaceType) { + panic(fmt.Sprintf("core/events: first parameter must implement context.Context, got %v", in0)) + } + listener.hasCtx = true + listener.hasPayload = true + listener.argType = fnType.In(1) + } + + b.mu.Lock() + b.handlers[topic] = append(b.handlers[topic], listener) + b.mu.Unlock() + + listenerID := listener.id + var disposed atomic.Bool + + return func() error { + if disposed.Swap(true) { + return nil + } + + b.mu.Lock() + defer b.mu.Unlock() + + list := b.handlers[topic] + for i, l := range list { + if l.id == listenerID { + b.handlers[topic] = append(list[:i], list[i+1:]...) + break + } + } + + if len(b.handlers[topic]) == 0 { + delete(b.handlers, topic) + } + + return nil + } +} + +// Subscribe registers a strongly-typed generic event listener on the given EventBus. +func Subscribe[T any](bus *EventBus, topic string, handler func(ctx context.Context, event T) error) Disposer { + if bus == nil { + panic("core/events: nil EventBus provided to Subscribe") + } + return bus.On(topic, handler) +} + +// Emit publishes an event to all subscribers of the specified topic. +// Handlers are executed synchronously. If any handler panics or returns an error, +// the error is collected and returned via errors.Join. +// +//nolint:contextcheck +func (b *EventBus) Emit(ctx context.Context, topic string, payload any) error { + if ctx == nil { + ctx = context.Background() + } + + listeners := b.getListeners(topic) + if len(listeners) == 0 { + return nil + } + + var payloadVal reflect.Value + if payload != nil { + payloadVal = reflect.ValueOf(payload) + } + + var errs []error + for _, l := range listeners { + args := b.buildArgs(ctx, l, payloadVal) + + err := func() (resErr error) { + defer func() { + if r := recover(); r != nil { + resErr = fmt.Errorf("core/events: panic in handler for topic %q: %v", topic, r) + } + }() + + results := l.fnVal.Call(args) + if l.returnsErr { + errIdx := l.numOut - 1 + if len(results) > errIdx && !results[errIdx].IsNil() { + resErr = results[errIdx].Interface().(error) + } + } + return resErr + }() + if err != nil { + errs = append(errs, err) + } + } + + return errors.Join(errs...) +} + +// Waterfall runs handlers sequentially as a transformation pipeline. +// The returned value of each handler becomes the payload input for the next handler. +// If any handler returns an error or panics, execution aborts immediately. +// +//nolint:contextcheck +func (b *EventBus) Waterfall(ctx context.Context, topic string, initialPayload any) (any, error) { + if ctx == nil { + ctx = context.Background() + } + + listeners := b.getListeners(topic) + if len(listeners) == 0 { + return initialPayload, nil + } + + currentPayload := initialPayload + + for _, l := range listeners { + var payloadVal reflect.Value + if currentPayload != nil { + payloadVal = reflect.ValueOf(currentPayload) + } + + args := b.buildArgs(ctx, l, payloadVal) + + var stepVal any + var stepErr error + + func() { + defer func() { + if r := recover(); r != nil { + stepErr = fmt.Errorf("core/events: panic in waterfall handler for topic %q: %v", topic, r) + } + }() + + results := l.fnVal.Call(args) + if l.returnsErr { + errIdx := l.numOut - 1 + if len(results) > errIdx && !results[errIdx].IsNil() { + stepErr = results[errIdx].Interface().(error) + } + } + if stepErr == nil && l.returnsVal && len(results) > 0 { + stepVal = results[0].Interface() + } + }() + + if stepErr != nil { + return nil, stepErr + } + + if l.returnsVal { + currentPayload = stepVal + } + } + + return currentPayload, nil +} + +// Parallel executes all subscribers of the topic concurrently in separate goroutines. +// It waits for all handlers to complete and collects any errors via errors.Join. +// +//nolint:contextcheck +func (b *EventBus) Parallel(ctx context.Context, topic string, payload any) error { + if ctx == nil { + ctx = context.Background() + } + + listeners := b.getListeners(topic) + if len(listeners) == 0 { + return nil + } + + var payloadVal reflect.Value + if payload != nil { + payloadVal = reflect.ValueOf(payload) + } + + var wg sync.WaitGroup + errCh := make(chan error, len(listeners)) + + for _, l := range listeners { + wg.Add(1) + go func(listener eventListener) { + defer wg.Done() + defer func() { + if r := recover(); r != nil { + errCh <- fmt.Errorf("core/events: panic in parallel handler for topic %q: %v", topic, r) + } + }() + + args := b.buildArgs(ctx, listener, payloadVal) + results := listener.fnVal.Call(args) + if listener.returnsErr { + errIdx := listener.numOut - 1 + if len(results) > errIdx && !results[errIdx].IsNil() { + errCh <- results[errIdx].Interface().(error) + } + } + }(l) + } + + wg.Wait() + close(errCh) + + var errs []error + for err := range errCh { + if err != nil { + errs = append(errs, err) + } + } + + return errors.Join(errs...) +} + +// Serial executes subscribers strictly in sequence. +// If any subscriber returns an error or panics, execution stops immediately and returns that error. +// +//nolint:contextcheck +func (b *EventBus) Serial(ctx context.Context, topic string, payload any) error { + if ctx == nil { + ctx = context.Background() + } + + listeners := b.getListeners(topic) + if len(listeners) == 0 { + return nil + } + + var payloadVal reflect.Value + if payload != nil { + payloadVal = reflect.ValueOf(payload) + } + + for _, l := range listeners { + args := b.buildArgs(ctx, l, payloadVal) + + var stepErr error + func() { + defer func() { + if r := recover(); r != nil { + stepErr = fmt.Errorf("core/events: panic in serial handler for topic %q: %v", topic, r) + } + }() + + results := l.fnVal.Call(args) + if l.returnsErr { + errIdx := l.numOut - 1 + if len(results) > errIdx && !results[errIdx].IsNil() { + stepErr = results[errIdx].Interface().(error) + } + } + }() + + if stepErr != nil { + return stepErr + } + } + + return nil +} + +func (b *EventBus) getListeners(topic string) []eventListener { + b.mu.RLock() + raw := b.handlers[topic] + if len(raw) == 0 { + b.mu.RUnlock() + return nil + } + listeners := make([]eventListener, len(raw)) + copy(listeners, raw) + b.mu.RUnlock() + return listeners +} + +func (b *EventBus) buildArgs(ctx context.Context, l eventListener, payloadVal reflect.Value) []reflect.Value { + if l.numIn == 0 { + return nil + } + + args := make([]reflect.Value, 0, l.numIn) + if l.hasCtx { + args = append(args, reflect.ValueOf(ctx)) + } + + if l.hasPayload { + arg := b.convertPayload(payloadVal, l.argType) + args = append(args, arg) + } + + return args +} + +func (b *EventBus) convertPayload(payloadVal reflect.Value, targetType reflect.Type) reflect.Value { + if !payloadVal.IsValid() { + return reflect.Zero(targetType) + } + + valType := payloadVal.Type() + + // 1. Direct assignable + if valType.AssignableTo(targetType) { + return payloadVal + } + + // 2. Direct convertible + if valType.ConvertibleTo(targetType) { + return payloadVal.Convert(targetType) + } + + // 3. Payload is pointer *T, target expects T + if valType.Kind() == reflect.Pointer && valType.Elem().AssignableTo(targetType) { + if !payloadVal.IsNil() { + return payloadVal.Elem() + } + return reflect.Zero(targetType) + } + + // 4. Payload is value T, target expects *T + if targetType.Kind() == reflect.Pointer && valType.AssignableTo(targetType.Elem()) { + ptr := reflect.New(valType) + ptr.Elem().Set(payloadVal) + return ptr + } + + // Fallback to zero value of targetType + return reflect.Zero(targetType) +} + +// Listeners returns the number of active listeners for a topic. +func (b *EventBus) Listeners(topic string) int { + b.mu.RLock() + defer b.mu.RUnlock() + return len(b.handlers[topic]) +} + +// Topics returns all topics that have registered listeners. +func (b *EventBus) Topics() []string { + b.mu.RLock() + defer b.mu.RUnlock() + + topics := make([]string, 0, len(b.handlers)) + for t := range b.handlers { + topics = append(topics, t) + } + return topics +} diff --git a/backend/core/events_test.go b/backend/core/events_test.go new file mode 100644 index 00000000..730661fc --- /dev/null +++ b/backend/core/events_test.go @@ -0,0 +1,395 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package core_test + +import ( + "Wavelet/core" + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type UserRegisteredEvent struct { + UserID string `json:"user_id"` + Username string `json:"username"` +} + +type OrderCreatedEvent struct { + OrderID string `json:"order_id"` + Amount float64 `json:"amount"` +} + +func TestEventBusPublishSubscribe(t *testing.T) { + bus := core.NewEventBus() + var receivedID string + + disposer := bus.On("user:registered", func(ctx context.Context, e UserRegisteredEvent) error { + receivedID = e.UserID + return nil + }) + require.NotNil(t, disposer) + + assert.Equal(t, []string{"user:registered"}, bus.Topics()) + + err := bus.Emit(context.Background(), "user:registered", UserRegisteredEvent{UserID: "u_999", Username: "alice"}) + assert.NoError(t, err) + assert.Equal(t, "u_999", receivedID) + + // Emit to empty topic returns nil error + err = bus.Emit(nil, "empty:topic", nil) + assert.NoError(t, err) +} + +func TestEventBusGenericSubscribe(t *testing.T) { + bus := core.NewEventBus() + var receivedOrder string + + assert.Panics(t, func() { + core.Subscribe[OrderCreatedEvent](nil, "order:created", func(ctx context.Context, e OrderCreatedEvent) error { + return nil + }) + }) + + disposer := core.Subscribe(bus, "order:created", func(ctx context.Context, e OrderCreatedEvent) error { + receivedOrder = e.OrderID + return nil + }) + require.NotNil(t, disposer) + + err := bus.Emit(context.Background(), "order:created", OrderCreatedEvent{OrderID: "ord_123", Amount: 99.5}) + assert.NoError(t, err) + assert.Equal(t, "ord_123", receivedOrder) + + // Test unsubscribe via disposer + err = disposer() + assert.NoError(t, err) + + receivedOrder = "" + err = bus.Emit(context.Background(), "order:created", OrderCreatedEvent{OrderID: "ord_456", Amount: 100}) + assert.NoError(t, err) + assert.Empty(t, receivedOrder, "handler should not be called after disposal") +} + +func TestEventBusHandlerSignatures(t *testing.T) { + bus := core.NewEventBus() + + var ( + calledWithCtxPayloadErr atomic.Bool + calledWithCtxPayload atomic.Bool + calledWithPayloadErr atomic.Bool + calledWithPayload atomic.Bool + calledWithCtxErr atomic.Bool + calledWithCtx atomic.Bool + calledWithNoArgsErr atomic.Bool + calledWithNoArgs atomic.Bool + ) + + bus.On("test:sig", func(ctx context.Context, e UserRegisteredEvent) error { + calledWithCtxPayloadErr.Store(true) + assert.Equal(t, "u_1", e.UserID) + return nil + }) + + bus.On("test:sig", func(ctx context.Context, e UserRegisteredEvent) { + calledWithCtxPayload.Store(true) + assert.Equal(t, "u_1", e.UserID) + }) + + bus.On("test:sig", func(e UserRegisteredEvent) error { + calledWithPayloadErr.Store(true) + assert.Equal(t, "u_1", e.UserID) + return nil + }) + + bus.On("test:sig", func(e UserRegisteredEvent) { + calledWithPayload.Store(true) + assert.Equal(t, "u_1", e.UserID) + }) + + bus.On("test:sig", func(ctx context.Context) error { + calledWithCtxErr.Store(true) + return nil + }) + + bus.On("test:sig", func(ctx context.Context) { + calledWithCtx.Store(true) + }) + + bus.On("test:sig", func() error { + calledWithNoArgsErr.Store(true) + return nil + }) + + bus.On("test:sig", func() { + calledWithNoArgs.Store(true) + }) + + err := bus.Emit(context.Background(), "test:sig", UserRegisteredEvent{UserID: "u_1", Username: "test"}) + assert.NoError(t, err) + + assert.True(t, calledWithCtxPayloadErr.Load()) + assert.True(t, calledWithCtxPayload.Load()) + assert.True(t, calledWithPayloadErr.Load()) + assert.True(t, calledWithPayload.Load()) + assert.True(t, calledWithCtxErr.Load()) + assert.True(t, calledWithCtx.Load()) + assert.True(t, calledWithNoArgsErr.Load()) + assert.True(t, calledWithNoArgs.Load()) +} + +func TestEventBusPointerAndValueConversion(t *testing.T) { + bus := core.NewEventBus() + + var ( + receivedFromValueToPtr atomic.Bool + receivedFromPtrToValue atomic.Bool + receivedFromNilPtr atomic.Bool + ) + + // Handler expects pointer, payload emitted as value + bus.On("test:ptr", func(ctx context.Context, e *UserRegisteredEvent) error { + if e != nil && e.UserID == "u_ptr" { + receivedFromValueToPtr.Store(true) + } + return nil + }) + + err := bus.Emit(context.Background(), "test:ptr", UserRegisteredEvent{UserID: "u_ptr"}) + assert.NoError(t, err) + assert.True(t, receivedFromValueToPtr.Load()) + + // Handler expects value, payload emitted as pointer + bus.On("test:val", func(ctx context.Context, e UserRegisteredEvent) error { + if e.UserID == "u_val" { + receivedFromPtrToValue.Store(true) + } + return nil + }) + + err = bus.Emit(context.Background(), "test:val", &UserRegisteredEvent{UserID: "u_val"}) + assert.NoError(t, err) + assert.True(t, receivedFromPtrToValue.Load()) + + // Handler expects value, payload is nil pointer + var nilEvent *UserRegisteredEvent + bus.On("test:nil_ptr", func(ctx context.Context, e UserRegisteredEvent) error { + assert.Equal(t, "", e.UserID) + receivedFromNilPtr.Store(true) + return nil + }) + err = bus.Emit(context.Background(), "test:nil_ptr", nilEvent) + assert.NoError(t, err) + assert.True(t, receivedFromNilPtr.Load()) + + // Convertible type test (int to int64) + var receivedConvert int64 + bus.On("test:conv", func(e int64) { + receivedConvert = e + }) + err = bus.Emit(context.Background(), "test:conv", int(42)) + assert.NoError(t, err) + assert.Equal(t, int64(42), receivedConvert) +} + +func TestEventBusErrorCollectionAndPanicRecovery(t *testing.T) { + bus := core.NewEventBus() + + errHandler1 := errors.New("handler 1 failed") + errHandler2 := errors.New("handler 2 failed") + + bus.On("test:err", func() error { + return errHandler1 + }) + + bus.On("test:err", func() { + panic("something went horribly wrong") + }) + + bus.On("test:err", func() error { + return errHandler2 + }) + + err := bus.Emit(context.Background(), "test:err", nil) + require.Error(t, err) + assert.True(t, errors.Is(err, errHandler1) || errors.Is(err, errHandler2)) + assert.Contains(t, err.Error(), "handler 1 failed") + assert.Contains(t, err.Error(), "handler 2 failed") + assert.Contains(t, err.Error(), "panic") +} + +func TestEventBusInvalidHandlerPanics(t *testing.T) { + bus := core.NewEventBus() + + assert.Panics(t, func() { + bus.On("test:invalid", nil) + }) + + assert.Panics(t, func() { + bus.On("test:invalid", "not-a-func") + }) + + assert.Panics(t, func() { + // More than 2 arguments + bus.On("test:invalid", func(a, b, c string) {}) + }) + + assert.Panics(t, func() { + // 2 args, but first is not context + bus.On("test:invalid", func(a string, b int) {}) + }) + + assert.Panics(t, func() { + // More than 2 return values + bus.On("test:invalid", func() (int, string, error) { return 0, "", nil }) + }) + + assert.Panics(t, func() { + // 2 return values, but second is not error + bus.On("test:invalid", func() (int, string) { return 0, "" }) + }) +} + +func TestEventBusListenersCountAndDisposerIdempotence(t *testing.T) { + bus := core.NewEventBus() + + assert.Equal(t, 0, bus.Listeners("topic1")) + + d1 := bus.On("topic1", func() {}) + d2 := bus.On("topic1", func() {}) + assert.Equal(t, 2, bus.Listeners("topic1")) + + _ = d1() + assert.Equal(t, 1, bus.Listeners("topic1")) + + // Calling disposer again should be no-op + _ = d1() + assert.Equal(t, 1, bus.Listeners("topic1")) + + _ = d2() + assert.Equal(t, 0, bus.Listeners("topic1")) +} + +func TestEventBusConcurrentAccess(t *testing.T) { + bus := core.NewEventBus() + var wg sync.WaitGroup + + var receivedCount atomic.Int64 + + // Concurrently subscribe and emit + for i := 0; i < 50; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + topic := fmt.Sprintf("topic:%d", idx%5) + disposer := bus.On(topic, func(ctx context.Context, e UserRegisteredEvent) error { + receivedCount.Add(1) + return nil + }) + + // Emit some events + _ = bus.Emit(context.Background(), topic, UserRegisteredEvent{UserID: fmt.Sprintf("u_%d", idx)}) + + // Randomly dispose + if idx%2 == 0 { + _ = disposer() + } + }(i) + } + + for i := 0; i < 50; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + topic := fmt.Sprintf("topic:%d", idx%5) + _ = bus.Emit(context.Background(), topic, UserRegisteredEvent{UserID: fmt.Sprintf("u_%d", idx)}) + }(i) + } + + wg.Wait() + assert.Greater(t, receivedCount.Load(), int64(0)) +} + +func TestEventBusWaterfall(t *testing.T) { + bus := core.NewEventBus() + + // Handler 1: appends "-first" + bus.On("pipeline:transform", func(ctx context.Context, s string) string { + return s + "-first" + }) + + // Handler 2: appends "-second" with error return + bus.On("pipeline:transform", func(s string) (string, error) { + return s + "-second", nil + }) + + res, err := bus.Waterfall(context.Background(), "pipeline:transform", "init") + assert.NoError(t, err) + assert.Equal(t, "init-first-second", res) + + // Test short-circuit on error + expectedErr := errors.New("waterfall step failed") + bus.On("pipeline:error", func(s string) (string, error) { + return s, expectedErr + }) + bus.On("pipeline:error", func(s string) string { + return s + "-should-not-run" + }) + + res, err = bus.Waterfall(context.Background(), "pipeline:error", "start") + assert.ErrorIs(t, err, expectedErr) + assert.Nil(t, res) +} + +func TestEventBusParallel(t *testing.T) { + bus := core.NewEventBus() + + var counter atomic.Int64 + err1 := errors.New("parallel err 1") + + bus.On("test:parallel", func(ctx context.Context, val int) error { + counter.Add(int64(val)) + return nil + }) + + bus.On("test:parallel", func(val int) error { + counter.Add(int64(val)) + return err1 + }) + + err := bus.Parallel(context.Background(), "test:parallel", 10) + assert.ErrorIs(t, err, err1) + assert.Equal(t, int64(20), counter.Load()) +} + +func TestEventBusSerial(t *testing.T) { + bus := core.NewEventBus() + + var executed []int + errStop := errors.New("serial stop") + + bus.On("test:serial", func() error { + executed = append(executed, 1) + return nil + }) + + bus.On("test:serial", func() error { + executed = append(executed, 2) + return errStop + }) + + bus.On("test:serial", func() error { + executed = append(executed, 3) + return nil + }) + + err := bus.Serial(context.Background(), "test:serial", nil) + assert.ErrorIs(t, err, errStop) + assert.Equal(t, []int{1, 2}, executed) +} diff --git a/backend/core/extpoints/config.go b/backend/core/extpoints/config.go new file mode 100644 index 00000000..b81e53f9 --- /dev/null +++ b/backend/core/extpoints/config.go @@ -0,0 +1,284 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package extpoints + +import ( + "errors" + "fmt" + "reflect" + "strings" + "sync" + "time" +) + +// Sentinel errors returned by the configuration extension point. +var ( + // ErrConfigConflict is returned when the same key is declared with disagreeing metadata. + ErrConfigConflict = errors.New("extpoints: conflicting configuration declarations") + + // ErrConfigType is returned when a value cannot be converted to the declared type. + ErrConfigType = errors.New("extpoints: configuration value type mismatch") + + // ErrConfigUnknownKey is returned when a configuration key was never declared. + ErrConfigUnknownKey = errors.New("extpoints: unknown configuration key") + + // ErrConfigNotResolved is returned when typed reads happen before resolution. + ErrConfigNotResolved = errors.New("extpoints: configuration not resolved; run App.Prepare first") + + // ErrConfigTarget is returned when a binding target is not an addressable struct pointer. + ErrConfigTarget = errors.New("extpoints: configuration binding target must be a non-nil struct pointer") + + // ErrConfigNoSource is returned when resolution is attempted without a registered source. + ErrConfigNoSource = errors.New("extpoints: no configuration source registered") +) + +// Configuration origin labels reported by ConfigView.Origin and ConfigEntry.Origin. +const ( + // OriginEnv marks a value that came from an environment variable. + OriginEnv = "env" + // OriginAutoEnable marks a boolean enabled by the presence of another environment variable. + OriginAutoEnable = "auto-enable" + // OriginFile marks a value that came from the configuration file. + OriginFile = "file" + // OriginDefault marks a value that came from a declaration default. + OriginDefault = "default" +) + +// RedactedValue replaces the printed value of keys declared with secret:"true". +const RedactedValue = "******" + +// durationType distinguishes time.Duration from plain int64 during tag walking and decoding. +var durationType = reflect.TypeFor[time.Duration]() + +// ConfigRegistry must satisfy the full extension contract, so a missing accessor is a +// compile error rather than a runtime surprise inside a plugin Apply. +var _ ConfigExtension = (*ConfigRegistry)(nil) + +// ConfigSource abstracts where raw configuration values come from, keeping the +// micro-kernel free of concrete loaders such as viper. +type ConfigSource interface { + // Lookup returns the raw value stored at a dotted path in the configuration file. + Lookup(path string) (any, bool) + // LookupEnv returns the raw value of an environment variable. + LookupEnv(name string) (string, bool) + // Describe returns a human readable identity for the source, used in diagnostics. + Describe() string +} + +// ConfigBinding declares that a plugin reads every `config` tagged field of Target +// under a dotted configuration prefix. +type ConfigBinding struct { + // Prefix is the dotted configuration path, e.g. "redis". An empty prefix means + // each field's `config` tag is already a full path. + Prefix string + // Target must be a non-nil pointer to a struct carrying `config` tags. + Target any +} + +// configField is a single leaf discovered while walking a binding struct's tags. +// key is the fully qualified dotted path used for resolution; path is the raw `config` +// tag value used to locate the Go field again during Bind. +type configField struct { + key string + path string + env string + autoEnable string + def string + secret bool + typ reflect.Type +} + +// configDecl is the registered form of a configField, attributed to its declaring plugin. +type configDecl struct { + key string + pluginID string + env string + autoEnable string + def string + secret bool + typ reflect.Type +} + +// ConfigEntry is a redacted, self-describing view of one effective configuration key. +type ConfigEntry struct { + Key string + PluginID string + Env string + Origin string + Value string +} + +// ConfigView is the read-only surface over effective configuration values. +// Keys are dotted paths such as "redis.enabled". +type ConfigView interface { + Value(key string) (any, bool) + String(key, fallback string) string + Bool(key string, fallback bool) bool + Int(key string, fallback int) int + Duration(key string, fallback time.Duration) time.Duration + Strings(key string) []string + WasSet(envName string) bool + Origin(key string) string +} + +// ConfigExtension is the plugin-facing configuration extension point mounted on the +// root Context and shared by every forked plugin scope. +type ConfigExtension interface { + ConfigView + + // SetSource installs the raw value source after construction, letting the composition + // root build the adapter once the kernel Context already exists. + SetSource(src ConfigSource) + // Declare registers plugin-owned configuration bindings before Apply runs. + Declare(pluginID string, bindings ...ConfigBinding) error + // Bind resolves and assigns the configuration values for a tagged struct. + Bind(prefix string, target any) error + // Resolve computes the effective value of every declared key once. + Resolve() error + // Resolved reports whether Resolve has already run. + Resolved() bool + // Entries returns the redacted effective configuration ordered by key. + Entries() []ConfigEntry +} + +// ConfigRegistry implements ConfigExtension. Declarations are additive; values are +// computed once by Resolve and reused by every later read. +type ConfigRegistry struct { + mu sync.RWMutex + src ConfigSource + decls map[string]*configDecl + order []string + values map[string]any + origins map[string]string + resolved bool +} + +// NewConfigRegistry creates an empty configuration registry. A nil src is allowed so +// that the kernel can construct the registry before the composition root injects one. +func NewConfigRegistry(src ConfigSource) *ConfigRegistry { + return &ConfigRegistry{ + src: src, + decls: make(map[string]*configDecl), + values: make(map[string]any), + origins: make(map[string]string), + } +} + +// SetSource installs the raw value source. It is intended for the composition root, +// which builds the adapter after the kernel Context already exists. +func (r *ConfigRegistry) SetSource(src ConfigSource) { + r.mu.Lock() + defer r.mu.Unlock() + r.src = src +} + +// Declare registers every `config` tagged leaf of each binding's target struct. +// Repeated declarations of the same key are accepted only when their env, default, +// auto-enable and secret metadata agree; disagreement is ErrConfigConflict. +func (r *ConfigRegistry) Declare(pluginID string, bindings ...ConfigBinding) error { + r.mu.Lock() + defer r.mu.Unlock() + + for _, b := range bindings { + if err := r.declareBinding(pluginID, b); err != nil { + return err + } + } + return nil +} + +func (r *ConfigRegistry) declareBinding(pluginID string, b ConfigBinding) error { + target, err := bindingStruct(b.Target, b.Prefix) + if err != nil { + return err + } + + fields, err := walkConfigFields(target.Type(), b.Prefix) + if err != nil { + return err + } + for _, f := range fields { + if err := r.addDecl(pluginID, f); err != nil { + return err + } + } + return nil +} + +// bindingStruct validates that a binding or bind target is a usable struct pointer. +func bindingStruct(target any, prefix string) (reflect.Value, error) { + rv := reflect.ValueOf(target) + if !rv.IsValid() || rv.Kind() != reflect.Pointer || rv.IsNil() || rv.Elem().Kind() != reflect.Struct { + return reflect.Value{}, fmt.Errorf("%w: prefix %q received %T", ErrConfigTarget, prefix, target) + } + return rv.Elem(), nil +} + +// walkConfigFields collects leaf configuration declarations from `config` tagged fields. +// A field without a `config` tag is skipped, except for embedded structs which are +// recursed into so their own tags resolve under the same prefix. +func walkConfigFields(t reflect.Type, prefix string) ([]configField, error) { + var out []configField + + for i := 0; i < t.NumField(); i++ { + sf := t.Field(i) + if sf.PkgPath != "" { + continue + } + + path := sf.Tag.Get("config") + if path == "-" { + continue + } + if path == "" { + if sf.Type.Kind() == reflect.Struct && sf.Type != durationType { + nested, err := walkConfigFields(sf.Type, prefix) + if err != nil { + return nil, err + } + out = append(out, nested...) + } + continue + } + + out = append(out, configField{ + key: joinKey(prefix, path), + path: path, + env: sf.Tag.Get("env"), + autoEnable: sf.Tag.Get("autoEnable"), + def: sf.Tag.Get("default"), + secret: strings.EqualFold(sf.Tag.Get("secret"), "true"), + typ: sf.Type, + }) + } + + return out, nil +} + +func joinKey(prefix, path string) string { + if prefix == "" { + return path + } + return prefix + "." + path +} + +// addDecl records one leaf, enforcing the shared-declaration consistency rule. +func (r *ConfigRegistry) addDecl(pluginID string, f configField) error { + if existing, ok := r.decls[f.key]; ok { + if existing.env != f.env || existing.def != f.def || + existing.autoEnable != f.autoEnable || existing.secret != f.secret { + return fmt.Errorf( + "%w: key %q declared by plugin %q and plugin %q with disagreeing env/default/autoEnable/secret metadata", + ErrConfigConflict, f.key, existing.pluginID, pluginID) + } + return nil + } + + r.decls[f.key] = &configDecl{ + key: f.key, pluginID: pluginID, env: f.env, + autoEnable: f.autoEnable, def: f.def, secret: f.secret, typ: f.typ, + } + r.order = append(r.order, f.key) + return nil +} diff --git a/backend/core/extpoints/config_resolve.go b/backend/core/extpoints/config_resolve.go new file mode 100644 index 00000000..09207c30 --- /dev/null +++ b/backend/core/extpoints/config_resolve.go @@ -0,0 +1,283 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package extpoints + +import ( + "errors" + "fmt" + "reflect" + "sort" + "time" +) + +// Resolve computes the effective value of every declared key. Priority is, in order: +// an explicit environment override, an auto-enable trigger, the configuration file, +// then the declared default. Resolution is idempotent; later declarations resolve lazily. +func (r *ConfigRegistry) Resolve() error { + r.mu.Lock() + defer r.mu.Unlock() + + if r.src == nil { + return ErrConfigNoSource + } + + var errs []error + for _, key := range r.order { + if _, done := r.values[key]; done { + continue + } + if err := r.resolveLocked(key); err != nil { + errs = append(errs, err) + } + } + r.resolved = true + + return errors.Join(errs...) +} + +// Resolved reports whether Resolve has already run. +func (r *ConfigRegistry) Resolved() bool { + r.mu.RLock() + defer r.mu.RUnlock() + return r.resolved +} + +// resolveLocked computes the effective value of one key. The caller must hold r.mu. +func (r *ConfigRegistry) resolveLocked(key string) error { + d, ok := r.decls[key] + if !ok { + return fmt.Errorf("%w: %s", ErrConfigUnknownKey, key) + } + + if d.env != "" { + if raw, found := r.src.LookupEnv(d.env); found { + value, err := convertValue(raw, d.typ) + if err != nil { + return fmt.Errorf("%w: key %q from environment %s: %w", ErrConfigType, key, d.env, err) + } + r.values[key], r.origins[key] = value, OriginEnv + return nil + } + } + + if d.autoEnable != "" && d.typ.Kind() == reflect.Bool { + if _, found := r.src.LookupEnv(d.autoEnable); found { + r.values[key], r.origins[key] = true, OriginAutoEnable + return nil + } + } + + if raw, found := r.src.Lookup(key); found { + value, err := convertValue(raw, d.typ) + if err != nil { + return fmt.Errorf("%w: key %q from %s: %w", ErrConfigType, key, r.src.Describe(), err) + } + r.values[key], r.origins[key] = value, OriginFile + return nil + } + + if d.def != "" { + value, err := convertValue(d.def, d.typ) + if err != nil { + return fmt.Errorf("%w: default %q for key %q: %w", ErrConfigType, d.def, key, err) + } + r.values[key], r.origins[key] = value, OriginDefault + return nil + } + + r.values[key] = reflect.New(d.typ).Elem().Interface() + r.origins[key] = "" + return nil +} + +// Bind resolves the tagged fields of target and assigns them in place. Prefixes that +// were never declared self-register, so only gates need DeclareConfig. +func (r *ConfigRegistry) Bind(prefix string, target any) error { + r.mu.Lock() + defer r.mu.Unlock() + + if r.src == nil { + return ErrConfigNoSource + } + if !r.resolved { + return fmt.Errorf("%w: Bind(%q, %T) ran before App.Prepare", ErrConfigNotResolved, prefix, target) + } + + elem, err := bindingStruct(target, prefix) + if err != nil { + return err + } + fields, err := walkConfigFields(elem.Type(), prefix) + if err != nil { + return err + } + + for _, f := range fields { + if _, declared := r.decls[f.key]; !declared { + if err := r.addDecl("bind:"+prefix, f); err != nil { + return err + } + } + if _, done := r.values[f.key]; !done { + if err := r.resolveLocked(f.key); err != nil { + return err + } + } + } + + return assignFields(elem, fields, r.values) +} + +// assignFields writes resolved values into a freshly walked target struct. +func assignFields(elem reflect.Value, fields []configField, values map[string]any) error { + for _, f := range fields { + field := elem.FieldByName(fieldNameForPath(elem.Type(), f.path)) + if !field.IsValid() || !field.CanSet() { + return fmt.Errorf("%w: field for key %q is not settable", ErrConfigTarget, f.key) + } + + value := reflect.ValueOf(values[f.key]) + if !value.Type().AssignableTo(field.Type()) { + return fmt.Errorf("%w: key %q resolves to %s, field expects %s", + ErrConfigType, f.key, value.Type(), field.Type()) + } + field.Set(value) + } + return nil +} + +// Entries returns the effective configuration as redacted, key-sorted entries. +func (r *ConfigRegistry) Entries() []ConfigEntry { + r.mu.Lock() + defer r.mu.Unlock() + + keys := append([]string(nil), r.order...) + sort.Strings(keys) + + out := make([]ConfigEntry, 0, len(keys)) + for _, key := range keys { + d := r.decls[key] + if _, done := r.values[key]; !done && r.src != nil { + _ = r.resolveLocked(key) + } + out = append(out, ConfigEntry{ + Key: d.key, + PluginID: d.pluginID, + Env: d.env, + Origin: r.origins[key], + Value: formatEntryValue(r.values[key], d.secret), + }) + } + return out +} + +// formatEntryValue renders one effective value for diagnostics, masking secrets. +func formatEntryValue(value any, secret bool) string { + if secret { + return RedactedValue + } + if value == nil { + return "" + } + return fmt.Sprint(value) +} + +// Value returns the resolved value for key, lazily resolving a declared key that has +// not been computed yet. Missing and unresolvable keys report false rather than an +// error so gates and diagnostics can keep using the fallback accessors. +func (r *ConfigRegistry) Value(key string) (any, bool) { + r.mu.Lock() + defer r.mu.Unlock() + + if _, done := r.values[key]; !done { + if r.src == nil { + return nil, false + } + if _, declared := r.decls[key]; !declared { + return nil, false + } + if err := r.resolveLocked(key); err != nil { + return nil, false + } + } + + value, ok := r.values[key] + return value, ok +} + +// String returns the string value of key or fallback when absent or mismatched. +func (r *ConfigRegistry) String(key, fallback string) string { + if value, ok := r.Value(key); ok { + if converted, err := convertString(value); err == nil { + return converted.(string) + } + } + return fallback +} + +// Bool returns the boolean value of key or fallback when absent or mismatched. +func (r *ConfigRegistry) Bool(key string, fallback bool) bool { + if value, ok := r.Value(key); ok { + if converted, err := convertBool(value); err == nil { + return converted.(bool) + } + } + return fallback +} + +// Int returns the int value of key or fallback when absent or mismatched. +func (r *ConfigRegistry) Int(key string, fallback int) int { + if value, ok := r.Value(key); ok { + converted, err := convertNumeric(value, reflect.TypeFor[int](), signedNumbers) + if err == nil { + return converted.(int) + } + } + return fallback +} + +// Duration returns the time.Duration value of key or fallback when absent or mismatched. +func (r *ConfigRegistry) Duration(key string, fallback time.Duration) time.Duration { + if value, ok := r.Value(key); ok { + if converted, err := convertDuration(value); err == nil { + return converted.(time.Duration) + } + } + return fallback +} + +// Strings returns the []string value of key, or nil when absent. +func (r *ConfigRegistry) Strings(key string) []string { + value, ok := r.Value(key) + if !ok { + return nil + } + + converted, err := convertSlice(value, reflect.TypeFor[[]string]()) + if err != nil { + return nil + } + + list, _ := converted.([]string) + return list +} + +// WasSet reports whether an environment variable is present, regardless of its value. +func (r *ConfigRegistry) WasSet(envName string) bool { + r.mu.RLock() + defer r.mu.RUnlock() + + if r.src == nil { + return false + } + _, found := r.src.LookupEnv(envName) + return found +} + +// Origin reports where a key's effective value came from; "" means the zero value. +func (r *ConfigRegistry) Origin(key string) string { + r.mu.RLock() + defer r.mu.RUnlock() + return r.origins[key] +} diff --git a/backend/core/extpoints/config_test.go b/backend/core/extpoints/config_test.go new file mode 100644 index 00000000..5315ec50 --- /dev/null +++ b/backend/core/extpoints/config_test.go @@ -0,0 +1,245 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package extpoints_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "Wavelet/core/extpoints" +) + +// fakeSource is an in-memory extpoints.ConfigSource used by configuration engine tests. +type fakeSource struct { + values map[string]any + env map[string]string +} + +func newFakeSource() *fakeSource { + return &fakeSource{values: map[string]any{}, env: map[string]string{}} +} + +func (f *fakeSource) Lookup(path string) (any, bool) { + v, ok := f.values[path] + return v, ok +} + +func (f *fakeSource) LookupEnv(name string) (string, bool) { + v, ok := f.env[name] + return v, ok +} + +func (f *fakeSource) Describe() string { return "fake" } + +// redisConfig mirrors how a plugin declares the configuration it reads. +type redisConfig struct { + Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"` + Addrs []string `config:"addrs" env:"REDIS_ADDR"` + DB int `config:"db" env:"REDIS_DB"` + KeyPrefix string `config:"key_prefix" env:"REDIS_KEY_PREFIX"` + Dial time.Duration `config:"dial_timeout" env:"REDIS_DIAL_TIMEOUT"` + Ignored string `config:"-"` + private string +} + +func TestDeclareRegistersTaggedLeafKeys(t *testing.T) { + r := extpoints.NewConfigRegistry(newFakeSource()) + + require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}})) + + keys := make([]string, 0) + for _, e := range r.Entries() { + keys = append(keys, e.Key) + } + assert.Equal(t, []string{ + "redis.addrs", "redis.db", "redis.dial_timeout", "redis.enabled", "redis.key_prefix", + }, keys) +} + +func TestDeclareRejectsNonStructPointerTarget(t *testing.T) { + r := extpoints.NewConfigRegistry(newFakeSource()) + + assert.ErrorIs(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: redisConfig{}}), + extpoints.ErrConfigTarget) + assert.ErrorIs(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: (*redisConfig)(nil)}), + extpoints.ErrConfigTarget) +} + +func TestDeclareAllowsIdenticalDuplicateAndRejectsConflictingMetadata(t *testing.T) { + r := extpoints.NewConfigRegistry(newFakeSource()) + binding := extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}} + require.NoError(t, r.Declare("cache", binding)) + require.NoError(t, r.Declare("cache_memory", binding), "identical shared declarations must be allowed") + + type conflictingConfig struct { + Enabled bool `config:"enabled" env:"REDIS_ON" default:"true"` + } + err := r.Declare("driver_http", extpoints.ConfigBinding{Prefix: "redis", Target: &conflictingConfig{}}) + require.ErrorIs(t, err, extpoints.ErrConfigConflict) + assert.Contains(t, err.Error(), "redis.enabled") + assert.Contains(t, err.Error(), "cache") + assert.Contains(t, err.Error(), "driver_http") +} + +// queueConfig is a composite element mirroring worker.queues in config.yaml. +type queueConfig struct { + Name string `config:"name"` + Priority int `config:"priority"` +} + +type workerConfig struct { + Concurrency int `config:"concurrency" env:"WORKER_CONCURRENCY"` + Queues []queueConfig `config:"queues"` +} + +type sessionConfig struct { + Secret string `config:"session_secret" env:"APP_SESSION_SECRET" secret:"true"` + Age int `config:"session_age" env:"APP_SESSION_AGE" default:"86400"` +} + +func TestResolveScalarDurationAndSlice(t *testing.T) { + src := newFakeSource() + src.values["redis.db"] = 1 + src.values["redis.dial_timeout"] = "5s" + src.values["redis.addrs"] = []any{"127.0.0.1:6379"} + src.env["REDIS_KEY_PREFIX"] = "refresh:" + + r := extpoints.NewConfigRegistry(src) + require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}})) + require.NoError(t, r.Resolve()) + + var got redisConfig + require.NoError(t, r.Bind("redis", &got)) + assert.Equal(t, redisConfig{ + Addrs: []string{"127.0.0.1:6379"}, DB: 1, KeyPrefix: "refresh:", Dial: 5 * time.Second, + }, got) +} + +func TestResolveFillsSliceFromScalarEnvironmentValue(t *testing.T) { + src := newFakeSource() + src.env["REDIS_ADDR"] = "redis:6379" + + r := extpoints.NewConfigRegistry(src) + require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}})) + require.NoError(t, r.Resolve()) + + var got redisConfig + require.NoError(t, r.Bind("redis", &got)) + assert.Equal(t, []string{"redis:6379"}, got.Addrs) +} + +func TestResolveCompositeSliceOfStructs(t *testing.T) { + src := newFakeSource() + src.values["worker.concurrency"] = 20 + src.values["worker.queues"] = []any{ + map[string]any{"name": "webhook", "priority": 10}, + map[string]any{"name": "default", "priority": 3}, + } + + r := extpoints.NewConfigRegistry(src) + require.NoError(t, r.Declare("asynq_worker", extpoints.ConfigBinding{Prefix: "worker", Target: &workerConfig{}})) + require.NoError(t, r.Resolve()) + + var got workerConfig + require.NoError(t, r.Bind("worker", &got)) + assert.Equal(t, workerConfig{ + Concurrency: 20, + Queues: []queueConfig{{Name: "webhook", Priority: 10}, {Name: "default", Priority: 3}}, + }, got) +} + +func TestResolveReportsTypeMismatchOnBadEnvironmentValue(t *testing.T) { + src := newFakeSource() + src.env["WORKER_CONCURRENCY"] = "many" + + r := extpoints.NewConfigRegistry(src) + require.NoError(t, r.Declare("asynq_worker", extpoints.ConfigBinding{Prefix: "worker", Target: &workerConfig{}})) + + err := r.Resolve() + require.ErrorIs(t, err, extpoints.ErrConfigType) + assert.Contains(t, err.Error(), "worker.concurrency") + assert.Contains(t, err.Error(), "WORKER_CONCURRENCY") +} + +func TestViewAccessorsAndOrigins(t *testing.T) { + src := newFakeSource() + src.values["redis.db"] = 1 + src.env["REDIS_ADDR"] = "redis:6379" + src.env["REDIS_ENABLED"] = "false" + + r := extpoints.NewConfigRegistry(src) + require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}})) + require.NoError(t, r.Declare("auth", extpoints.ConfigBinding{Prefix: "app", Target: &sessionConfig{}})) + require.NoError(t, r.Resolve()) + + assert.Equal(t, extpoints.OriginEnv, r.Origin("redis.addrs")) + assert.Equal(t, []string{"redis:6379"}, r.Strings("redis.addrs")) + assert.False(t, r.Bool("redis.enabled", true)) + assert.Equal(t, 1, r.Int("redis.db", 0)) + assert.Equal(t, "86400", r.String("app.session_age", "0")) + assert.Equal(t, "fallback", r.String("redis.missing", "fallback")) + assert.Zero(t, r.Duration("redis.dial_timeout", 0)) + assert.True(t, r.WasSet("REDIS_ADDR")) + assert.False(t, r.WasSet("REDIS_NOPE")) +} + +func TestAutoEnableBeatsFileValueButLosesToExplicitEnv(t *testing.T) { + src := newFakeSource() + src.env["REDIS_ADDR"] = "redis:6379" + src.values["redis.enabled"] = false + + r := extpoints.NewConfigRegistry(src) + require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}})) + require.NoError(t, r.Resolve()) + assert.True(t, r.Bool("redis.enabled", false), "REDIS_ADDR presence implies enabled") + assert.Equal(t, extpoints.OriginAutoEnable, r.Origin("redis.enabled")) + + explicit := newFakeSource() + explicit.env["REDIS_ADDR"] = "redis:6379" + explicit.env["REDIS_ENABLED"] = "false" + + r2 := extpoints.NewConfigRegistry(explicit) + require.NoError(t, r2.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}})) + require.NoError(t, r2.Resolve()) + assert.False(t, r2.Bool("redis.enabled", true), "explicit REDIS_ENABLED must win over auto-enable") + assert.Equal(t, extpoints.OriginEnv, r2.Origin("redis.enabled")) +} + +func TestEntriesRedactSecretsAndReportDefaults(t *testing.T) { + r := extpoints.NewConfigRegistry(newFakeSource()) + require.NoError(t, r.Declare("auth", extpoints.ConfigBinding{Prefix: "app", Target: &sessionConfig{}})) + require.NoError(t, r.Resolve()) + + entries := map[string]extpoints.ConfigEntry{} + for _, e := range r.Entries() { + entries[e.Key] = e + } + + assert.Equal(t, extpoints.RedactedValue, entries["app.session_secret"].Value) + assert.Equal(t, extpoints.OriginDefault, entries["app.session_age"].Origin) + assert.Equal(t, "86400", entries["app.session_age"].Value) +} + +func TestBindRejectsReadsBeforeSourceIsRegistered(t *testing.T) { + r := extpoints.NewConfigRegistry(nil) + require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}})) + + assert.ErrorIs(t, r.Resolve(), extpoints.ErrConfigNoSource) + + var cfg redisConfig + assert.ErrorIs(t, r.Bind("redis", &cfg), extpoints.ErrConfigNoSource) +} + +func TestBindRejectsReadsBeforeResolution(t *testing.T) { + r := extpoints.NewConfigRegistry(newFakeSource()) + require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}})) + + var cfg redisConfig + err := r.Bind("redis", &cfg) + require.ErrorIs(t, err, extpoints.ErrConfigNotResolved) + assert.Contains(t, err.Error(), "App.Prepare") +} diff --git a/backend/core/extpoints/config_value.go b/backend/core/extpoints/config_value.go new file mode 100644 index 00000000..1660f94b --- /dev/null +++ b/backend/core/extpoints/config_value.go @@ -0,0 +1,243 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package extpoints + +import ( + "fmt" + "reflect" + "strconv" + "strings" + "time" +) + +// convertValue coerces a raw value coming from the configuration file or an +// environment variable into the declared Go type. +func convertValue(raw any, typ reflect.Type) (any, error) { + if typ == durationType { + return convertDuration(raw) + } + + switch typ.Kind() { + case reflect.Bool: + return convertBool(raw) + case reflect.String: + return convertString(raw) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return convertNumeric(raw, typ, signedNumbers) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return convertNumeric(raw, typ, unsignedNumbers) + case reflect.Float32, reflect.Float64: + return convertNumeric(raw, typ, floatingNumbers) + case reflect.Slice: + return convertSlice(raw, typ) + case reflect.Struct: + return convertStruct(raw, typ) + default: + return nil, fmt.Errorf("%w: %s is not a supported configuration type", ErrConfigType, typ) + } +} + +func convertBool(raw any) (any, error) { + switch v := raw.(type) { + case bool: + return v, nil + case string: + parsed, err := strconv.ParseBool(strings.TrimSpace(v)) + if err != nil { + return nil, fmt.Errorf("%w: %q is not a boolean", ErrConfigType, v) + } + return parsed, nil + default: + return nil, fmt.Errorf("%w: %v is not a boolean", ErrConfigType, raw) + } +} + +func convertString(raw any) (any, error) { + switch v := raw.(type) { + case string: + return v, nil + case bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: + return fmt.Sprint(v), nil + default: + return nil, fmt.Errorf("%w: %v is not a string", ErrConfigType, raw) + } +} + +// numericString extracts the textual form of a value so environment overrides, +// which always arrive as strings, share one parsing path with file values. +func numericString(raw any) (string, bool) { + switch v := raw.(type) { + case string: + return strings.TrimSpace(v), true + case bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: + return fmt.Sprint(v), true + default: + return "", false + } +} + +// numericKind selects which strconv family converts a raw value. +type numericKind int + +const ( + signedNumbers numericKind = iota + unsignedNumbers + floatingNumbers +) + +// convertNumeric parses a raw value into the numeric type declared by typ. The three +// numeric families share one implementation because they differ only in the strconv +// call and the reflect setter. +func convertNumeric(raw any, typ reflect.Type, family numericKind) (any, error) { + text, ok := numericString(raw) + if !ok { + return nil, fmt.Errorf("%w: %v is not a %s", ErrConfigType, raw, typ) + } + + out := reflect.New(typ).Elem() + var err error + + switch family { + case signedNumbers: + parsed, parseErr := strconv.ParseInt(text, 10, typ.Bits()) + out.SetInt(parsed) + err = parseErr + case unsignedNumbers: + parsed, parseErr := strconv.ParseUint(text, 10, typ.Bits()) + out.SetUint(parsed) + err = parseErr + default: + parsed, parseErr := strconv.ParseFloat(text, typ.Bits()) + out.SetFloat(parsed) + err = parseErr + } + + if err != nil { + return nil, fmt.Errorf("%w: %q is not a valid %s", ErrConfigType, text, typ) + } + return out.Interface(), nil +} + +// convertDuration accepts both Go duration strings such as "200ms" and integer +// nanoseconds, mirroring what the previous viper based decoding supported. +func convertDuration(raw any) (any, error) { + if v, ok := raw.(time.Duration); ok { + return v, nil + } + + text, ok := numericString(raw) + if !ok { + return nil, fmt.Errorf("%w: %v is not a duration", ErrConfigType, raw) + } + + if parsed, err := time.ParseDuration(text); err == nil { + return parsed, nil + } + + nanos, err := strconv.ParseInt(text, 10, 64) + if err != nil { + return nil, fmt.Errorf("%w: %q is not a valid duration", ErrConfigType, text) + } + return time.Duration(nanos), nil +} + +// convertSlice promotes a scalar into a single-element slice so that a value such as +// REDIS_ADDR=redis:6379 can populate the redis.addrs list. +func convertSlice(raw any, typ reflect.Type) (any, error) { + items, ok := sliceItems(raw) + if !ok { + items = []any{raw} + } + + out := reflect.MakeSlice(typ, 0, len(items)) + for _, item := range items { + converted, err := convertValue(item, typ.Elem()) + if err != nil { + return nil, err + } + out = reflect.Append(out, reflect.ValueOf(converted)) + } + return out.Interface(), nil +} + +// sliceItems normalises the several slice shapes a loader may produce. +func sliceItems(raw any) ([]any, bool) { + switch v := raw.(type) { + case []any: + return v, true + case []string: + items := make([]any, len(v)) + for i, s := range v { + items[i] = s + } + return items, true + } + + rv := reflect.ValueOf(raw) + if rv.IsValid() && rv.Kind() == reflect.Slice { + items := make([]any, rv.Len()) + for i := range items { + items[i] = rv.Index(i).Interface() + } + return items, true + } + return nil, false +} + +func convertStruct(raw any, typ reflect.Type) (any, error) { + table, ok := asStringMap(raw) + if !ok { + return nil, fmt.Errorf("%w: %v is not a mapping, cannot decode into %s", ErrConfigType, raw, typ) + } + + fields, err := walkConfigFields(typ, "") + if err != nil { + return nil, err + } + + out := reflect.New(typ).Elem() + for _, f := range fields { + item, present := table[f.path] + if !present || item == nil { + continue + } + + converted, err := convertValue(item, f.typ) + if err != nil { + return nil, fmt.Errorf("%w: %s.%s: %w", ErrConfigType, typ.Name(), f.key, err) + } + out.FieldByName(fieldNameForPath(typ, f.path)).Set(reflect.ValueOf(converted)) + } + return out.Interface(), nil +} + +// asStringMap normalises the two map shapes produced by YAML decoders. +func asStringMap(raw any) (map[string]any, bool) { + switch v := raw.(type) { + case map[string]any: + return v, true + case map[any]any: + out := make(map[string]any, len(v)) + for key, val := range v { + name, ok := key.(string) + if !ok { + return nil, false + } + out[name] = val + } + return out, true + default: + return nil, false + } +} + +// fieldNameForPath maps a declared config path back to the Go struct field carrying it. +func fieldNameForPath(t reflect.Type, path string) string { + for i := 0; i < t.NumField(); i++ { + if t.Field(i).Tag.Get("config") == path { + return t.Field(i).Name + } + } + return "" +} diff --git a/backend/core/extpoints/extpoints_test.go b/backend/core/extpoints/extpoints_test.go new file mode 100644 index 00000000..6d51b999 --- /dev/null +++ b/backend/core/extpoints/extpoints_test.go @@ -0,0 +1,383 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package extpoints_test + +import ( + "Wavelet/core" + "Wavelet/core/extpoints" + "context" + "testing" + "testing/fstest" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRouterExtension(t *testing.T) { + r := extpoints.NewRouterRegistry() + require.NotNil(t, r) + + mGlobal := "global_middleware" + r.Use(mGlobal) + assert.Equal(t, []any{mGlobal}, r.Middlewares()) + + // Test root methods + hRoot := "root_handler" + r.GET("/", hRoot) + r.POST("/root_post", hRoot) + r.PUT("/root_put", hRoot) + r.DELETE("/root_del", hRoot) + r.PATCH("/root_patch", hRoot) + r.HEAD("/root_head", hRoot) + r.OPTIONS("/root_opt", hRoot) + anyRootDefs := r.Any("/root_any", hRoot) + assert.Len(t, anyRootDefs, 7) + + // Group and Group.Use + mAPI := "api_middleware" + api := r.Group("/api/v1", mAPI) + api.Use("api_extra_middleware") + assert.Len(t, api.Middlewares(), 2) + + hList := "list_orders_handler" + hCreate := "create_order_handler" + api.GET("/orders", hList) + api.POST("/orders", hCreate) + + mAdmin := "admin_middleware" + admin := api.Group("admin", mAdmin) + + hUserGet := "get_user_handler" + hUserPut := "put_user_handler" + hUserDel := "del_user_handler" + hUserPatch := "patch_user_handler" + hUserHead := "head_user_handler" + hUserOptions := "options_user_handler" + admin.GET("/users/:id", hUserGet) + admin.PUT("/users/:id", hUserPut) + admin.DELETE("/users/:id", hUserDel) + admin.PATCH("/users/:id", hUserPatch) + admin.HEAD("/users/:id", hUserHead) + admin.OPTIONS("/users/:id", hUserOptions) + + hCustom := "custom_handler" + admin.Handle("CUSTOM", "/custom", hCustom) + + hAny := "any_handler" + anyRoutes := admin.Any("/all", hAny) + assert.NotEmpty(t, anyRoutes) + + // Group.Routes() returns root routes + assert.Equal(t, r.Routes(), admin.Routes()) + + routes := r.Routes() + + // Verify route paths and middlewares + var foundOrderGet bool + var foundUserPut bool + for _, route := range routes { + if route.Method == "GET" && route.Path == "/api/v1/orders" { + foundOrderGet = true + assert.Equal(t, []any{mGlobal, mAPI, "api_extra_middleware"}, route.Middlewares) + assert.Equal(t, []any{hList}, route.Handlers) + } + if route.Method == "PUT" && route.Path == "/api/v1/admin/users/:id" { + foundUserPut = true + assert.Equal(t, []any{mGlobal, mAPI, "api_extra_middleware", mAdmin}, route.Middlewares) + assert.Equal(t, []any{hUserPut}, route.Handlers) + } + } + assert.True(t, foundOrderGet) + assert.True(t, foundUserPut) +} + +func TestRouterWhitelist(t *testing.T) { + r := extpoints.NewRouterRegistry() + require.NotNil(t, r) + + r.RegisterWhitelist( + "/healthz", + "/api/v1/user/login", + "/api/v1/oauth/*", + ) + + api := r.Group("/api/v1") + api.RegisterWhitelist("/cap/challenge", "/cap/redeem") + + whitelist := r.Whitelist() + assert.Contains(t, whitelist, "/healthz") + assert.Contains(t, whitelist, "/api/v1/user/login") + assert.Contains(t, whitelist, "/api/v1/oauth/*") + assert.Contains(t, whitelist, "/api/v1/cap/challenge") + assert.Contains(t, whitelist, "/api/v1/cap/redeem") + + // Exact match + assert.True(t, r.IsWhitelisted("/healthz")) + assert.True(t, r.IsWhitelisted("/api/v1/user/login")) + assert.True(t, api.IsWhitelisted("/api/v1/cap/challenge")) + + // Wildcard match + assert.True(t, r.IsWhitelisted("/api/v1/oauth/sources")) + assert.True(t, r.IsWhitelisted("/api/v1/oauth/github/authorize")) + + // Non-whitelisted + assert.False(t, r.IsWhitelisted("/api/v1/orders")) + assert.False(t, r.IsWhitelisted("/api/v1/user/profile")) +} + +func TestMigrationExtension(t *testing.T) { + m := extpoints.NewMigrationRegistry() + require.NotNil(t, m) + + fs1 := fstest.MapFS{ + "migrations/001_init.sql": &fstest.MapFile{Data: []byte("CREATE TABLE t1(id int);")}, + } + fs2 := fstest.MapFS{ + "custom/001_order.sql": &fstest.MapFile{Data: []byte("CREATE TABLE t2(id int);")}, + } + + m.Register("auth", fs1) + m.Register("order", fs2, "custom") + + // Update existing entry + fs1Updated := fstest.MapFS{ + "migrations/002_update.sql": &fstest.MapFile{Data: []byte("ALTER TABLE t1 ADD col int;")}, + } + m.Register("auth", fs1Updated, "") + + entries := m.Entries() + require.Len(t, entries, 2) + assert.Equal(t, "auth", entries[0].PluginID) + assert.Equal(t, "migrations", entries[0].Dir) + assert.Equal(t, "order", entries[1].PluginID) + assert.Equal(t, "custom", entries[1].Dir) + + authEntry, ok := m.Get("auth") + assert.True(t, ok) + assert.Equal(t, "auth", authEntry.PluginID) + + _, ok = m.Get("non_existent") + assert.False(t, ok) +} + +func TestTaskExtension(t *testing.T) { + tr := extpoints.NewTaskRegistry() + require.NotNil(t, tr) + + handler := func(ctx context.Context, payload []byte) error { return nil } + + tr.Register("order:cancel_timeout", handler, + extpoints.WithTaskConcurrency(5), + extpoints.WithTaskRetry(3), + extpoints.WithTaskTimeout(10*time.Second), + extpoints.WithTaskMetadata("queue", "critical"), + extpoints.WithTaskType("cancel_timeout"), + extpoints.WithTaskName("取消超时订单"), + extpoints.WithTaskDescription("自动关单"), + extpoints.WithTaskCategory("order"), + extpoints.WithTaskSupportsTime(true), + extpoints.WithTaskQueue("orders"), + extpoints.WithTaskRetryable(true), + nil, // test nil option + ) + + // Re-register to test update + tr.Register("order:cancel_timeout", handler, + extpoints.WithTaskConcurrency(10), + extpoints.WithTaskRetry(3), + extpoints.WithTaskTimeout(10*time.Second), + extpoints.WithTaskMetadata("queue", "high"), + extpoints.WithTaskType("cancel_timeout"), + extpoints.WithTaskName("取消超时订单"), + extpoints.WithTaskDescription("自动关单"), + extpoints.WithTaskCategory("order"), + extpoints.WithTaskSupportsTime(true), + extpoints.WithTaskQueue("orders"), + extpoints.WithTaskRetryable(true), + ) + + tasks := tr.Tasks() + require.Len(t, tasks, 1) + assert.Equal(t, "order:cancel_timeout", tasks[0].Pattern) + assert.Equal(t, 10, tasks[0].Concurrency) + assert.Equal(t, "high", tasks[0].Metadata["queue"]) + assert.Equal(t, "cancel_timeout", tasks[0].Type) + assert.Equal(t, "取消超时订单", tasks[0].Name) + + dto := tasks[0].ToDTO() + assert.Equal(t, "cancel_timeout", dto.Type) + assert.Equal(t, "order:cancel_timeout", dto.AsynqTask) + assert.Equal(t, "取消超时订单", dto.Name) + assert.Equal(t, "取消超时订单", dto.DisplayName) + assert.Equal(t, "自动关单", dto.Description) + assert.Equal(t, "order", dto.Category) + assert.True(t, dto.SupportsTime) + assert.Equal(t, "orders", dto.Queue) + assert.True(t, dto.Retryable) + + task, ok := tr.Get("order:cancel_timeout") + assert.True(t, ok) + assert.Equal(t, "order:cancel_timeout", task.Pattern) + + _, ok = tr.Get("unknown") + assert.False(t, ok) +} + +func TestScheduleExtension(t *testing.T) { + sr := extpoints.NewScheduleRegistry() + require.NotNil(t, sr) + + type ReportPayload struct { + Type string `json:"type"` + } + + sr.RegisterCron("0 2 * * *", "report:daily_summary", ReportPayload{Type: "daily"}) + sr.Register("@every 1h", "cleanup:expired_sessions", nil, + extpoints.WithScheduleOption("retry", 2), + nil, // test nil option + ) + + // Re-register to test update + sr.RegisterCron("0 3 * * *", "report:daily_summary", ReportPayload{Type: "all"}) + + schedules := sr.Schedules() + require.Len(t, schedules, 2) + + assert.Equal(t, "0 3 * * *", schedules[0].Spec) + assert.Equal(t, "report:daily_summary", schedules[0].TaskType) + assert.Equal(t, ReportPayload{Type: "all"}, schedules[0].Payload) + + assert.Equal(t, "@every 1h", schedules[1].Spec) + assert.Equal(t, "cleanup:expired_sessions", schedules[1].TaskType) + assert.Equal(t, 2, schedules[1].Options["retry"]) + + sched, ok := sr.Get("report:daily_summary") + assert.True(t, ok) + assert.Equal(t, "0 3 * * *", sched.Spec) + + _, ok = sr.Get("unknown") + assert.False(t, ok) +} + +func TestSettingExtension(t *testing.T) { + sr := extpoints.NewSettingRegistry() + require.NotNil(t, sr) + + assert.Panics(t, func() { + sr.Register(extpoints.SettingSchema{}) // empty key panics + }) + + sr.Register(extpoints.SettingSchema{ + Key: "order.auto_cancel_mins", + Default: 15, + Description: "Order auto cancellation timeout in minutes", + Category: "order", + Public: true, + }) + + // Re-register to test update + sr.Register(extpoints.SettingSchema{ + Key: "order.auto_cancel_mins", + Default: 30, + Description: "Updated timeout", + }) + + sr.Register(extpoints.SettingSchema{ + Key: "auth.jwt_secret", + Default: "default-secret", + Description: "JWT secret key", + Category: "auth", + ReadOnly: true, + }) + + schemas := sr.Schemas() + require.Len(t, schemas, 2) + + schema, ok := sr.Get("order.auto_cancel_mins") + assert.True(t, ok) + assert.Equal(t, 30, schema.Default) + + _, ok = sr.Get("unknown") + assert.False(t, ok) +} + +func TestContextExtensionPointsIntegration(t *testing.T) { + ctx := core.NewContext(context.Background()) + + require.NotNil(t, ctx.Events()) + require.NotNil(t, ctx.Router()) + require.NotNil(t, ctx.Migrations()) + require.NotNil(t, ctx.Tasks()) + require.NotNil(t, ctx.Task()) + require.NotNil(t, ctx.Schedules()) + require.NotNil(t, ctx.Schedule()) + require.NotNil(t, ctx.Settings()) + require.NotNil(t, ctx.Setting()) + + // Register from child context and verify shared application registry + child := ctx.Fork() + child.Router().GET("/ping", "pong_handler") + child.Task().Register("sample:task", "handler") + child.Schedule().RegisterCron("@hourly", "sample:cron", nil) + child.Settings().Register(extpoints.SettingSchema{ + Key: "app.name", + Default: "Wavelet", + }) + + assert.Len(t, ctx.Router().Routes(), 1) + assert.Len(t, ctx.Tasks().Tasks(), 1) + assert.Len(t, ctx.Schedules().Schedules(), 1) + assert.Len(t, ctx.Settings().Schemas(), 1) + + // Child and root events + var eventReceived bool + child.Events().On("app:ready", func() { + eventReceived = true + }) + err := ctx.Events().Emit(context.Background(), "app:ready", nil) + assert.NoError(t, err) + assert.True(t, eventReceived) +} + +func TestExtensionPointsUnregister(t *testing.T) { + ctx := core.NewContext(context.Background()) + + // 1. Router unregister + rd := ctx.Router().GET("/temp", "temp_handler") + assert.Greater(t, rd.ID, uint64(0)) + assert.Len(t, ctx.Router().Routes(), 1) + assert.True(t, ctx.Router().Unregister("GET", "/temp")) + assert.Len(t, ctx.Router().Routes(), 0) + + rd2 := ctx.Router().POST("/temp2", "temp2_handler") + assert.Len(t, ctx.Router().Routes(), 1) + assert.True(t, ctx.Router().UnregisterByID(rd2.ID)) + assert.Len(t, ctx.Router().Routes(), 0) + + // 2. Task unregister + ctx.Task().Register("temp:task", "handler") + assert.Len(t, ctx.Task().Tasks(), 1) + assert.True(t, ctx.Task().Unregister("temp:task")) + assert.Len(t, ctx.Task().Tasks(), 0) + + // 3. Schedule unregister + ctx.Schedule().RegisterCron("@hourly", "temp:cron", nil) + assert.Len(t, ctx.Schedule().Schedules(), 1) + assert.True(t, ctx.Schedule().Unregister("temp:cron")) + assert.Len(t, ctx.Schedule().Schedules(), 0) + + // 4. Setting unregister + ctx.Settings().Register(extpoints.SettingSchema{Key: "temp.key", Default: 1}) + assert.Len(t, ctx.Settings().Schemas(), 1) + assert.True(t, ctx.Settings().Unregister("temp.key")) + assert.Len(t, ctx.Settings().Schemas(), 0) + + // 5. Migration unregister + fsys := fstest.MapFS{"001.sql": &fstest.MapFile{Data: []byte("-- migration")}} + ctx.Migrations().Register("temp_plugin", fsys) + assert.Len(t, ctx.Migrations().Entries(), 1) + assert.True(t, ctx.Migrations().Unregister("temp_plugin")) + assert.Len(t, ctx.Migrations().Entries(), 0) +} diff --git a/backend/core/extpoints/migration.go b/backend/core/extpoints/migration.go new file mode 100644 index 00000000..36d9de80 --- /dev/null +++ b/backend/core/extpoints/migration.go @@ -0,0 +1,94 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package extpoints defines extension points for router, migrations, tasks, schedules, and settings. +package extpoints + +import ( + "io/fs" + "sync" +) + +// MigrationEntry contains the migration filesystem and configuration for a plugin. +type MigrationEntry struct { + PluginID string + FS fs.FS + Dir string +} + +// MigrationExtension defines the interface for registering and querying plugin migrations. +type MigrationExtension interface { + Register(pluginID string, fsys fs.FS, dir ...string) + Unregister(pluginID string) bool + Entries() []MigrationEntry + Get(pluginID string) (MigrationEntry, bool) +} + +// MigrationRegistry implements MigrationExtension. +type MigrationRegistry struct { + mu sync.RWMutex + entries []MigrationEntry + lookup map[string]MigrationEntry +} + +// NewMigrationRegistry creates a new MigrationRegistry. +func NewMigrationRegistry() *MigrationRegistry { + return &MigrationRegistry{ + lookup: make(map[string]MigrationEntry), + } +} + +// Register registers an embedded migration filesystem for a plugin. +func (m *MigrationRegistry) Register(pluginID string, fsys fs.FS, dir ...string) { + m.mu.Lock() + defer m.mu.Unlock() + + migrationDir := "migrations" + if len(dir) > 0 && dir[0] != "" { + migrationDir = dir[0] + } + + entry := MigrationEntry{ + PluginID: pluginID, + FS: fsys, + Dir: migrationDir, + } + + // If entry already exists, update in-place; otherwise append + if _, exists := m.lookup[pluginID]; exists { + for i, e := range m.entries { + if e.PluginID == pluginID { + m.entries[i] = entry + break + } + } + } else { + m.entries = append(m.entries, entry) + } + + m.lookup[pluginID] = entry +} + +// Unregister removes a registered migration entry by plugin ID. +func (m *MigrationRegistry) Unregister(pluginID string) bool { + return unregisterEntry(&m.mu, m.lookup, &m.entries, pluginID, func(e MigrationEntry) bool { + return e.PluginID == pluginID + }) +} + +// Entries returns a copy of all registered migration entries in registration order. +func (m *MigrationRegistry) Entries() []MigrationEntry { + m.mu.RLock() + defer m.mu.RUnlock() + res := make([]MigrationEntry, len(m.entries)) + copy(res, m.entries) + return res +} + +// Get retrieves the migration entry for a specific plugin ID. +func (m *MigrationRegistry) Get(pluginID string) (MigrationEntry, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + e, ok := m.lookup[pluginID] + return e, ok +} diff --git a/backend/core/extpoints/registry.go b/backend/core/extpoints/registry.go new file mode 100644 index 00000000..9ecf03b9 --- /dev/null +++ b/backend/core/extpoints/registry.go @@ -0,0 +1,22 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package extpoints + +import ( + "slices" + "sync" +) + +func unregisterEntry[T any](mu *sync.RWMutex, lookup map[string]T, list *[]T, key string, matches func(T) bool) bool { + mu.Lock() + defer mu.Unlock() + + if _, exists := lookup[key]; !exists { + return false + } + + delete(lookup, key) + *list = slices.DeleteFunc(*list, matches) + return true +} diff --git a/backend/core/extpoints/router.go b/backend/core/extpoints/router.go new file mode 100644 index 00000000..44258028 --- /dev/null +++ b/backend/core/extpoints/router.go @@ -0,0 +1,558 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package extpoints + +import ( + "strings" + "sync" +) + +// RouteDefinition holds the metadata and handler list for a single HTTP route. +type RouteDefinition struct { + ID uint64 + Method string + Path string + Handlers []any + Middlewares []any +} + +// RouterExtension defines the interface for registering routes and middlewares. +type RouterExtension interface { + Use(middlewares ...any) + Group(prefix string, middlewares ...any) RouterExtension + Handle(method, path string, handlers ...any) RouteDefinition + // HandleRaw joins path with the group prefix but preserves a trailing slash, + // so `/resource` and `/resource/` can coexist as distinct routes. + HandleRaw(method, path string, handlers ...any) RouteDefinition + // BasePath reports this group's absolute prefix ("" for the root registry). + BasePath() string + GET(path string, handlers ...any) RouteDefinition + POST(path string, handlers ...any) RouteDefinition + PUT(path string, handlers ...any) RouteDefinition + DELETE(path string, handlers ...any) RouteDefinition + PATCH(path string, handlers ...any) RouteDefinition + HEAD(path string, handlers ...any) RouteDefinition + OPTIONS(path string, handlers ...any) RouteDefinition + Any(path string, handlers ...any) []RouteDefinition + Routes() []RouteDefinition + Middlewares() []any + Unregister(method, path string) bool + UnregisterByID(id uint64) bool + RegisterWhitelist(patterns ...string) + Whitelist() []string + IsWhitelisted(path string) bool +} + +// RouterRegistry implements RouterExtension as the root route and middleware collector. +type RouterRegistry struct { + mu sync.RWMutex + nextID uint64 + routes []RouteDefinition + middlewares []any + whitelist PathWhitelist +} + +// NewRouterRegistry creates a new root router collector. +func NewRouterRegistry() *RouterRegistry { + return &RouterRegistry{} +} + +// Use registers global middlewares to the router. +func (r *RouterRegistry) Use(middlewares ...any) { + r.mu.Lock() + defer r.mu.Unlock() + r.middlewares = append(r.middlewares, middlewares...) +} + +// Middlewares returns a copy of registered root middlewares. +func (r *RouterRegistry) Middlewares() []any { + r.mu.RLock() + defer r.mu.RUnlock() + res := make([]any, len(r.middlewares)) + copy(res, r.middlewares) + return res +} + +// Group creates a new RouteGroup under the router. +func (r *RouterRegistry) Group(prefix string, middlewares ...any) RouterExtension { + return &RouterGroup{ + registry: r, + prefix: cleanPath(prefix), + middlewares: middlewares, + } +} + +// Handle registers a route with a custom HTTP method and handlers. +func (r *RouterRegistry) Handle(method, path string, handlers ...any) RouteDefinition { + return r.addRoute(method, cleanPath(path), handlers...) +} + +// addRoute appends a route whose path is already normalised. +func (r *RouterRegistry) addRoute(method, fullPath string, handlers ...any) RouteDefinition { + r.mu.Lock() + defer r.mu.Unlock() + + r.nextID++ + rd := RouteDefinition{ + ID: r.nextID, + Method: strings.ToUpper(method), + Path: fullPath, + Handlers: handlers, + Middlewares: append([]any(nil), r.middlewares...), + } + r.routes = append(r.routes, rd) + return rd +} + +// Unregister removes a route matching method and path from the registry. +func (r *RouterRegistry) Unregister(method, path string) bool { + r.mu.Lock() + defer r.mu.Unlock() + + targetMethod := strings.ToUpper(method) + targetPath := cleanPath(path) + + for i, rd := range r.routes { + if rd.Method == targetMethod && rd.Path == targetPath { + r.routes = append(r.routes[:i], r.routes[i+1:]...) + return true + } + } + return false +} + +// UnregisterByID removes a route by its unique ID. +func (r *RouterRegistry) UnregisterByID(id uint64) bool { + r.mu.Lock() + defer r.mu.Unlock() + + for i, rd := range r.routes { + if rd.ID == id { + r.routes = append(r.routes[:i], r.routes[i+1:]...) + return true + } + } + return false +} + +// GET registers a GET route. +func (r *RouterRegistry) GET(path string, handlers ...any) RouteDefinition { + return r.Handle("GET", path, handlers...) +} + +// POST registers a POST route. +func (r *RouterRegistry) POST(path string, handlers ...any) RouteDefinition { + return r.Handle("POST", path, handlers...) +} + +// PUT registers a PUT route. +func (r *RouterRegistry) PUT(path string, handlers ...any) RouteDefinition { + return r.Handle("PUT", path, handlers...) +} + +// DELETE registers a DELETE route. +func (r *RouterRegistry) DELETE(path string, handlers ...any) RouteDefinition { + return r.Handle("DELETE", path, handlers...) +} + +// PATCH registers a PATCH route. +func (r *RouterRegistry) PATCH(path string, handlers ...any) RouteDefinition { + return r.Handle("PATCH", path, handlers...) +} + +// HEAD registers a HEAD route. +func (r *RouterRegistry) HEAD(path string, handlers ...any) RouteDefinition { + return r.Handle("HEAD", path, handlers...) +} + +// OPTIONS registers an OPTIONS route. +func (r *RouterRegistry) OPTIONS(path string, handlers ...any) RouteDefinition { + return r.Handle("OPTIONS", path, handlers...) +} + +// Any registers a route for standard HTTP methods. +func (r *RouterRegistry) Any(path string, handlers ...any) []RouteDefinition { + methods := []string{"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"} + defs := make([]RouteDefinition, 0, len(methods)) + for _, m := range methods { + defs = append(defs, r.Handle(m, path, handlers...)) + } + return defs +} + +// Routes returns a copy of all collected RouteDefinitions. +func (r *RouterRegistry) Routes() []RouteDefinition { + r.mu.RLock() + defer r.mu.RUnlock() + res := make([]RouteDefinition, len(r.routes)) + copy(res, r.routes) + return res +} + +// RegisterWhitelist adds path patterns to the whitelist. +func (r *RouterRegistry) RegisterWhitelist(patterns ...string) { + r.whitelist.Add(patterns...) +} + +// Whitelist returns a copy of all registered whitelist path patterns. +func (r *RouterRegistry) Whitelist() []string { + return r.whitelist.Patterns() +} + +// IsWhitelisted checks if the given path matches any registered whitelist pattern. +func (r *RouterRegistry) IsWhitelisted(path string) bool { + return r.whitelist.Match(path) +} + +// RouterGroup represents a scoped route group with a path prefix and group-level middlewares. +type RouterGroup struct { + registry *RouterRegistry + prefix string + middlewares []any +} + +// Use adds middlewares to this group. +func (g *RouterGroup) Use(middlewares ...any) { + g.middlewares = append(g.middlewares, middlewares...) +} + +// Group creates a nested RouteGroup. +func (g *RouterGroup) Group(prefix string, middlewares ...any) RouterExtension { + combinedPrefix := joinPaths(g.prefix, prefix) + combinedMiddlewares := make([]any, 0, len(g.middlewares)+len(middlewares)) + combinedMiddlewares = append(combinedMiddlewares, g.middlewares...) + combinedMiddlewares = append(combinedMiddlewares, middlewares...) + + return &RouterGroup{ + registry: g.registry, + prefix: combinedPrefix, + middlewares: combinedMiddlewares, + } +} + +// Handle registers a route under this group. +func (g *RouterGroup) Handle(method, path string, handlers ...any) RouteDefinition { + return g.addRoute(method, joinPaths(g.prefix, path), handlers...) +} + +// addRoute appends a route under this group whose path is already joined. +func (g *RouterGroup) addRoute(method, fullPath string, handlers ...any) RouteDefinition { + g.registry.mu.Lock() + defer g.registry.mu.Unlock() + + allMiddlewares := make([]any, 0, len(g.registry.middlewares)+len(g.middlewares)) + allMiddlewares = append(allMiddlewares, g.registry.middlewares...) + allMiddlewares = append(allMiddlewares, g.middlewares...) + + g.registry.nextID++ + rd := RouteDefinition{ + ID: g.registry.nextID, + Method: strings.ToUpper(method), + Path: fullPath, + Handlers: handlers, + Middlewares: allMiddlewares, + } + g.registry.routes = append(g.registry.routes, rd) + return rd +} + +// Unregister removes a route under this group prefix matching method and path. +func (g *RouterGroup) Unregister(method, path string) bool { + fullPath := joinPaths(g.prefix, path) + return g.registry.Unregister(method, fullPath) +} + +// UnregisterByID removes a route by its unique ID. +func (g *RouterGroup) UnregisterByID(id uint64) bool { + return g.registry.UnregisterByID(id) +} + +// GET registers a GET route in this group. +func (g *RouterGroup) GET(path string, handlers ...any) RouteDefinition { + return g.Handle("GET", path, handlers...) +} + +// POST registers a POST route in this group. +func (g *RouterGroup) POST(path string, handlers ...any) RouteDefinition { + return g.Handle("POST", path, handlers...) +} + +// PUT registers a PUT route in this group. +func (g *RouterGroup) PUT(path string, handlers ...any) RouteDefinition { + return g.Handle("PUT", path, handlers...) +} + +// DELETE registers a DELETE route in this group. +func (g *RouterGroup) DELETE(path string, handlers ...any) RouteDefinition { + return g.Handle("DELETE", path, handlers...) +} + +// PATCH registers a PATCH route in this group. +func (g *RouterGroup) PATCH(path string, handlers ...any) RouteDefinition { + return g.Handle("PATCH", path, handlers...) +} + +// HEAD registers a HEAD route in this group. +func (g *RouterGroup) HEAD(path string, handlers ...any) RouteDefinition { + return g.Handle("HEAD", path, handlers...) +} + +// OPTIONS registers an OPTIONS route in this group. +func (g *RouterGroup) OPTIONS(path string, handlers ...any) RouteDefinition { + return g.Handle("OPTIONS", path, handlers...) +} + +// Any registers a route in this group for standard HTTP methods. +func (g *RouterGroup) Any(path string, handlers ...any) []RouteDefinition { + methods := []string{"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"} + defs := make([]RouteDefinition, 0, len(methods)) + for _, m := range methods { + defs = append(defs, g.Handle(m, path, handlers...)) + } + return defs +} + +// Routes returns all routes from the parent registry. +func (g *RouterGroup) Routes() []RouteDefinition { + return g.registry.Routes() +} + +// Middlewares returns a copy of the group's middlewares. +func (g *RouterGroup) Middlewares() []any { + res := make([]any, len(g.middlewares)) + copy(res, g.middlewares) + return res +} + +// RegisterWhitelist adds path patterns under this group prefix to the whitelist. +func (g *RouterGroup) RegisterWhitelist(patterns ...string) { + for _, p := range patterns { + g.registry.RegisterWhitelist(joinPaths(g.prefix, p)) + } +} + +// Whitelist returns a copy of all registered whitelist path patterns. +func (g *RouterGroup) Whitelist() []string { + return g.registry.Whitelist() +} + +// IsWhitelisted checks if the given path matches any registered whitelist pattern. +func (g *RouterGroup) IsWhitelisted(path string) bool { + return g.registry.IsWhitelisted(path) +} + +func cleanPath(p string) string { + if p == "" { + return "/" + } + if !strings.HasPrefix(p, "/") { + p = "/" + p + } + if len(p) > 1 && strings.HasSuffix(p, "/") { + p = strings.TrimSuffix(p, "/") + } + return p +} + +func joinPaths(base, relative string) string { + if base == "" || base == "/" { + return cleanPath(relative) + } + if relative == "" || relative == "/" { + return cleanPath(base) + } + base = strings.TrimSuffix(base, "/") + relative = strings.TrimPrefix(relative, "/") + return cleanPath(base + "/" + relative) +} + +// MatchPathPattern checks if a URL path matches a pattern (supports exact match and wildcards). +func MatchPathPattern(pattern, path string) bool { + pattern = cleanPath(pattern) + path = cleanPath(path) + + if pattern == path { + return true + } + + // Suffix wildcard: /api/v1/oauth/* matches /api/v1/oauth and /api/v1/oauth/... + if strings.HasSuffix(pattern, "/*") { + prefix := strings.TrimSuffix(pattern, "/*") + if path == prefix || strings.HasPrefix(path, prefix+"/") { + return true + } + } + + // Parameter wildcard: /api/v1/oauth/*/authorize or /api/v1/oauth/:source/authorize + patternParts := strings.Split(pattern, "/") + pathParts := strings.Split(path, "/") + if len(patternParts) == len(pathParts) { + matched := true + for i, part := range patternParts { + if part == "*" || strings.HasPrefix(part, ":") { + continue + } + if part != pathParts[i] { + matched = false + break + } + } + if matched { + return true + } + } + + return false +} + +// compiledPattern holds a whitelist pattern with its per-request work already done. +type compiledPattern struct { + raw string // normalised pattern, reported back by Patterns + prefix string // non-empty when the pattern ends in "/*" + parts []string // normalised pattern split on "/" +} + +// PathWhitelist matches request paths against a fixed set of patterns. +// +// Patterns are registered once during plugin Apply and never change afterwards, so +// normalising and splitting them on every request is wasted work. PathWhitelist +// does that once at registration instead. The zero value is ready to use. +type PathWhitelist struct { + mu sync.RWMutex + patterns []compiledPattern +} + +// NewPathWhitelist returns a whitelist pre-populated with the given patterns. +func NewPathWhitelist(patterns ...string) *PathWhitelist { + w := &PathWhitelist{} + w.Add(patterns...) + return w +} + +// compilePatterns normalises and splits each pattern once, ahead of any request. +func compilePatterns(patterns []string) []compiledPattern { + compiled := make([]compiledPattern, 0, len(patterns)) + for _, p := range patterns { + clean := cleanPath(p) + cp := compiledPattern{raw: clean, parts: strings.Split(clean, "/")} + if strings.HasSuffix(clean, "/*") { + cp.prefix = strings.TrimSuffix(clean, "/*") + } + compiled = append(compiled, cp) + } + return compiled +} + +// Add appends patterns, normalising and splitting each now rather than per request. +func (w *PathWhitelist) Add(patterns ...string) { + if len(patterns) == 0 { + return + } + compiled := compilePatterns(patterns) + + w.mu.Lock() + defer w.mu.Unlock() + w.patterns = append(w.patterns, compiled...) +} + +// Replace discards any existing patterns and installs the given ones, for callers +// whose configuration is a full swap rather than an incremental registration. +func (w *PathWhitelist) Replace(patterns ...string) { + compiled := compilePatterns(patterns) + + w.mu.Lock() + defer w.mu.Unlock() + w.patterns = compiled +} + +// Match reports whether path matches any registered pattern. Equivalent to calling +// MatchPathPattern for every pattern, except the path is normalised and split once. +func (w *PathWhitelist) Match(path string) bool { + clean := cleanPath(path) + pathParts := strings.Split(clean, "/") + + w.mu.RLock() + defer w.mu.RUnlock() + for i := range w.patterns { + p := &w.patterns[i] + if p.raw == clean { + return true + } + // A suffix wildcard matches both the bare prefix and anything below it. + if p.prefix != "" && (clean == p.prefix || strings.HasPrefix(clean, p.prefix+"/")) { + return true + } + if len(p.parts) != len(pathParts) { + continue + } + if matchSegments(p.parts, pathParts) { + return true + } + } + return false +} + +// matchSegments compares an already-split pattern against an already-split path. +func matchSegments(patternParts, pathParts []string) bool { + for i, part := range patternParts { + if part == "*" || strings.HasPrefix(part, ":") { + continue + } + if part != pathParts[i] { + return false + } + } + return true +} + +// Patterns returns a copy of the registered patterns in registration order. +func (w *PathWhitelist) Patterns() []string { + w.mu.RLock() + defer w.mu.RUnlock() + res := make([]string, len(w.patterns)) + for i := range w.patterns { + res[i] = w.patterns[i].raw + } + return res +} + +// ─── Raw path registration ──────────────────────────────────────────────────── + +// ensureLeadingSlash normalises a path to start with exactly one "/" while +// preserving any trailing slash (unlike cleanPath). +func ensureLeadingSlash(p string) string { + if p == "" { + return "/" + } + if !strings.HasPrefix(p, "/") { + return "/" + p + } + return p +} + +// joinPathPreservingTrailing joins a group prefix and a relative path without +// stripping a trailing slash, so a group "/x" can serve both "/x" and "/x/". +func joinPathPreservingTrailing(base, relative string) string { + rel := ensureLeadingSlash(relative) + if base == "" || base == "/" { + return rel + } + return strings.TrimSuffix(cleanPath(base), "/") + rel +} + +// HandleRaw registers a route on the root registry, preserving a trailing slash. +func (r *RouterRegistry) HandleRaw(method, path string, handlers ...any) RouteDefinition { + return r.addRoute(method, ensureLeadingSlash(path), handlers...) +} + +// BasePath returns "" because the root registry has no prefix. +func (r *RouterRegistry) BasePath() string { return "" } + +// HandleRaw registers a route under this group, preserving a trailing slash. +func (g *RouterGroup) HandleRaw(method, path string, handlers ...any) RouteDefinition { + return g.addRoute(method, joinPathPreservingTrailing(g.prefix, path), handlers...) +} + +// BasePath returns this group's absolute prefix. +func (g *RouterGroup) BasePath() string { return g.prefix } diff --git a/backend/core/extpoints/router_raw_test.go b/backend/core/extpoints/router_raw_test.go new file mode 100644 index 00000000..31b0f38f --- /dev/null +++ b/backend/core/extpoints/router_raw_test.go @@ -0,0 +1,52 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package extpoints + +import "testing" + +// TestHandleRawPreservesTrailingSlash 验证 HandleRaw 能表达 /x 与 /x/ 两条不同路由, +// 而 Handle 会归一化掉尾部斜杠(server 插件的 list 端点历史行为依赖这一点)。 +func TestHandleRawPreservesTrailingSlash(t *testing.T) { + r := &RouterRegistry{} + g := r.Group("/api/v1/nodes") + + if got := g.BasePath(); got != "/api/v1/nodes" { + t.Fatalf("BasePath() = %q, want %q", got, "/api/v1/nodes") + } + slashless := g.Handle("GET", "") + slashed := g.HandleRaw("GET", "/") + + if slashless.Path != "/api/v1/nodes" { + t.Errorf("Handle(\"\") path = %q, want %q", slashless.Path, "/api/v1/nodes") + } + if slashed.Path != "/api/v1/nodes/" { + t.Errorf("HandleRaw(\"/\") path = %q, want %q", slashed.Path, "/api/v1/nodes/") + } + if slashed.ID == slashless.ID { + t.Error("HandleRaw must allocate its own route ID so scoped teardown can unregister both") + } + if got := len(r.Routes()); got != 2 { + t.Errorf("registry routes = %d, want 2", got) + } + if !r.UnregisterByID(slashed.ID) { + t.Error("UnregisterByID(HandleRaw route) = false, want true") + } + if got := len(r.Routes()); got != 1 { + t.Errorf("routes after unregister = %d, want 1", got) + } +} + +// TestRegistryHandleRawKeepsAbsolutePath 根注册表上 HandleRaw 只做绝对化处理。 +func TestRegistryHandleRawKeepsAbsolutePath(t *testing.T) { + r := &RouterRegistry{} + if got := r.HandleRaw("GET", "/health/").Path; got != "/health/" { + t.Errorf("path = %q, want %q", got, "/health/") + } + if got := r.HandleRaw("POST", "submit").Path; got != "/submit" { + t.Errorf("path = %q, want %q", got, "/submit") + } + if got := r.BasePath(); got != "" { + t.Errorf("registry BasePath() = %q, want empty", got) + } +} diff --git a/backend/core/extpoints/schedule.go b/backend/core/extpoints/schedule.go new file mode 100644 index 00000000..663f69a6 --- /dev/null +++ b/backend/core/extpoints/schedule.go @@ -0,0 +1,111 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package extpoints + +import "sync" + +// ScheduleDefinition holds the configuration for a scheduled/cron task. +type ScheduleDefinition struct { + Spec string + TaskType string + Payload any + Options map[string]any +} + +// ScheduleOption configures a ScheduleDefinition. +type ScheduleOption func(*ScheduleDefinition) + +// WithScheduleOption adds a custom option to the schedule definition. +func WithScheduleOption(key string, val any) ScheduleOption { + return func(sd *ScheduleDefinition) { + if sd.Options == nil { + sd.Options = make(map[string]any) + } + sd.Options[key] = val + } +} + +// ScheduleExtension defines the interface for registering and querying cron/scheduled tasks. +type ScheduleExtension interface { + Register(spec, taskType string, payload any, opts ...ScheduleOption) + RegisterCron(spec, taskType string, payload any, opts ...ScheduleOption) + Schedules() []ScheduleDefinition + Get(taskType string) (ScheduleDefinition, bool) + Unregister(taskType string) bool +} + +// ScheduleRegistry collects and manages schedule registrations. +type ScheduleRegistry struct { + mu sync.RWMutex + schedules []ScheduleDefinition + lookup map[string]ScheduleDefinition +} + +// NewScheduleRegistry creates a new schedule registry. +func NewScheduleRegistry() *ScheduleRegistry { + return &ScheduleRegistry{ + lookup: make(map[string]ScheduleDefinition), + } +} + +// Register adds a schedule definition. +func (s *ScheduleRegistry) Register(spec, taskType string, payload any, opts ...ScheduleOption) { + s.mu.Lock() + defer s.mu.Unlock() + + sd := ScheduleDefinition{ + Spec: spec, + TaskType: taskType, + Payload: payload, + Options: make(map[string]any), + } + + for _, opt := range opts { + if opt != nil { + opt(&sd) + } + } + + if _, exists := s.lookup[taskType]; exists { + for i, item := range s.schedules { + if item.TaskType == taskType { + s.schedules[i] = sd + break + } + } + } else { + s.schedules = append(s.schedules, sd) + } + + s.lookup[taskType] = sd +} + +// RegisterCron is an alias for Register. +func (s *ScheduleRegistry) RegisterCron(spec, taskType string, payload any, opts ...ScheduleOption) { + s.Register(spec, taskType, payload, opts...) +} + +// Unregister removes a registered schedule definition by its task type. +func (s *ScheduleRegistry) Unregister(taskType string) bool { + return unregisterEntry(&s.mu, s.lookup, &s.schedules, taskType, func(item ScheduleDefinition) bool { + return item.TaskType == taskType + }) +} + +// Schedules returns a copy of all registered ScheduleDefinitions. +func (s *ScheduleRegistry) Schedules() []ScheduleDefinition { + s.mu.RLock() + defer s.mu.RUnlock() + res := make([]ScheduleDefinition, len(s.schedules)) + copy(res, s.schedules) + return res +} + +// Get retrieves a schedule definition by its task type. +func (s *ScheduleRegistry) Get(taskType string) (ScheduleDefinition, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + sd, ok := s.lookup[taskType] + return sd, ok +} diff --git a/backend/core/extpoints/setting.go b/backend/core/extpoints/setting.go new file mode 100644 index 00000000..2232ebc2 --- /dev/null +++ b/backend/core/extpoints/setting.go @@ -0,0 +1,88 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package extpoints + +import "sync" + +// SettingSchema defines the configuration schema and metadata for a system or plugin setting. +type SettingSchema struct { + Key string `json:"key"` + Default any `json:"default"` + Description string `json:"description"` + Type string `json:"type,omitempty"` + ReadOnly bool `json:"read_only,omitempty"` + Public bool `json:"public,omitempty"` + Category string `json:"category,omitempty"` + Validation string `json:"validation,omitempty"` +} + +// SettingExtension defines the interface for registering and querying setting configuration schemas. +type SettingExtension interface { + Register(schema SettingSchema) + Schemas() []SettingSchema + Get(key string) (SettingSchema, bool) + Unregister(key string) bool +} + +// SettingRegistry collects and manages setting configuration schemas. +type SettingRegistry struct { + mu sync.RWMutex + schemas []SettingSchema + lookup map[string]SettingSchema +} + +// NewSettingRegistry creates a new setting schema registry. +func NewSettingRegistry() *SettingRegistry { + return &SettingRegistry{ + lookup: make(map[string]SettingSchema), + } +} + +// Register registers a SettingSchema into the registry. +// Panics if the schema Key is empty. +func (s *SettingRegistry) Register(schema SettingSchema) { + if schema.Key == "" { + panic("core/extpoints: setting schema key cannot be empty") + } + + s.mu.Lock() + defer s.mu.Unlock() + + if _, exists := s.lookup[schema.Key]; exists { + for i, item := range s.schemas { + if item.Key == schema.Key { + s.schemas[i] = schema + break + } + } + } else { + s.schemas = append(s.schemas, schema) + } + + s.lookup[schema.Key] = schema +} + +// Unregister removes a registered SettingSchema by its key. +func (s *SettingRegistry) Unregister(key string) bool { + return unregisterEntry(&s.mu, s.lookup, &s.schemas, key, func(item SettingSchema) bool { + return item.Key == key + }) +} + +// Schemas returns a copy of all registered SettingSchemas. +func (s *SettingRegistry) Schemas() []SettingSchema { + s.mu.RLock() + defer s.mu.RUnlock() + res := make([]SettingSchema, len(s.schemas)) + copy(res, s.schemas) + return res +} + +// Get retrieves a SettingSchema by its key. +func (s *SettingRegistry) Get(key string) (SettingSchema, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + schema, ok := s.lookup[key] + return schema, ok +} diff --git a/backend/core/extpoints/task.go b/backend/core/extpoints/task.go new file mode 100644 index 00000000..1217ed64 --- /dev/null +++ b/backend/core/extpoints/task.go @@ -0,0 +1,285 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package extpoints + +import ( + "Wavelet/core/contracts" + "sync" + "time" +) + +// TaskDefinition holds the definition and runtime options for an asynchronous background task. +type TaskDefinition struct { + Pattern string + Type string + Name string + DisplayName string + Description string + Category string + SupportsTime bool + Retryable bool + Queue string + Params []contracts.TaskParamDTO + Handler any + Concurrency int + Retry int + Timeout time.Duration + Metadata map[string]any +} + +// TaskOption configures a TaskDefinition. +type TaskOption func(*TaskDefinition) + +// WithTaskType sets the admin task type identifier. +func WithTaskType(taskType string) TaskOption { + return func(td *TaskDefinition) { + td.Type = taskType + } +} + +// WithTaskName sets the task human-readable display name. +func WithTaskName(name string) TaskOption { + return func(td *TaskDefinition) { + td.Name = name + if td.DisplayName == "" { + td.DisplayName = name + } + } +} + +// WithTaskDisplayName sets the task display name. +func WithTaskDisplayName(displayName string) TaskOption { + return func(td *TaskDefinition) { + td.DisplayName = displayName + if td.Name == "" { + td.Name = displayName + } + } +} + +// WithTaskDescription sets the task description. +func WithTaskDescription(desc string) TaskOption { + return func(td *TaskDefinition) { + td.Description = desc + } +} + +// WithTaskCategory sets the task category grouping. +func WithTaskCategory(category string) TaskOption { + return func(td *TaskDefinition) { + td.Category = category + } +} + +// WithTaskSupportsTime sets whether the task supports time range filtering. +func WithTaskSupportsTime(supports bool) TaskOption { + return func(td *TaskDefinition) { + td.SupportsTime = supports + } +} + +// WithTaskQueue sets the task queue. +func WithTaskQueue(queue string) TaskOption { + return func(td *TaskDefinition) { + td.Queue = queue + } +} + +// WithTaskRetryable sets whether the task is retryable. +func WithTaskRetryable(retryable bool) TaskOption { + return func(td *TaskDefinition) { + td.Retryable = retryable + } +} + +// WithTaskParams sets the task parameter definitions. +func WithTaskParams(params ...contracts.TaskParamDTO) TaskOption { + return func(td *TaskDefinition) { + td.Params = append(td.Params, params...) + } +} + +// WithTaskMeta sets all task metadata from a TaskMetaDTO. +func WithTaskMeta(meta contracts.TaskMetaDTO) TaskOption { + return func(td *TaskDefinition) { + if meta.Type != "" { + td.Type = meta.Type + } + if meta.Name != "" { + td.Name = meta.Name + } + if meta.DisplayName != "" { + td.DisplayName = meta.DisplayName + } + if meta.Description != "" { + td.Description = meta.Description + } + if meta.Category != "" { + td.Category = meta.Category + } + td.SupportsTime = meta.SupportsTime + if meta.Queue != "" { + td.Queue = meta.Queue + } + td.Retryable = meta.Retryable + if meta.MaxRetry > 0 { + td.Retry = meta.MaxRetry + } + if meta.Timeout > 0 { + td.Timeout = meta.Timeout + } + if len(meta.Params) > 0 { + td.Params = append([]contracts.TaskParamDTO(nil), meta.Params...) + } + } +} + +// WithTaskConcurrency sets the concurrency limit for the task. +func WithTaskConcurrency(concurrency int) TaskOption { + return func(td *TaskDefinition) { + td.Concurrency = concurrency + } +} + +// WithTaskRetry sets the maximum retry count for the task. +func WithTaskRetry(retry int) TaskOption { + return func(td *TaskDefinition) { + td.Retry = retry + } +} + +// WithTaskTimeout sets the execution timeout for the task. +func WithTaskTimeout(timeout time.Duration) TaskOption { + return func(td *TaskDefinition) { + td.Timeout = timeout + } +} + +// WithTaskMetadata adds a key-value pair to the task metadata. +func WithTaskMetadata(key string, val any) TaskOption { + return func(td *TaskDefinition) { + if td.Metadata == nil { + td.Metadata = make(map[string]any) + } + td.Metadata[key] = val + } +} + +// ToDTO converts TaskDefinition to contracts.TaskMetaDTO. +func (td TaskDefinition) ToDTO() contracts.TaskMetaDTO { + taskType := td.Type + if taskType == "" { + taskType = td.Pattern + } + name := td.Name + if name == "" { + name = td.DisplayName + } + if name == "" { + name = td.Pattern + } + displayName := td.DisplayName + if displayName == "" { + displayName = name + } + queue := td.Queue + if queue == "" { + queue = "default" + } + retryable := td.Retryable + if !retryable && td.Retry > 0 { + retryable = true + } + return contracts.TaskMetaDTO{ + Type: taskType, + AsynqTask: td.Pattern, + Name: name, + DisplayName: displayName, + Description: td.Description, + Category: td.Category, + SupportsTime: td.SupportsTime, + Params: td.Params, + MaxRetry: td.Retry, + Timeout: td.Timeout, + Queue: queue, + Retryable: retryable, + } +} + +// TaskExtension defines the interface for registering and querying background task handlers. +type TaskExtension interface { + Register(pattern string, handler any, opts ...TaskOption) + Tasks() []TaskDefinition + Get(pattern string) (TaskDefinition, bool) + Unregister(pattern string) bool +} + +// TaskRegistry collects and manages task registrations. +type TaskRegistry struct { + mu sync.RWMutex + tasks []TaskDefinition + lookup map[string]TaskDefinition +} + +// NewTaskRegistry creates a new task registry. +func NewTaskRegistry() *TaskRegistry { + return &TaskRegistry{ + lookup: make(map[string]TaskDefinition), + } +} + +// Register registers a task pattern and its handler with optional configuration. +func (t *TaskRegistry) Register(pattern string, handler any, opts ...TaskOption) { + t.mu.Lock() + defer t.mu.Unlock() + + td := TaskDefinition{ + Pattern: pattern, + Handler: handler, + Metadata: make(map[string]any), + } + + for _, opt := range opts { + if opt != nil { + opt(&td) + } + } + + if _, exists := t.lookup[pattern]; exists { + for i, item := range t.tasks { + if item.Pattern == pattern { + t.tasks[i] = td + break + } + } + } else { + t.tasks = append(t.tasks, td) + } + + t.lookup[pattern] = td +} + +// Unregister removes a registered task definition by its pattern. +func (t *TaskRegistry) Unregister(pattern string) bool { + return unregisterEntry(&t.mu, t.lookup, &t.tasks, pattern, func(item TaskDefinition) bool { + return item.Pattern == pattern + }) +} + +// Tasks returns a copy of all registered TaskDefinitions. +func (t *TaskRegistry) Tasks() []TaskDefinition { + t.mu.RLock() + defer t.mu.RUnlock() + res := make([]TaskDefinition, len(t.tasks)) + copy(res, t.tasks) + return res +} + +// Get retrieves a task definition by its pattern. +func (t *TaskRegistry) Get(pattern string) (TaskDefinition, bool) { + t.mu.RLock() + defer t.mu.RUnlock() + td, ok := t.lookup[pattern] + return td, ok +} diff --git a/backend/core/extpoints/whitelist_test.go b/backend/core/extpoints/whitelist_test.go new file mode 100644 index 00000000..2a33e501 --- /dev/null +++ b/backend/core/extpoints/whitelist_test.go @@ -0,0 +1,157 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package extpoints_test + +import ( + "Wavelet/core/extpoints" + "testing" +) + +// whitelistEquivalencePatterns and paths cover every matching rule MatchPathPattern +// implements, so PathWhitelist.Match can be pinned against the behaviour it replaces. +var ( + whitelistEquivalencePatterns = []string{ + "/api/v1/user/login", + "/api/v1/oauth/*", + "/api/v1/cap/:source/authorize", + "/api/v1/files/*/download", + "/", + "login", + "/api/v1/x/", + "", + } + + whitelistEquivalencePaths = []string{ + "/api/v1/user/login", + "/api/v1/user/login/", + "/api/v1/oauth/callback", + "/api/v1/oauth", + "/api/v1/oauth/a/b", + "/api/v1/cap/github/authorize", + "/api/v1/cap/:source/authorize", + "/api/v1/files/abc/download", + "/api/v1/files/a/b/download", + "/", + "login", + "/login", + "", + "/api/v1/x", + "/api/v1/x/y", + } +) + +// legacyMatch reproduces the per-request loop every whitelist caller used before +// PathWhitelist existed. +func legacyMatch(patterns []string, path string) bool { + for _, pattern := range patterns { + if extpoints.MatchPathPattern(pattern, path) { + return true + } + } + return false +} + +func TestPathWhitelistMatchesLegacyLoop(t *testing.T) { + for _, pattern := range whitelistEquivalencePatterns { + wl := extpoints.NewPathWhitelist(pattern) + for _, path := range whitelistEquivalencePaths { + got := wl.Match(path) + want := legacyMatch([]string{pattern}, path) + if got != want { + t.Errorf("pattern %q path %q: Match=%v, legacy=%v", pattern, path, got, want) + } + } + } +} + +func TestPathWhitelistAccumulatesAcrossRegistration(t *testing.T) { + wl := extpoints.NewPathWhitelist("/api/v1/a") + wl.Add("/api/v1/b/*") + + if !wl.Match("/api/v1/a") { + t.Error("first registration lost") + } + if !wl.Match("/api/v1/b/deep") { + t.Error("second registration lost") + } + if wl.Match("/api/v1/c") { + t.Error("path outside both registrations matched") + } + + got := wl.Patterns() + want := []string{"/api/v1/a", "/api/v1/b/*"} + if len(got) != len(want) { + t.Fatalf("Patterns() = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("Patterns()[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestPathWhitelistReplaceDropsPreviousPatterns(t *testing.T) { + wl := extpoints.NewPathWhitelist("/api/v1/a") + wl.Replace("/api/v1/b") + + if wl.Match("/api/v1/a") { + t.Error("Replace kept a pattern it should have discarded") + } + if !wl.Match("/api/v1/b") { + t.Error("Replace did not install the new pattern") + } +} + +// whitelistBenchPatterns mirrors a realistically sized auth whitelist. +var whitelistBenchPatterns = []string{ + "/api/v1/user/login", + "/api/v1/auth/refresh", + "/api/v1/oauth/*", + "/api/v1/cap/*", + "/api/v1/public/config", + "/api/v1/health", + "/api/v1/uploads/:id/file", + "/api/v1/notify/webhook/:channel", + "/login", + "/api/v1/access-tokens/:id/revoke", +} + +// TestPathWhitelistAllocationReduction asserts the point of pre-compiling: a single +// Match must allocate less than the legacy per-pattern loop it replaces. +func TestPathWhitelistAllocationReduction(t *testing.T) { + wl := extpoints.NewPathWhitelist(whitelistBenchPatterns...) + + legacy := testing.Benchmark(func(b *testing.B) { + for b.Loop() { + _ = legacyMatch(whitelistBenchPatterns, "/api/v1/uploads/9/file") + } + }) + compiled := testing.Benchmark(func(b *testing.B) { + for b.Loop() { + _ = wl.Match("/api/v1/uploads/9/file") + } + }) + + legacyAlloc := legacy.AllocsPerOp() + compiledAlloc := compiled.AllocsPerOp() + t.Logf("legacy %d allocs/op, PathWhitelist %d allocs/op", legacyAlloc, compiledAlloc) + + if compiledAlloc >= legacyAlloc { + t.Errorf("PathWhitelist allocated %d/op, want fewer than legacy %d/op", compiledAlloc, legacyAlloc) + } +} + +func BenchmarkLegacyWhitelistMatch(b *testing.B) { + for b.Loop() { + _ = legacyMatch(whitelistBenchPatterns, "/api/v1/uploads/9/file") + } +} + +func BenchmarkPathWhitelistMatch(b *testing.B) { + wl := extpoints.NewPathWhitelist(whitelistBenchPatterns...) + b.ResetTimer() + for b.Loop() { + _ = wl.Match("/api/v1/uploads/9/file") + } +} diff --git a/backend/core/fiber.go b/backend/core/fiber.go new file mode 100644 index 00000000..899711b7 --- /dev/null +++ b/backend/core/fiber.go @@ -0,0 +1,181 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package core + +import ( + "fmt" + "reflect" + "sync" +) + +// FiberState represents the lifecycle status of a plugin instance in the Cordis micro-kernel. +type FiberState string + +const ( + // FiberPending indicates the plugin is waiting for its required dependencies to be provided. + FiberPending FiberState = "PENDING" + + // FiberLoading indicates the plugin is currently running its Apply mounting phase. + FiberLoading FiberState = "LOADING" + + // FiberActive indicates the plugin is fully mounted, active, and operating without error. + FiberActive FiberState = "ACTIVE" + + // FiberUnloading indicates the plugin is tearing down its scoped effects in LIFO order. + FiberUnloading FiberState = "UNLOADING" + + // FiberDisposed indicates the plugin has been completely unmounted and its context disposed. + FiberDisposed FiberState = "DISPOSED" + + // FiberSkipped indicates the plugin never activated because its configuration gate + // evaluated to false, so an alternative provider took over. + FiberSkipped FiberState = "SKIPPED" +) + +// Fiber wraps a Plugin instance with a dedicated scoped Context and manages its +// reactive lifecycle state machine according to Cordis spatiotemporal composability principles. +type Fiber struct { + mu sync.RWMutex + plugin Plugin + state FiberState + ctx *Context + deps []reflect.Type + err error +} + +// NewFiber creates a new Fiber for the specified plugin with a child scoped context. +func NewFiber(rootCtx *Context, plugin Plugin) *Fiber { + var deps []reflect.Type + if depPlugin, ok := plugin.(DependentPlugin); ok { + deps = depPlugin.Inject() + } + + return &Fiber{ + plugin: plugin, + state: FiberPending, + ctx: rootCtx.Fork(), + deps: deps, + } +} + +// Plugin returns the underlying Plugin instance. +func (f *Fiber) Plugin() Plugin { + return f.plugin +} + +// Name returns the unique identifier of the plugin. +func (f *Fiber) Name() string { + if f.plugin == nil { + return "" + } + return f.plugin.Name() +} + +// State returns the current lifecycle state of this Fiber. +func (f *Fiber) State() FiberState { + f.mu.RLock() + defer f.mu.RUnlock() + return f.state +} + +// Context returns the dedicated scoped Context for this Fiber. +func (f *Fiber) Context() *Context { + return f.ctx +} + +// Dependencies returns the list of required service reflect.Types. +func (f *Fiber) Dependencies() []reflect.Type { + res := make([]reflect.Type, len(f.deps)) + copy(res, f.deps) + return res +} + +// Error returns the latest mounting or unmounting error, if any. +func (f *Fiber) Error() error { + f.mu.RLock() + defer f.mu.RUnlock() + return f.err +} + +// DependenciesSatisfied checks if all declared dependencies are present in the target Context container. +func (f *Fiber) DependenciesSatisfied(ctx *Context) bool { + if len(f.deps) == 0 { + return true + } + container := ctx.Container() + for _, dep := range f.deps { + if _, err := container.resolve(dep); err != nil { + return false + } + } + return true +} + +// Load executes the plugin mounting lifecycle: PENDING -> LOADING -> ACTIVE. +func (f *Fiber) Load() error { + f.mu.Lock() + if f.state != FiberPending { + f.mu.Unlock() + return nil + } + f.state = FiberLoading + f.err = nil + f.mu.Unlock() + + if err := f.plugin.Apply(f.ctx); err != nil { + f.mu.Lock() + f.err = fmt.Errorf("fiber %q: apply failed: %w", f.Name(), err) + f.state = FiberPending + _ = f.ctx.Dispose() + f.mu.Unlock() + return err + } + + f.mu.Lock() + f.state = FiberActive + f.mu.Unlock() + return nil +} + +// Skip transitions a pending plugin to FiberSkipped and releases its scoped Context. +// Active plugins are left untouched, which makes the call safe to replay on every +// reconciliation pass, including for plugins mounted after the first gate evaluation. +func (f *Fiber) Skip() error { + f.mu.Lock() + if f.state != FiberPending { + f.mu.Unlock() + return nil + } + f.state = FiberSkipped + f.mu.Unlock() + + return f.ctx.Dispose() +} + +// Skipped reports whether the plugin was excluded by its configuration gate. +func (f *Fiber) Skipped() bool { + return f.State() == FiberSkipped +} + +// Unload tears down the plugin: ACTIVE -> UNLOADING -> DISPOSED. +func (f *Fiber) Unload() error { + f.mu.Lock() + if f.state != FiberActive && f.state != FiberLoading { + f.mu.Unlock() + return nil + } + f.state = FiberUnloading + f.mu.Unlock() + + err := f.ctx.Dispose() + + f.mu.Lock() + f.state = FiberDisposed + if err != nil { + f.err = err + } + f.mu.Unlock() + + return err +} diff --git a/backend/core/fiber_test.go b/backend/core/fiber_test.go new file mode 100644 index 00000000..273b56bf --- /dev/null +++ b/backend/core/fiber_test.go @@ -0,0 +1,164 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package core_test + +import ( + "Wavelet/core" + "Wavelet/core/extpoints" + "context" + "reflect" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type MockServiceA interface { + DoA() string +} + +type mockServiceAImpl struct{} + +func (m *mockServiceAImpl) DoA() string { return "doneA" } + +type MockServiceB interface { + DoB() string +} + +type mockServiceBImpl struct{} + +func (m *mockServiceBImpl) DoB() string { return "doneB" } + +type mockProviderPlugin struct { + name string + applied bool +} + +func (p *mockProviderPlugin) Name() string { + return p.name +} + +func (p *mockProviderPlugin) Apply(ctx *core.Context) error { + p.applied = true + core.Provide[MockServiceA](ctx, &mockServiceAImpl{}) + return nil +} + +type mockConsumerPlugin struct { + name string + applied bool + gotSvc MockServiceA +} + +func (p *mockConsumerPlugin) Name() string { + return p.name +} + +func (p *mockConsumerPlugin) Inject() []reflect.Type { + return []reflect.Type{ + reflect.TypeFor[MockServiceA](), + } +} + +func (p *mockConsumerPlugin) Apply(ctx *core.Context) error { + p.applied = true + svc, err := core.Inject[MockServiceA](ctx) + if err != nil { + return err + } + p.gotSvc = svc + return nil +} + +func TestFiber_ConfluenceAndReactiveActivation(t *testing.T) { + app := core.NewApp() + + // Register Consumer BEFORE Provider to test confluence & out-of-order dependency resolution + consumer := &mockConsumerPlugin{name: "consumer-plugin"} + provider := &mockProviderPlugin{name: "provider-plugin"} + + app.Use(consumer, provider) + + err := app.Start(context.Background()) + require.NoError(t, err) + + assert.True(t, provider.applied, "provider should be applied") + assert.True(t, consumer.applied, "consumer should be reactively applied once dependency was provided") + assert.NotNil(t, consumer.gotSvc) + assert.Equal(t, "doneA", consumer.gotSvc.DoA()) + + fibers := app.Fibers() + require.Equal(t, 2, len(fibers)) + for _, f := range fibers { + assert.Equal(t, core.FiberActive, f.State()) + } + + err = app.Stop() + assert.NoError(t, err) +} + +func TestFiber_UnsatisfiedDependencyReturnsError(t *testing.T) { + app := core.NewApp() + + // Register Consumer whose dependency is never provided + consumer := &mockConsumerPlugin{name: "consumer-plugin"} + app.Use(consumer) + + err := app.Start(context.Background()) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsatisfied dependencies") +} + +// gateConfig is the configuration section consumed by gatedPlugin below. +type gateConfig struct { + Enabled bool `config:"enabled" env:"GATE_ENABLED"` +} + +// gatedPlugin implements core.ConfigGatedPlugin: two instances with opposite +// expectations model the mutually exclusive cache / cache_memory driver pair. +type gatedPlugin struct { + name string + enabled bool + applied bool +} + +func (g *gatedPlugin) Name() string { return g.name } + +func (g *gatedPlugin) Apply(_ *core.Context) error { + g.applied = true + return nil +} + +func (g *gatedPlugin) DeclareConfig() []extpoints.ConfigBinding { + return []extpoints.ConfigBinding{{Prefix: "gate", Target: &gateConfig{}}} +} + +func (g *gatedPlugin) ConfigEnabled(view extpoints.ConfigView) bool { + return view.Bool("gate.enabled", false) == g.enabled +} + +func TestFiberSkipMovesToSkippedStateAndDisposesScope(t *testing.T) { + root := core.NewContext(nil) + plugin := &gatedPlugin{name: "cache", enabled: true} + f := core.NewFiber(root, plugin) + require.Equal(t, core.FiberPending, f.State()) + + require.NoError(t, f.Skip()) + + assert.Equal(t, core.FiberSkipped, f.State()) + assert.True(t, f.Skipped()) + assert.False(t, plugin.applied, "a skipped plugin must never reach Apply") + assert.NoError(t, f.Unload(), "unloading a skipped fiber is a no-op") +} + +func TestFiberSkipIsInertForActiveFibers(t *testing.T) { + root := core.NewContext(nil) + f := core.NewFiber(root, &gatedPlugin{name: "cache", enabled: true}) + require.NoError(t, f.Load()) + + require.NoError(t, f.Skip()) + + assert.Equal(t, core.FiberActive, f.State(), "Skip only applies to pending fibers") + assert.False(t, f.Skipped()) +} diff --git a/backend/core/manifest.go b/backend/core/manifest.go new file mode 100644 index 00000000..68f2aef9 --- /dev/null +++ b/backend/core/manifest.go @@ -0,0 +1,38 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package core + +import ( + "fmt" + "strings" +) + +// Manifest defines the metadata and dependency declarations for a plugin. +type Manifest struct { + // Name is the unique identifier for the plugin (e.g. "auth", "user", "order"). + Name string `json:"name" yaml:"name"` + + // Version is the semantic version string of the plugin (e.g. "1.0.0"). + Version string `json:"version,omitempty" yaml:"version,omitempty"` + + // Description gives a brief summary of the plugin capabilities. + Description string `json:"description,omitempty" yaml:"description,omitempty"` + + // Author specifies the author or maintainer of the plugin. + Author string `json:"author,omitempty" yaml:"author,omitempty"` + + // Dependencies lists the plugin names that this plugin depends on. + Dependencies []string `json:"dependencies,omitempty" yaml:"dependencies,omitempty"` + + // Metadata holds arbitrary plugin-specific metadata. + Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"` +} + +// Validate checks whether the manifest satisfies basic integrity requirements. +func (m Manifest) Validate() error { + if strings.TrimSpace(m.Name) == "" { + return fmt.Errorf("%w: %w", ErrInvalidManifest, ErrInvalidManifestName) + } + return nil +} diff --git a/backend/core/scoped_extpoints.go b/backend/core/scoped_extpoints.go new file mode 100644 index 00000000..f567544a --- /dev/null +++ b/backend/core/scoped_extpoints.go @@ -0,0 +1,227 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package core + +import ( + "Wavelet/core/extpoints" +) + +// scopedRouterExtension wraps a RouterExtension to automatically register +// teardown disposers on the associated Context when routes are declared. +type scopedRouterExtension struct { + underlying extpoints.RouterExtension + ctx *Context +} + +func newScopedRouterExtension(ctx *Context, underlying extpoints.RouterExtension) extpoints.RouterExtension { + return &scopedRouterExtension{ + underlying: underlying, + ctx: ctx, + } +} + +func (s *scopedRouterExtension) Use(middlewares ...any) { + s.underlying.Use(middlewares...) +} + +func (s *scopedRouterExtension) Group(prefix string, middlewares ...any) extpoints.RouterExtension { + subGroup := s.underlying.Group(prefix, middlewares...) + return newScopedRouterExtension(s.ctx, subGroup) +} + +func (s *scopedRouterExtension) Handle(method, path string, handlers ...any) extpoints.RouteDefinition { + rd := s.underlying.Handle(method, path, handlers...) + routeID := rd.ID + s.ctx.OnDispose(func() error { + s.underlying.UnregisterByID(routeID) + return nil + }) + return rd +} + +// HandleRaw registers a trailing-slash-preserving route and tears it down with the scope. +func (s *scopedRouterExtension) HandleRaw(method, path string, handlers ...any) extpoints.RouteDefinition { + rd := s.underlying.HandleRaw(method, path, handlers...) + routeID := rd.ID + s.ctx.OnDispose(func() error { + s.underlying.UnregisterByID(routeID) + return nil + }) + return rd +} + +// BasePath delegates to the wrapped group prefix. +func (s *scopedRouterExtension) BasePath() string { return s.underlying.BasePath() } + +func (s *scopedRouterExtension) GET(path string, handlers ...any) extpoints.RouteDefinition { + return s.Handle("GET", path, handlers...) +} + +func (s *scopedRouterExtension) POST(path string, handlers ...any) extpoints.RouteDefinition { + return s.Handle("POST", path, handlers...) +} + +func (s *scopedRouterExtension) PUT(path string, handlers ...any) extpoints.RouteDefinition { + return s.Handle("PUT", path, handlers...) +} + +func (s *scopedRouterExtension) DELETE(path string, handlers ...any) extpoints.RouteDefinition { + return s.Handle("DELETE", path, handlers...) +} + +func (s *scopedRouterExtension) PATCH(path string, handlers ...any) extpoints.RouteDefinition { + return s.Handle("PATCH", path, handlers...) +} + +func (s *scopedRouterExtension) HEAD(path string, handlers ...any) extpoints.RouteDefinition { + return s.Handle("HEAD", path, handlers...) +} + +func (s *scopedRouterExtension) OPTIONS(path string, handlers ...any) extpoints.RouteDefinition { + return s.Handle("OPTIONS", path, handlers...) +} + +func (s *scopedRouterExtension) Any(path string, handlers ...any) []extpoints.RouteDefinition { + methods := []string{"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"} + defs := make([]extpoints.RouteDefinition, 0, len(methods)) + for _, m := range methods { + defs = append(defs, s.Handle(m, path, handlers...)) + } + return defs +} + +func (s *scopedRouterExtension) Routes() []extpoints.RouteDefinition { + return s.underlying.Routes() +} + +func (s *scopedRouterExtension) Middlewares() []any { + return s.underlying.Middlewares() +} + +func (s *scopedRouterExtension) Unregister(method, path string) bool { + return s.underlying.Unregister(method, path) +} + +func (s *scopedRouterExtension) UnregisterByID(id uint64) bool { + return s.underlying.UnregisterByID(id) +} + +func (s *scopedRouterExtension) RegisterWhitelist(patterns ...string) { + s.underlying.RegisterWhitelist(patterns...) +} + +func (s *scopedRouterExtension) Whitelist() []string { + return s.underlying.Whitelist() +} + +func (s *scopedRouterExtension) IsWhitelisted(path string) bool { + return s.underlying.IsWhitelisted(path) +} + +// scopedTaskExtension wraps a TaskExtension to automatically register +// teardown disposers on the associated Context when task handlers are declared. +type scopedTaskExtension struct { + underlying extpoints.TaskExtension + ctx *Context +} + +func newScopedTaskExtension(ctx *Context, underlying extpoints.TaskExtension) extpoints.TaskExtension { + return &scopedTaskExtension{ + underlying: underlying, + ctx: ctx, + } +} + +func (s *scopedTaskExtension) Register(pattern string, handler any, opts ...extpoints.TaskOption) { + s.underlying.Register(pattern, handler, opts...) + s.ctx.OnDispose(func() error { + s.underlying.Unregister(pattern) + return nil + }) +} + +func (s *scopedTaskExtension) Tasks() []extpoints.TaskDefinition { + return s.underlying.Tasks() +} + +func (s *scopedTaskExtension) Get(pattern string) (extpoints.TaskDefinition, bool) { + return s.underlying.Get(pattern) +} + +func (s *scopedTaskExtension) Unregister(pattern string) bool { + return s.underlying.Unregister(pattern) +} + +// scopedScheduleExtension wraps a ScheduleExtension to automatically register +// teardown disposers on the associated Context when cron/scheduled tasks are declared. +type scopedScheduleExtension struct { + underlying extpoints.ScheduleExtension + ctx *Context +} + +func newScopedScheduleExtension(ctx *Context, underlying extpoints.ScheduleExtension) extpoints.ScheduleExtension { + return &scopedScheduleExtension{ + underlying: underlying, + ctx: ctx, + } +} + +func (s *scopedScheduleExtension) Register(spec, taskType string, payload any, opts ...extpoints.ScheduleOption) { + s.underlying.Register(spec, taskType, payload, opts...) + s.ctx.OnDispose(func() error { + s.underlying.Unregister(taskType) + return nil + }) +} + +func (s *scopedScheduleExtension) RegisterCron(spec, taskType string, payload any, opts ...extpoints.ScheduleOption) { + s.Register(spec, taskType, payload, opts...) +} + +func (s *scopedScheduleExtension) Schedules() []extpoints.ScheduleDefinition { + return s.underlying.Schedules() +} + +func (s *scopedScheduleExtension) Get(taskType string) (extpoints.ScheduleDefinition, bool) { + return s.underlying.Get(taskType) +} + +func (s *scopedScheduleExtension) Unregister(taskType string) bool { + return s.underlying.Unregister(taskType) +} + +// scopedSettingExtension wraps a SettingExtension to automatically register +// teardown disposers on the associated Context when settings schemas are declared. +type scopedSettingExtension struct { + underlying extpoints.SettingExtension + ctx *Context +} + +func newScopedSettingExtension(ctx *Context, underlying extpoints.SettingExtension) extpoints.SettingExtension { + return &scopedSettingExtension{ + underlying: underlying, + ctx: ctx, + } +} + +func (s *scopedSettingExtension) Register(schema extpoints.SettingSchema) { + s.underlying.Register(schema) + key := schema.Key + s.ctx.OnDispose(func() error { + s.underlying.Unregister(key) + return nil + }) +} + +func (s *scopedSettingExtension) Schemas() []extpoints.SettingSchema { + return s.underlying.Schemas() +} + +func (s *scopedSettingExtension) Get(key string) (extpoints.SettingSchema, bool) { + return s.underlying.Get(key) +} + +func (s *scopedSettingExtension) Unregister(key string) bool { + return s.underlying.Unregister(key) +} diff --git a/backend/core/types.go b/backend/core/types.go new file mode 100644 index 00000000..5fb521fc --- /dev/null +++ b/backend/core/types.go @@ -0,0 +1,185 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package core + +import ( + "Wavelet/core/extpoints" + "context" + "errors" + "reflect" +) + +// Standard sentinel errors returned by core operations. +var ( + // ErrServiceNotFound is returned when a requested service is not registered in the IoC container. + ErrServiceNotFound = errors.New("core: service not found") + + // ErrServiceNotReady is returned when one or more required services are not ready in Using/UsingN. + ErrServiceNotReady = errors.New("core: service not ready") + + // ErrNilContext is returned when a nil Context is passed to an operation requiring a valid Context. + ErrNilContext = errors.New("core: context is nil") + + // ErrNilService is returned when attempting to provide a nil service implementation. + ErrNilService = errors.New("core: service is nil") + + // ErrInvalidManifest is returned when a plugin manifest fails validation. + ErrInvalidManifest = errors.New("core: invalid manifest") + + // ErrInvalidManifestName is returned when a plugin manifest has an empty name. + ErrInvalidManifestName = errors.New("core: manifest name is required") + + // ErrDriverNotFound is returned when a requested driver type is not registered. + ErrDriverNotFound = errors.New("core: driver not found") + + // ErrAppRunning is returned when attempting to start an already running App. + ErrAppRunning = errors.New("core: app is already running") + + // ErrAppNotRunning is returned when attempting to operate on an App that is not running. + ErrAppNotRunning = errors.New("core: app is not running") +) + +// Plugin is the unified contract for all core and downstream plugins. +type Plugin interface { + // Name returns the globally unique identifier of the plugin (e.g. "auth", "database"). + Name() string + // Apply is the core mounting entrypoint: provides services, registers routes, tasks, and event listeners. + Apply(ctx *Context) error +} + +// DependentPlugin is an optional extension interface for plugins that declare required service dependencies. +type DependentPlugin interface { + Plugin + Inject() []reflect.Type +} + +// PluginWithManifest is an optional extension interface for plugins that declare metadata. +type PluginWithManifest interface { + Plugin + Manifest() Manifest +} + +// DriverType identifies the category of a runtime driver engine. +type DriverType string + +const ( + // DriverTypeHTTP represents HTTP web server drivers (e.g. Gin). + DriverTypeHTTP DriverType = "http" + + // DriverTypeWorker represents asynchronous background worker drivers (e.g. Asynq worker server). + DriverTypeWorker DriverType = "worker" + + // DriverTypeScheduler represents cron and timer schedule drivers (e.g. Asynq scheduler). + DriverTypeScheduler DriverType = "schedule" +) + +// Driver is a runtime engine that manages an event loop or listening port. +type Driver interface { + // Type returns the category of this driver engine. + Type() DriverType + // Start starts the driver lifecycle loop. + Start(ctx context.Context) error + // Stop gracefully shuts down the driver. + Stop(ctx context.Context) error +} + +// Profile identifies the runtime aspect or execution mode of an application. +type Profile string + +const ( + // ProfileAPI runs HTTP API server drivers. + ProfileAPI Profile = "api" + + // ProfileWorker runs asynchronous background worker drivers. + ProfileWorker Profile = "worker" + + // ProfileSchedule runs cron and timer schedule drivers. + ProfileSchedule Profile = "schedule" + + // ProfileAll runs all registered drivers concurrently in fused mode. + ProfileAll Profile = "all" +) + +// MigrationEngine is the interface for executing database migrations across registered plugins. +// The ctx parameter is the root micro-kernel Context, allowing the engine to resolve +// services from the IoC container via core.Inject or core.Using. +type MigrationEngine interface { + Migrate(ctx *Context, entries []MigrationEntry) error +} + +// MigrationRunner is a function adapter implementing MigrationEngine. +type MigrationRunner func(ctx *Context, entries []MigrationEntry) error + +// Migrate calls the underlying migration function. +func (fn MigrationRunner) Migrate(ctx *Context, entries []MigrationEntry) error { + return fn(ctx, entries) +} + +// Disposer is a cleanup function executed when a Context is disposed. +type Disposer func() error + +// RouterExtension re-exports extpoints.RouterExtension. +type RouterExtension = extpoints.RouterExtension + +// RouteDefinition re-exports extpoints.RouteDefinition. +type RouteDefinition = extpoints.RouteDefinition + +// MigrationExtension re-exports extpoints.MigrationExtension. +type MigrationExtension = extpoints.MigrationExtension + +// MigrationEntry re-exports extpoints.MigrationEntry. +type MigrationEntry = extpoints.MigrationEntry + +// TaskExtension re-exports extpoints.TaskExtension. +type TaskExtension = extpoints.TaskExtension + +// TaskDefinition re-exports extpoints.TaskDefinition. +type TaskDefinition = extpoints.TaskDefinition + +// TaskOption re-exports extpoints.TaskOption. +type TaskOption = extpoints.TaskOption + +// ScheduleExtension re-exports extpoints.ScheduleExtension. +type ScheduleExtension = extpoints.ScheduleExtension + +// ScheduleDefinition re-exports extpoints.ScheduleDefinition. +type ScheduleDefinition = extpoints.ScheduleDefinition + +// ScheduleOption re-exports extpoints.ScheduleOption. +type ScheduleOption = extpoints.ScheduleOption + +// SettingExtension re-exports extpoints.SettingExtension. +type SettingExtension = extpoints.SettingExtension + +// SettingSchema re-exports extpoints.SettingSchema. +type SettingSchema = extpoints.SettingSchema + +// ConfigExtension re-exports extpoints.ConfigExtension. +type ConfigExtension = extpoints.ConfigExtension + +// ConfigSource re-exports extpoints.ConfigSource. +type ConfigSource = extpoints.ConfigSource + +// ConfigBinding re-exports extpoints.ConfigBinding. +type ConfigBinding = extpoints.ConfigBinding + +// ConfigView re-exports extpoints.ConfigView. +type ConfigView = extpoints.ConfigView + +// ConfigEntry re-exports extpoints.ConfigEntry. +type ConfigEntry = extpoints.ConfigEntry + +// ConfigGatedPlugin is an optional interface for plugins whose activation depends on +// configuration. The kernel evaluates the gate during reconciliation, before the +// plugin's Apply runs, so every key read by ConfigEnabled must be published through +// DeclareConfig. +type ConfigGatedPlugin interface { + Plugin + + // DeclareConfig publishes the configuration bindings consumed by ConfigEnabled. + DeclareConfig() []extpoints.ConfigBinding + + // ConfigEnabled reports whether this plugin should activate for the resolved values. + ConfigEnabled(view extpoints.ConfigView) bool +} diff --git a/docs/docs.go b/backend/docs/docs.go similarity index 99% rename from docs/docs.go rename to backend/docs/docs.go index ec7252b7..8bb8140c 100644 --- a/docs/docs.go +++ b/backend/docs/docs.go @@ -57,7 +57,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_internal_apps_cap.ChallengeResponse" + "$ref": "#/definitions/Wavelet_OpenFlare_plugins_server_cap.ChallengeResponse" } } } @@ -109,7 +109,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_internal_apps_cap.RedeemResponse" + "$ref": "#/definitions/Wavelet_OpenFlare_plugins_server_cap.RedeemResponse" } } } @@ -4522,7 +4522,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.PagesDeploymentHashResponse" + "$ref": "#/definitions/Wavelet_OpenFlare_share_protocol.PagesDeploymentHashResponse" } } } @@ -4626,7 +4626,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.PagesProjectLatestHashResponse" + "$ref": "#/definitions/Wavelet_OpenFlare_share_protocol.PagesProjectLatestHashResponse" } } } @@ -14260,7 +14260,7 @@ const docTemplate = `{ } }, "400": { - "description": "用户名或密码错误、帐号已禁用等", + "description": "用户名或密码错误", "schema": { "$ref": "#/definitions/response.Any" } @@ -14597,6 +14597,316 @@ const docTemplate = `{ } }, "definitions": { + "Wavelet_OpenFlare_plugins_server_cap.ChallengeResponse": { + "type": "object", + "properties": { + "challenge": { + "type": "object", + "properties": { + "c": { + "type": "integer" + }, + "d": { + "type": "integer" + }, + "s": { + "type": "integer" + } + } + }, + "expires": { + "description": "ms timestamp", + "type": "integer" + }, + "token": { + "type": "string" + } + } + }, + "Wavelet_OpenFlare_plugins_server_cap.RedeemResponse": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "expires": { + "type": "integer" + }, + "success": { + "type": "boolean" + }, + "token": { + "type": "string" + } + } + }, + "Wavelet_OpenFlare_plugins_server_model_analytics.ClickHouseOperationalStats": { + "type": "object", + "properties": { + "active_parts": { + "type": "integer" + }, + "async_insert_bytes": { + "type": "integer" + }, + "async_insert_queue": { + "type": "integer" + }, + "batch_writers": { + "description": "BatchWriters reports in-process queue depth/drops/flush errors for CH writers.", + "type": "array", + "items": { + "$ref": "#/definitions/analytics.BatchWriterStats" + } + }, + "database": { + "type": "string" + }, + "pending_mutations": { + "type": "integer" + }, + "total_rows": { + "type": "integer" + } + } + }, + "Wavelet_OpenFlare_share_protocol.ActiveConfigMeta": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "Wavelet_OpenFlare_share_protocol.BufferedObservabilityRecord": { + "type": "object", + "properties": { + "access_logs": { + "type": "array", + "items": { + "$ref": "#/definitions/Wavelet_OpenFlare_share_protocol.NodeAccessLog" + } + }, + "captured_at_unix": { + "type": "integer" + }, + "edge_health": { + "$ref": "#/definitions/Wavelet_OpenFlare_share_protocol.NodeEdgeHealth" + }, + "host_metrics": { + "$ref": "#/definitions/Wavelet_OpenFlare_share_protocol.NodeMetricSnapshot" + } + } + }, + "Wavelet_OpenFlare_share_protocol.NodeAccessLog": { + "type": "object", + "properties": { + "bytes_sent": { + "description": "body bytes = 已提供数据", + "type": "integer" + }, + "cache_status": { + "description": "$upstream_cache_status", + "type": "string" + }, + "host": { + "type": "string" + }, + "logged_at_unix": { + "type": "integer" + }, + "path": { + "type": "string" + }, + "remote_addr": { + "type": "string" + }, + "request_length": { + "description": "接收数据", + "type": "integer" + }, + "request_time_ms": { + "description": "optional", + "type": "integer" + }, + "status_code": { + "type": "integer" + }, + "user_agent": { + "type": "string" + } + } + }, + "Wavelet_OpenFlare_share_protocol.NodeEdgeHealth": { + "type": "object", + "properties": { + "captured_at_unix": { + "type": "integer" + }, + "connections": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "Wavelet_OpenFlare_share_protocol.NodeHealthEvent": { + "type": "object", + "properties": { + "event_type": { + "type": "string" + }, + "message": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "severity": { + "type": "string" + }, + "triggered_at_unix": { + "type": "integer" + } + } + }, + "Wavelet_OpenFlare_share_protocol.NodeMetricSnapshot": { + "type": "object", + "properties": { + "captured_at_unix": { + "type": "integer" + }, + "cpu_usage_percent": { + "type": "number" + }, + "disk_read_bytes": { + "type": "integer" + }, + "disk_write_bytes": { + "type": "integer" + }, + "memory_total_bytes": { + "type": "integer" + }, + "memory_used_bytes": { + "type": "integer" + }, + "storage_total_bytes": { + "type": "integer" + }, + "storage_used_bytes": { + "type": "integer" + } + } + }, + "Wavelet_OpenFlare_share_protocol.NodeSystemProfile": { + "type": "object", + "properties": { + "architecture": { + "type": "string" + }, + "cpu_cores": { + "type": "integer" + }, + "cpu_model": { + "type": "string" + }, + "hostname": { + "type": "string" + }, + "kernel_version": { + "type": "string" + }, + "os_name": { + "type": "string" + }, + "os_version": { + "type": "string" + }, + "reported_at_unix": { + "type": "integer" + }, + "total_disk_bytes": { + "type": "integer" + }, + "total_memory_bytes": { + "type": "integer" + }, + "uptime_seconds": { + "type": "integer" + } + } + }, + "Wavelet_OpenFlare_share_protocol.PagesDeploymentHashResponse": { + "type": "object", + "properties": { + "deployment_id": { + "type": "integer" + }, + "hash": { + "type": "string" + } + } + }, + "Wavelet_OpenFlare_share_protocol.PagesProjectLatestHashResponse": { + "type": "object", + "properties": { + "deployment_id": { + "type": "integer" + }, + "file_count": { + "type": "integer" + }, + "hash": { + "type": "string" + }, + "package_size": { + "type": "integer" + }, + "project_id": { + "type": "integer" + }, + "total_size": { + "type": "integer" + } + } + }, + "Wavelet_OpenFlare_share_protocol.WAFIPGroup": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "ip_list": { + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, "agent.ActiveConfigMeta": { "type": "object", "properties": { @@ -14686,20 +14996,20 @@ const docTemplate = `{ "access_logs": { "type": "array", "items": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeAccessLog" + "$ref": "#/definitions/Wavelet_OpenFlare_share_protocol.NodeAccessLog" } }, "buffered": { "type": "array", "items": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.BufferedObservabilityRecord" + "$ref": "#/definitions/Wavelet_OpenFlare_share_protocol.BufferedObservabilityRecord" } }, "current_version": { "type": "string" }, "edge_health": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeEdgeHealth" + "$ref": "#/definitions/Wavelet_OpenFlare_share_protocol.NodeEdgeHealth" }, "ext_version": { "type": "string" @@ -14707,11 +15017,11 @@ const docTemplate = `{ "health_events": { "type": "array", "items": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeHealthEvent" + "$ref": "#/definitions/Wavelet_OpenFlare_share_protocol.NodeHealthEvent" } }, "host_metrics": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeMetricSnapshot" + "$ref": "#/definitions/Wavelet_OpenFlare_share_protocol.NodeMetricSnapshot" }, "ip": { "type": "string" @@ -14732,7 +15042,7 @@ const docTemplate = `{ "type": "string" }, "profile": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeSystemProfile" + "$ref": "#/definitions/Wavelet_OpenFlare_share_protocol.NodeSystemProfile" }, "schema_version": { "type": "integer" @@ -14851,7 +15161,7 @@ const docTemplate = `{ "groups": { "type": "array", "items": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.WAFIPGroup" + "$ref": "#/definitions/Wavelet_OpenFlare_share_protocol.WAFIPGroup" } } } @@ -15676,7 +15986,7 @@ const docTemplate = `{ "type": "object", "properties": { "active_config": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.ActiveConfigMeta" + "$ref": "#/definitions/Wavelet_OpenFlare_share_protocol.ActiveConfigMeta" }, "tunnel_settings": { "$ref": "#/definitions/protocol.RelaySettings" @@ -15706,316 +16016,6 @@ const docTemplate = `{ } } }, - "github_com_Rain-kl_Wavelet_internal_apps_cap.ChallengeResponse": { - "type": "object", - "properties": { - "challenge": { - "type": "object", - "properties": { - "c": { - "type": "integer" - }, - "d": { - "type": "integer" - }, - "s": { - "type": "integer" - } - } - }, - "expires": { - "description": "ms timestamp", - "type": "integer" - }, - "token": { - "type": "string" - } - } - }, - "github_com_Rain-kl_Wavelet_internal_apps_cap.RedeemResponse": { - "type": "object", - "properties": { - "error": { - "type": "string" - }, - "expires": { - "type": "integer" - }, - "success": { - "type": "boolean" - }, - "token": { - "type": "string" - } - } - }, - "github_com_Rain-kl_Wavelet_internal_model_analytics.ClickHouseOperationalStats": { - "type": "object", - "properties": { - "active_parts": { - "type": "integer" - }, - "async_insert_bytes": { - "type": "integer" - }, - "async_insert_queue": { - "type": "integer" - }, - "batch_writers": { - "description": "BatchWriters reports in-process queue depth/drops/flush errors for CH writers.", - "type": "array", - "items": { - "$ref": "#/definitions/analytics.BatchWriterStats" - } - }, - "database": { - "type": "string" - }, - "pending_mutations": { - "type": "integer" - }, - "total_rows": { - "type": "integer" - } - } - }, - "github_com_Rain-kl_Wavelet_pkg_protocol.ActiveConfigMeta": { - "type": "object", - "properties": { - "checksum": { - "type": "string" - }, - "version": { - "type": "string" - } - } - }, - "github_com_Rain-kl_Wavelet_pkg_protocol.BufferedObservabilityRecord": { - "type": "object", - "properties": { - "access_logs": { - "type": "array", - "items": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeAccessLog" - } - }, - "captured_at_unix": { - "type": "integer" - }, - "edge_health": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeEdgeHealth" - }, - "host_metrics": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeMetricSnapshot" - } - } - }, - "github_com_Rain-kl_Wavelet_pkg_protocol.NodeAccessLog": { - "type": "object", - "properties": { - "bytes_sent": { - "description": "body bytes = 已提供数据", - "type": "integer" - }, - "cache_status": { - "description": "$upstream_cache_status", - "type": "string" - }, - "host": { - "type": "string" - }, - "logged_at_unix": { - "type": "integer" - }, - "path": { - "type": "string" - }, - "remote_addr": { - "type": "string" - }, - "request_length": { - "description": "接收数据", - "type": "integer" - }, - "request_time_ms": { - "description": "optional", - "type": "integer" - }, - "status_code": { - "type": "integer" - }, - "user_agent": { - "type": "string" - } - } - }, - "github_com_Rain-kl_Wavelet_pkg_protocol.NodeEdgeHealth": { - "type": "object", - "properties": { - "captured_at_unix": { - "type": "integer" - }, - "connections": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "status": { - "type": "string" - } - } - }, - "github_com_Rain-kl_Wavelet_pkg_protocol.NodeHealthEvent": { - "type": "object", - "properties": { - "event_type": { - "type": "string" - }, - "message": { - "type": "string" - }, - "metadata": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "severity": { - "type": "string" - }, - "triggered_at_unix": { - "type": "integer" - } - } - }, - "github_com_Rain-kl_Wavelet_pkg_protocol.NodeMetricSnapshot": { - "type": "object", - "properties": { - "captured_at_unix": { - "type": "integer" - }, - "cpu_usage_percent": { - "type": "number" - }, - "disk_read_bytes": { - "type": "integer" - }, - "disk_write_bytes": { - "type": "integer" - }, - "memory_total_bytes": { - "type": "integer" - }, - "memory_used_bytes": { - "type": "integer" - }, - "storage_total_bytes": { - "type": "integer" - }, - "storage_used_bytes": { - "type": "integer" - } - } - }, - "github_com_Rain-kl_Wavelet_pkg_protocol.NodeSystemProfile": { - "type": "object", - "properties": { - "architecture": { - "type": "string" - }, - "cpu_cores": { - "type": "integer" - }, - "cpu_model": { - "type": "string" - }, - "hostname": { - "type": "string" - }, - "kernel_version": { - "type": "string" - }, - "os_name": { - "type": "string" - }, - "os_version": { - "type": "string" - }, - "reported_at_unix": { - "type": "integer" - }, - "total_disk_bytes": { - "type": "integer" - }, - "total_memory_bytes": { - "type": "integer" - }, - "uptime_seconds": { - "type": "integer" - } - } - }, - "github_com_Rain-kl_Wavelet_pkg_protocol.PagesDeploymentHashResponse": { - "type": "object", - "properties": { - "deployment_id": { - "type": "integer" - }, - "hash": { - "type": "string" - } - } - }, - "github_com_Rain-kl_Wavelet_pkg_protocol.PagesProjectLatestHashResponse": { - "type": "object", - "properties": { - "deployment_id": { - "type": "integer" - }, - "file_count": { - "type": "integer" - }, - "hash": { - "type": "string" - }, - "package_size": { - "type": "integer" - }, - "project_id": { - "type": "integer" - }, - "total_size": { - "type": "integer" - } - } - }, - "github_com_Rain-kl_Wavelet_pkg_protocol.WAFIPGroup": { - "type": "object", - "properties": { - "checksum": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "id": { - "type": "integer" - }, - "ip_list": { - "type": "array", - "items": { - "type": "string" - } - }, - "name": { - "type": "string" - }, - "type": { - "type": "string" - } - } - }, "handler.batchDownloadRequest": { "type": "object", "required": [ @@ -19794,7 +19794,7 @@ const docTemplate = `{ } }, "clickhouse": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_internal_model_analytics.ClickHouseOperationalStats" + "$ref": "#/definitions/Wavelet_OpenFlare_plugins_server_model_analytics.ClickHouseOperationalStats" }, "migration": { "description": "idle | migrating", diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json new file mode 100644 index 00000000..00149de2 --- /dev/null +++ b/backend/docs/swagger.json @@ -0,0 +1,21125 @@ +{ + "swagger": "2.0", + "info": { + "description": "OpenFlare 平台后端 API,提供用户认证、系统配置、任务调度与边缘节点管理能力。", + "title": "OpenFlare API", + "contact": { + "name": "OpenFlare", + "url": "https://github.com/Rain-kl/OpenFlare" + }, + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + }, + "version": "1.0.0" + }, + "basePath": "/", + "paths": { + "/api/cap/challenge": { + "post": { + "description": "客户端获取 PoW 难题和签名的 JWT Token,并在后台计算。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "cap" + ], + "summary": "生成人机验证难题", + "parameters": [ + { + "description": "可选范围限制参数", + "name": "request", + "in": "body", + "schema": { + "$ref": "#/definitions/cap.challengeRequest" + } + } + ], + "responses": { + "200": { + "description": "成功返回 PoW 难题", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/Wavelet_OpenFlare_plugins_server_cap.ChallengeResponse" + } + } + } + ] + } + }, + "500": { + "description": "内部服务错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/cap/redeem": { + "post": { + "description": "提交 PoW 解答进行核销,成功后返回一次性 X-Cap-Token 凭证", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "cap" + ], + "summary": "校验人机验证解答", + "parameters": [ + { + "description": "难题 Token 与解答 solutions 数组", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/cap.redeemRequest" + } + } + ], + "responses": { + "200": { + "description": "核销成功,返回 X-Cap-Token", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/Wavelet_OpenFlare_plugins_server_cap.RedeemResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误或核销失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部服务错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/health": { + "get": { + "description": "检查服务是否正常运行,可用于负载均衡存活探测", + "produces": [ + "application/json" + ], + "tags": [ + "health" + ], + "summary": "健康检查", + "responses": { + "200": { + "description": "服务正常", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/api/v1/admin/auth-sources": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回所有已配置的 OAuth/OIDC 认证源列表,包括已启用和未启用的,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取认证源列表", + "responses": { + "200": { + "description": "认证源列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.AuthSource" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "创建一个新的 OAuth/OIDC 认证源配置,认证源名称必须唯一且符合命名规范,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "创建认证源", + "parameters": [ + { + "description": "创建认证源参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth_source.AuthSourceRequest" + } + } + ], + "responses": { + "200": { + "description": "创建成功,返回认证源信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.AuthSource" + } + } + } + ] + } + }, + "400": { + "description": "参数错误或验证失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/auth-sources/{id}": { + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "更新指定 ID 的认证源配置。若 client_secret 字段为空,则保留原有密钥不变,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "更新认证源", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "认证源 ID 或名称", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "更新认证源参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth_source.AuthSourceRequest" + } + } + ], + "responses": { + "200": { + "description": "更新成功,返回更新后的认证源信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.AuthSource" + } + } + } + ] + } + }, + "400": { + "description": "参数错误或验证失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "删除指定认证源及其关联的所有外部帐号绑定记录,警告:删除后相关用户将无法通过该源登录,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "删除认证源", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "认证源 ID 或名称", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "ID 无效或删除失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/auth-sources/{id}/toggle": { + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "启用或禁用指定认证源。尝试启用时将验证 Client ID 和 Client Secret 是否已配置,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "切换认证源启用状态", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "认证源 ID 或名称", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "启用状态", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth_source.ToggleAuthSourceRequest" + } + } + ], + "responses": { + "200": { + "description": "切换成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "验证失败或认证源不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/cache/clear": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "清除系统磁盘缓存目录中的所有临时文件,并重置缓存容量和 Key 追踪数据", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "清空缓存", + "responses": { + "200": { + "description": "清理成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "服务内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/cache/config": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "更改磁盘缓存最大容量限制、文件生存时间(TTL)以及是否启用 LRU 淘汰淘汰算法,并进行热更新", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "更新缓存配置", + "parameters": [ + { + "description": "缓存配置请求体", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/cache.updateCacheConfigRequest" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "服务内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/cache/status": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "获取当前系统磁盘缓存的使用情况(已占用字节、Key 数量等)与策略配置", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取缓存状态", + "responses": { + "200": { + "description": "获取成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/diskcache.Status" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/db-export": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "SQLite 时直接下载 .db 文件;PostgreSQL 时执行 pg_dump 并流式下载 .sql 文件,需要管理员权限", + "produces": [ + "application/octet-stream" + ], + "tags": [ + "admin" + ], + "summary": "导出数据库", + "responses": { + "200": { + "description": "数据库文件", + "schema": { + "type": "file" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "导出失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/db-info": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前使用的数据库类型(sqlite/postgres)、名称/路径及版本字符串,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取数据库信息", + "responses": { + "200": { + "description": "获取成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/status.DatabaseInfoResponse" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/db-manage/overview": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "获取数据库类型、版本、名称、文件大小、表数量及当前连接数,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取数据库运行概览", + "responses": { + "200": { + "description": "获取成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/db_manage.DBOverviewResponse" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/db-manage/query": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "在当前数据库中执行任意自定义 SQL,如果是查询语句将返回格式化后的列与数据集,否则返回受影响行数,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "执行 SQL 查询", + "parameters": [ + { + "description": "SQL 请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/db_manage.ExecuteSQLRequest" + } + } + ], + "responses": { + "200": { + "description": "执行完毕", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/db_manage.ExecuteSQLResponse" + } + } + } + ] + } + }, + "400": { + "description": "SQL 语句错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/db-manage/tables": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前数据库的所有用户自定义表名称列表,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取数据库所有表名", + "responses": { + "200": { + "description": "获取成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/logs": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "分页获取系统历史日志,cursor=0 获取最新日志,cursor\u003e0 获取更早日志", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取系统日志", + "parameters": [ + { + "type": "integer", + "default": 0, + "description": "日志游标,0=获取最新", + "name": "cursor", + "in": "query" + }, + { + "type": "integer", + "default": 200, + "description": "每页条数", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "日志列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/logs.logsResponse" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/logs/access": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "分页并按照用户、接口路径、时间范围等维度检索用户访问日志列表(需要管理员权限,日志存储未启用时报错)", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取用户访问日志", + "parameters": [ + { + "type": "integer", + "default": 1, + "description": "页码", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "default": 20, + "description": "每页条数", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "用户名模糊搜索", + "name": "username", + "in": "query" + }, + { + "type": "string", + "description": "接口路径模糊搜索", + "name": "path", + "in": "query" + }, + { + "type": "string", + "description": "起始时间(RFC3339 或 YYYY-MM-DD HH:MM:SS)", + "name": "start_time", + "in": "query" + }, + { + "type": "string", + "description": "结束时间(RFC3339 或 YYYY-MM-DD HH:MM:SS)", + "name": "end_time", + "in": "query" + } + ], + "responses": { + "200": { + "description": "访问日志列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/logs.accessLogsResponse" + } + } + } + ] + } + }, + "400": { + "description": "日志存储未启用或参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/logs/analytics": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "聚合统计最近 7 天的每日访问趋势、浏览器分布以及前 10 名最活跃用户排行(需要管理员权限,日志存储未启用时报错)", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取访问日志分析数据", + "responses": { + "200": { + "description": "分析统计数据", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/logs.logsAnalyticsResponse" + } + } + } + ] + } + }, + "400": { + "description": "日志存储未启用", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/logs/ws": { + "get": { + "description": "通过 WebSocket 实时推送系统日志,需要管理员权限", + "tags": [ + "admin" + ], + "summary": "系统日志实时推送", + "responses": {} + } + }, + "/api/v1/admin/push/channels": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回系统配置的所有消息通道列表,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "获取所有消息通道", + "responses": { + "200": { + "description": "消息通道列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.PushChannel" + } + } + } + } + ] + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "新建一个消息通道配置,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "创建消息通道", + "parameters": [ + { + "description": "创建参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/push.CreateChannelRequest" + } + } + ], + "responses": { + "200": { + "description": "创建成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.PushChannel" + } + } + } + ] + } + } + } + } + }, + "/api/v1/admin/push/channels/definitions": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回系统支持的所有消息通道类型(如飞书、邮件、自定义、Telegram)的动态表单定义,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "获取所有消息通道配置字段定义", + "responses": { + "200": { + "description": "通道配置定义列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/push.Definition" + } + } + } + } + ] + } + } + } + } + }, + "/api/v1/admin/push/channels/test": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "触发一次临时的或现有的通道连通性推送测试,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "测试通道连通性", + "parameters": [ + { + "description": "测试参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/push.TestChannelRequest" + } + } + ], + "responses": { + "200": { + "description": "测试触发成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/push/channels/{id}": { + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "修改消息通道配置,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "更新消息通道", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "通道ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "更新参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/push.UpdateChannelRequest" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.PushChannel" + } + } + } + ] + } + } + } + }, + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "根据ID删除消息通道,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "删除消息通道", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "通道ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/push/events": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回系统配置的通知事件列表,包括预置和自定义事件,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "获取所有通知事件", + "responses": { + "200": { + "description": "通知事件列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.PushEvent" + } + } + } + } + ] + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "绑定系统内置事件或异步任务、推送渠道、接收目标并创建通知事件配置,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "创建通知事件", + "parameters": [ + { + "description": "创建参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/push.CreateEventRequest" + } + } + ], + "responses": { + "200": { + "description": "创建成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.PushEvent" + } + } + } + ] + } + } + } + } + }, + "/api/v1/admin/push/events/builtin": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回系统定义的所有内置通知事件元数据,供前端下拉框选择,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "获取所有内置通知事件", + "responses": { + "200": { + "description": "内置通知事件列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/push.EventMetadata" + } + } + } + } + ] + } + } + } + } + }, + "/api/v1/admin/push/events/{id}": { + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "更新已有通知事件的推送渠道、接收目标和内容模板,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "更新通知事件", + "parameters": [ + { + "type": "integer", + "description": "事件 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "更新参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/push.UpdateEventRequest" + } + } + ], + "responses": { + "200": { + "description": "修改成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + } + } + }, + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "删除数据库中的特定通知事件配置,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "删除通知事件配置", + "parameters": [ + { + "type": "integer", + "description": "事件 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/api/v1/admin/push/events/{id}/toggle": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "启用或禁用指定的通知事件", + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "快捷切换通知事件启用状态", + "parameters": [ + { + "type": "integer", + "description": "事件 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "切换成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/api/v1/admin/push/histories": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回分页的通知历史日志数据,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "分页获取通知推送历史", + "parameters": [ + { + "type": "integer", + "description": "当前页码", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "分页大小", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "过滤事件名称", + "name": "event_key", + "in": "query" + }, + { + "type": "string", + "description": "过滤发送状态", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "推送历史列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/push.pushHistoriesResponse" + } + } + } + ] + } + } + } + } + }, + "/api/v1/admin/push/test": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "接收临时通知渠道配置并在本地同步调用 Pusher.Send 发送测试消息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "测试推送通道发送", + "parameters": [ + { + "description": "测试请求体", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/push.TestPushRequest" + } + } + ], + "responses": { + "200": { + "description": "测试成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/api/v1/admin/status": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "获取后端服务运行状态、Goroutine、内存指标等详细统计数据,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取系统状态信息", + "responses": { + "200": { + "description": "获取成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/status.SystemStatusResponse" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/status/log-database": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前日志主库、迁移状态、各库保留天数与合法迁移目标,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取日志数据库状态", + "responses": { + "200": { + "description": "获取成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/status.LogDatabaseStatus" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/system-configs": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回所有系统配置列表,支持按配置类型(system/business)过滤,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取系统配置列表", + "parameters": [ + { + "type": "string", + "description": "配置类型(system/business)", + "name": "type", + "in": "query" + } + ], + "responses": { + "200": { + "description": "系统配置列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.SystemConfig" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "创建一条新的系统配置项,配置键不可重复,同时将新配置同步到 Redis,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "创建系统配置", + "parameters": [ + { + "description": "创建请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/system_config.CreateSystemConfigRequest" + } + } + ], + "responses": { + "200": { + "description": "创建成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误或配置键已存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/system-configs/smtp/test": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "使用传入的配置进行 SMTP 邮件发送测试,支持使用 ****** 占位符使用保存的数据库密码", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "测试 SMTP 邮件发送", + "parameters": [ + { + "description": "测试请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/system_config.TestSMTPRequest" + } + } + ], + "responses": { + "200": { + "description": "测试执行完毕", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/system_config.TestSMTPResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/system-configs/{key}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "根据配置键获取对应的系统配置详情,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取单个系统配置", + "parameters": [ + { + "type": "string", + "description": "配置键", + "name": "key", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "系统配置详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.SystemConfig" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "配置不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "根据配置键更新对应的配置内容,同时将更新同步到 Redis,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "更新系统配置", + "parameters": [ + { + "type": "string", + "description": "配置键", + "name": "key", + "in": "path", + "required": true + }, + { + "description": "更新请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/system_config.UpdateSystemConfigRequest" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "配置不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/tasks/dispatch": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "手动触发指定类型的异步任务,支持指定时间范围和用户,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "下发异步任务", + "parameters": [ + { + "description": "任务请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/task.DispatchTaskRequest" + } + } + ], + "responses": { + "200": { + "description": "任务已入队", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "任务类型不存在或参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "任务入队失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/tasks/executions": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "分页查询任务执行记录,支持按状态和任务类型筛选,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "查询任务执行记录", + "parameters": [ + { + "type": "string", + "description": "状态筛选 (pending/running/succeeded/failed)", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "任务类型筛选", + "name": "task_type", + "in": "query" + }, + { + "type": "string", + "description": "任务类型前缀筛选(与 task_type / task_types 互斥,精确类型优先)", + "name": "task_type_prefix", + "in": "query" + }, + { + "type": "string", + "description": "逗号分隔的精确任务类型列表(IN 筛选,优先于前缀)", + "name": "task_types", + "in": "query" + }, + { + "type": "integer", + "default": 1, + "description": "页码", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "default": 20, + "description": "每页条数", + "name": "page_size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "任务执行记录列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/tasks/executions/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "根据 ID 查询任务执行记录详情,包含完整执行日志,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "查询任务执行详情", + "parameters": [ + { + "type": "integer", + "description": "任务执行记录 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "任务执行详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TaskExecution" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/tasks/executions/{id}/retry": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "重新下发一条失败的任务,创建新的执行记录,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "重试失败任务", + "parameters": [ + { + "type": "integer", + "description": "任务执行记录 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "新任务的 TaskID", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "任务不支持重试或参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "重试失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/tasks/schedules": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回管理员可管理的定时任务配置列表,包括名称、关联的异步任务类型、Cron 表达式和启用状态;系统内部排程不会暴露,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取定时任务列表", + "responses": { + "200": { + "description": "定时任务列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.Schedule" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "新增一个动态定时任务配置,关联已有的异步任务,配置 Cron 表达式和执行参数,并触发调度器热加载,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "创建定时任务", + "parameters": [ + { + "description": "创建定时任务请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/task.CreateScheduleRequest" + } + } + ], + "responses": { + "200": { + "description": "创建成功的定时任务信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.Schedule" + } + } + } + ] + } + }, + "400": { + "description": "Cron 表达式无效、异步任务类型不存在或参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "保存定时任务失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/tasks/schedules/{id}": { + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "修改一个定时任务的配置(名称、Cron 表达式、异步任务参数和是否启用等),并触发调度器热加载,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "修改定时任务", + "parameters": [ + { + "type": "integer", + "description": "定时任务 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "修改定时任务请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/task.UpdateScheduleRequest" + } + } + ], + "responses": { + "200": { + "description": "修改后的定时任务信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.Schedule" + } + } + } + ] + } + }, + "400": { + "description": "Cron 表达式无效、参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "定时任务不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "修改定时任务失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "删除指定的定时任务配置,并触发调度器热加载,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "删除定时任务", + "parameters": [ + { + "type": "integer", + "description": "定时任务 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除结果", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "定时任务不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "删除定时任务失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/tasks/types": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回系统支持的所有可调度任务类型列表,包括任务名称、描述、是否支持时间范围等元数据,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取支持的任务类型", + "responses": { + "200": { + "description": "任务类型列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/task.TaskMeta" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/templates": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回所有通知模板列表,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取模板列表", + "responses": { + "200": { + "description": "模板列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.Template" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "创建一条新的自定义通知模板,模板标识符(Key)不可重复,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "创建模板", + "parameters": [ + { + "description": "创建请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/template.CreateTemplateRequest" + } + } + ], + "responses": { + "200": { + "description": "创建成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误或模板标识符已存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/templates/{key}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "根据模板标识符获取对应的模板详情,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取单个模板", + "parameters": [ + { + "type": "string", + "description": "模板标识符", + "name": "key", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "模板详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.Template" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "模板不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "根据模板标识符更新对应的模板内容,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "更新模板", + "parameters": [ + { + "type": "string", + "description": "模板标识符", + "name": "key", + "in": "path", + "required": true + }, + { + "description": "更新请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/template.UpdateTemplateRequest" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.Template" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "模板不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "根据模板标识符删除对应模板,系统预置模板不可删除,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "删除模板", + "parameters": [ + { + "type": "string", + "description": "模板标识符", + "name": "key", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "不可删除系统模板", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "模板不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/update": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "从系统配置指定的 GitHub 上游仓库查询最新兼容 Release,并与当前服务版本比较", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取应用更新状态", + "responses": { + "200": { + "description": "更新状态", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/updater.Status" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "查询失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/update/apply": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "下载当前平台对应的 GitHub Actions Release 资产,替换当前二进制并重启进程", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "下载并应用应用更新", + "responses": { + "200": { + "description": "升级已准备并即将重启", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "当前版本不可升级", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "升级准备失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/uploads": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "分页获取系统上传的文件列表,支持文件名关键词、业务类型、扩展名、上传用户ID过滤", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取文件列表", + "parameters": [ + { + "type": "integer", + "description": "页码(默认 1)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量(默认 20,最大 100)", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "文件名关键词(模糊匹配)", + "name": "keyword", + "in": "query" + }, + { + "type": "string", + "description": "业务分类过滤", + "name": "type", + "in": "query" + }, + { + "type": "string", + "description": "扩展名过滤", + "name": "extension", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "description": "上传用户 ID", + "name": "user_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "查询成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/handler.listFilesResponse" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/uploads/download/batch": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "传入多个文件 ID,后台实时将其打包压缩为 ZIP 流并输出,自动处理文件名重复冲突", + "consumes": [ + "application/json" + ], + "produces": [ + "application/octet-stream" + ], + "tags": [ + "admin" + ], + "summary": "批量打包下载", + "parameters": [ + { + "description": "包含文件 ID 数组 of string 的请求体", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.batchDownloadRequest" + } + } + ], + "responses": { + "200": { + "description": "成功下载打包后的 ZIP", + "schema": { + "type": "file" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "打包失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/uploads/download/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "根据文件 ID 获取文件,以附件形式 (Attachment) 强制开启客户端浏览器下载", + "produces": [ + "application/octet-stream" + ], + "tags": [ + "admin" + ], + "summary": "下载单文件", + "parameters": [ + { + "type": "string", + "description": "文件 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "图片质量 (low, medium, high, origin),默认为 origin", + "name": "quality", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功下载文件", + "schema": { + "type": "file" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "文件不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "服务内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/uploads/stats": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回系统级的总文件数、占用大小、最近 7 天新增趋势、文件类型/格式分布等数据", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取文件统计数据", + "responses": { + "200": { + "description": "获取成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/handler.fileStatsResponse" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/uploads/types": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回数据库中所有已上传文件实际拥有的业务类型列表", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取文件业务类型列表", + "responses": { + "200": { + "description": "业务类型列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/uploads/{id}": { + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "将文件状态置为 deleted(软删除),不会立即清理底层存储对象", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "删除文件", + "parameters": [ + { + "type": "string", + "description": "文件 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无权操作", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "文件不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "409": { + "description": "系统保留类型或存储只读", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/users": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "分页返回用户列表,支持按用户 ID 和用户名筛选,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取用户列表", + "parameters": [ + { + "type": "string", + "name": "email", + "in": "query" + }, + { + "minimum": 1, + "type": "integer", + "name": "page", + "in": "query" + }, + { + "maximum": 100, + "minimum": 1, + "type": "integer", + "name": "page_size", + "in": "query" + }, + { + "type": "integer", + "name": "user_id", + "in": "query" + }, + { + "type": "string", + "name": "username", + "in": "query" + } + ], + "responses": { + "200": { + "description": "用户列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/user.listUsersResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "创建一个本地密码登录的新用户,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "创建用户", + "parameters": [ + { + "description": "创建用户参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.createUserRequest" + } + } + ], + "responses": { + "200": { + "description": "创建成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/user.user" + } + } + } + ] + } + }, + "400": { + "description": "参数错误或用户名已存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/users/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回指定用户的完整个人资料和系统状态,需要管理员权限,不返回密码等敏感字段", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取用户详情", + "parameters": [ + { + "type": "integer", + "description": "用户 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "用户详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/user.user" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "用户不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "更新指定用户的昵称、邮箱、管理员权限,并可选重置密码,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "更新用户信息", + "parameters": [ + { + "type": "integer", + "description": "用户 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "更新参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.updateUserRequest" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限或尝试修改自身权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "用户不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "删除指定非管理员用户,需要管理员权限,不能删除当前登录用户", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "删除用户", + "parameters": [ + { + "type": "integer", + "description": "用户 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限、尝试删除管理员或当前用户", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "用户不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/users/{id}/status": { + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "启用或禁用指定用户,管理员账号无法被禁用,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "更新用户状态", + "parameters": [ + { + "type": "integer", + "description": "用户 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "状态参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.updateUserStatusRequest" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限或尝试禁用管理员", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "用户不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/agent/apply-logs": { + "post": { + "security": [ + { + "AgentTokenAuth": [] + } + ], + "description": "记录 Agent 配置下发与应用结果", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-agent" + ], + "summary": "上报配置应用日志", + "parameters": [ + { + "description": "应用日志", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/agent.ApplyLogPayload" + } + } + ], + "responses": { + "200": { + "description": "日志记录", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.OpenFlareApplyLog" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/agent/config-versions/active": { + "get": { + "security": [ + { + "AgentTokenAuth": [] + } + ], + "description": "返回当前生效的完整配置包,供 Agent 拉取并应用", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-agent" + ], + "summary": "获取活跃配置版本", + "responses": { + "200": { + "description": "活跃配置", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/agent.ConfigResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/agent/nodes/heartbeat": { + "post": { + "security": [ + { + "AgentTokenAuth": [] + } + ], + "description": "上报节点状态、指标与健康事件,返回远程控制配置与活跃配置元信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-agent" + ], + "summary": "Agent 心跳上报", + "parameters": [ + { + "description": "心跳数据", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/agent.NodePayload" + } + } + ], + "responses": { + "200": { + "description": "心跳成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/agent.HeartbeatResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/agent/nodes/register": { + "post": { + "security": [ + { + "AgentTokenAuth": [] + } + ], + "description": "使用节点 access token 重新注册,或使用全局 discovery token 发现新节点;请求头需携带 X-Agent-Token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-agent" + ], + "summary": "注册或发现 Agent 节点", + "parameters": [ + { + "description": "节点上报数据", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/agent.NodePayload" + } + } + ], + "responses": { + "200": { + "description": "注册成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/agent.RegistrationResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/agent/pages/deployments/{deployment_id}/hash": { + "get": { + "security": [ + { + "AgentTokenAuth": [] + } + ], + "description": "返回 upload 框架记录的 SHA-256 哈希,供 Agent 对比本地缓存并按需拉取部署包(兼容旧路径)", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-agent" + ], + "summary": "查询 Pages 部署包哈希", + "parameters": [ + { + "type": "integer", + "description": "部署 ID", + "name": "deployment_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/Wavelet_OpenFlare_share_protocol.PagesDeploymentHashResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/agent/pages/deployments/{deployment_id}/package": { + "get": { + "security": [ + { + "AgentTokenAuth": [] + } + ], + "description": "流式下载指定部署的静态资源压缩包,供 Agent 边缘分发(兼容旧路径)", + "produces": [ + "application/octet-stream" + ], + "tags": [ + "openflare-agent" + ], + "summary": "下载 Pages 部署包", + "parameters": [ + { + "type": "integer", + "description": "部署 ID", + "name": "deployment_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "部署包文件", + "schema": { + "type": "file" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/agent/pages/projects/{project_id}/latest/hash": { + "get": { + "security": [ + { + "AgentTokenAuth": [] + } + ], + "description": "按项目 ID 返回当前激活部署的包哈希(类似 latest 指针),Agent 无需关心具体部署 ID", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-agent" + ], + "summary": "查询 Pages 项目最新激活部署哈希", + "parameters": [ + { + "type": "integer", + "description": "Pages 项目 ID", + "name": "project_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/Wavelet_OpenFlare_share_protocol.PagesProjectLatestHashResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/agent/pages/projects/{project_id}/latest/package": { + "get": { + "security": [ + { + "AgentTokenAuth": [] + } + ], + "description": "按项目 ID 下载当前激活部署的压缩包,供 Agent 边缘分发", + "produces": [ + "application/octet-stream" + ], + "tags": [ + "openflare-agent" + ], + "summary": "下载 Pages 项目最新激活部署包", + "parameters": [ + { + "type": "integer", + "description": "Pages 项目 ID", + "name": "project_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "部署包文件", + "schema": { + "type": "file" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/agent/waf/ip-groups/sync": { + "post": { + "security": [ + { + "AgentTokenAuth": [] + } + ], + "description": "按 ID 与校验和增量同步 WAF IP 组定义", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-agent" + ], + "summary": "同步 WAF IP 组", + "parameters": [ + { + "description": "同步请求", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/agent.WAFIPGroupSyncInput" + } + } + ], + "responses": { + "200": { + "description": "同步结果", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/agent.WAFIPGroupSyncResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/agent/ws": { + "get": { + "security": [ + { + "AgentTokenAuth": [] + } + ], + "description": "升级为 WebSocket 长连接,用于实时推送配置同步、WAF IP 组等指令;需携带 X-Agent-Token", + "tags": [ + "openflare-agent" + ], + "summary": "Agent WebSocket 连接", + "responses": { + "401": { + "description": "Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/config/public": { + "get": { + "description": "返回系统配置表中 visibility 为 1 的配置键值集合", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "config" + ], + "summary": "获取公共配置", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/access-logs": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "分页返回 OpenFlare 访问日志,支持按节点、IP、主机、路径与状态码筛选,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-observability" + ], + "summary": "列出访问日志", + "parameters": [ + { + "type": "string", + "description": "节点 ID", + "name": "node_id", + "in": "query" + }, + { + "type": "string", + "description": "客户端 IP", + "name": "remote_addr", + "in": "query" + }, + { + "type": "string", + "description": "请求 Host", + "name": "host", + "in": "query" + }, + { + "type": "string", + "description": "请求路径", + "name": "path", + "in": "query" + }, + { + "type": "integer", + "description": "HTTP 状态码(100-599)", + "name": "status_code", + "in": "query" + }, + { + "type": "string", + "description": "起始时间(RFC3339,需与 until 成对提供)", + "name": "since", + "in": "query" + }, + { + "type": "string", + "description": "结束时间(RFC3339,需与 since 成对提供)", + "name": "until", + "in": "query" + }, + { + "type": "integer", + "description": "页码", + "name": "p", + "in": "query" + }, + { + "type": "integer", + "description": "每页条数", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "排序字段", + "name": "sort_by", + "in": "query" + }, + { + "type": "string", + "description": "排序方向", + "name": "sort_order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "访问日志列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/observability.AccessLogList" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/access-logs/cleanup": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按保留天数清理过期访问日志记录,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-observability" + ], + "summary": "清理访问日志", + "parameters": [ + { + "description": "清理参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/observability.AccessLogCleanupInput" + } + } + ], + "responses": { + "200": { + "description": "清理结果", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/observability.AccessLogCleanupResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/access-logs/folds": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按时间桶聚合访问日志并分页返回,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-observability" + ], + "summary": "列出折叠访问日志", + "parameters": [ + { + "type": "string", + "description": "节点 ID", + "name": "node_id", + "in": "query" + }, + { + "type": "string", + "description": "客户端 IP", + "name": "remote_addr", + "in": "query" + }, + { + "type": "string", + "description": "请求 Host", + "name": "host", + "in": "query" + }, + { + "type": "string", + "description": "请求路径", + "name": "path", + "in": "query" + }, + { + "type": "integer", + "description": "折叠时间窗口(分钟)", + "name": "fold_minutes", + "in": "query" + }, + { + "type": "integer", + "description": "页码", + "name": "p", + "in": "query" + }, + { + "type": "integer", + "description": "每页条数", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "排序字段", + "name": "sort_by", + "in": "query" + }, + { + "type": "string", + "description": "排序方向", + "name": "sort_order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "折叠访问日志列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/observability.FoldedAccessLogList" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/access-logs/folds/ip-summary": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "在指定时间桶内按 IP 聚合访问统计,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-observability" + ], + "summary": "列出折叠访问日志 IP 汇总", + "parameters": [ + { + "type": "string", + "description": "节点 ID", + "name": "node_id", + "in": "query" + }, + { + "type": "string", + "description": "客户端 IP", + "name": "remote_addr", + "in": "query" + }, + { + "type": "string", + "description": "请求 Host", + "name": "host", + "in": "query" + }, + { + "type": "string", + "description": "请求路径", + "name": "path", + "in": "query" + }, + { + "type": "string", + "description": "时间桶起始时间", + "name": "bucket_started_at", + "in": "query" + }, + { + "type": "integer", + "description": "折叠时间窗口(分钟)", + "name": "fold_minutes", + "in": "query" + }, + { + "type": "integer", + "description": "页码", + "name": "p", + "in": "query" + }, + { + "type": "integer", + "description": "每页条数", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "排序字段", + "name": "sort_by", + "in": "query" + }, + { + "type": "string", + "description": "排序方向", + "name": "sort_order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "折叠 IP 汇总列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/observability.FoldedAccessLogIPList" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/access-logs/ip-summary": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 IP 聚合访问日志统计并分页返回;支持 hours 或 since/until 时间窗,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-observability" + ], + "summary": "列出访问日志 IP 汇总", + "parameters": [ + { + "type": "string", + "description": "节点 ID", + "name": "node_id", + "in": "query" + }, + { + "type": "string", + "description": "客户端 IP", + "name": "remote_addr", + "in": "query" + }, + { + "type": "string", + "description": "请求 Host", + "name": "host", + "in": "query" + }, + { + "type": "integer", + "description": "统计时间范围(小时,1-720,默认 168)", + "name": "hours", + "in": "query" + }, + { + "type": "string", + "description": "开始时间 RFC3339(与 until 同时提供时优先于 hours)", + "name": "since", + "in": "query" + }, + { + "type": "string", + "description": "结束时间 RFC3339", + "name": "until", + "in": "query" + }, + { + "type": "integer", + "description": "页码", + "name": "p", + "in": "query" + }, + { + "type": "integer", + "description": "每页条数", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "排序字段 total_requests|request_length|bytes_sent|success_ratio|last_seen_at|remote_addr", + "name": "sort_by", + "in": "query" + }, + { + "type": "string", + "description": "排序方向", + "name": "sort_order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "IP 汇总列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/observability.AccessLogIPSummaryList" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/access-logs/ip-summary/analysis": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回指定 IP 的汇总指标与 Top 分布,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-observability" + ], + "summary": "获取访问日志 IP 分析", + "parameters": [ + { + "type": "string", + "description": "节点 ID", + "name": "node_id", + "in": "query" + }, + { + "type": "string", + "description": "客户端 IP", + "name": "remote_addr", + "in": "query" + }, + { + "type": "string", + "description": "请求 Host", + "name": "host", + "in": "query" + }, + { + "type": "integer", + "description": "统计时间范围(小时)", + "name": "hours", + "in": "query" + } + ], + "responses": { + "200": { + "description": "IP 访问分析", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/observability.AccessLogIPAnalysisView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/access-logs/ip-summary/trend": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回指定 IP 在时间范围内的访问趋势数据,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-observability" + ], + "summary": "获取访问日志 IP 趋势", + "parameters": [ + { + "type": "string", + "description": "节点 ID", + "name": "node_id", + "in": "query" + }, + { + "type": "string", + "description": "客户端 IP", + "name": "remote_addr", + "in": "query" + }, + { + "type": "string", + "description": "请求 Host", + "name": "host", + "in": "query" + }, + { + "type": "integer", + "description": "统计时间范围(小时)", + "name": "hours", + "in": "query" + }, + { + "type": "integer", + "description": "时间桶粒度(分钟)", + "name": "bucket_minutes", + "in": "query" + } + ], + "responses": { + "200": { + "description": "IP 访问趋势", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/observability.AccessLogIPTrendView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/access-logs/overview": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回访问日志汇总指标、趋势与 Top 排行,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-observability" + ], + "summary": "获取访问日志概览", + "parameters": [ + { + "type": "string", + "description": "节点 ID", + "name": "node_id", + "in": "query" + }, + { + "type": "string", + "description": "请求 Host(单域名)", + "name": "host", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "description": "请求 Host 列表(多域名精确匹配)", + "name": "hosts", + "in": "query" + }, + { + "type": "integer", + "description": "统计时间范围(小时)", + "name": "hours", + "in": "query" + }, + { + "type": "integer", + "description": "趋势桶分钟数(1、3、5 或 60,默认 60)", + "name": "bucket_minutes", + "in": "query" + } + ], + "responses": { + "200": { + "description": "访问日志概览", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/observability.AccessLogOverview" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/acme-accounts/default": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回系统默认 ACME 账号配置,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "获取默认 ACME 账号", + "responses": { + "200": { + "description": "默认 ACME 账号", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.AcmeAccount" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/apply-logs": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "分页返回节点配置下发记录,支持按节点 ID 筛选,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-apply-log" + ], + "summary": "获取配置下发日志", + "parameters": [ + { + "type": "string", + "description": "节点 ID 筛选", + "name": "node_id", + "in": "query" + }, + { + "type": "integer", + "description": "页码", + "name": "pageNo", + "in": "query" + }, + { + "type": "integer", + "description": "页码(别名)", + "name": "page_no", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量", + "name": "pageSize", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量(别名)", + "name": "page_size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "下发日志列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/apply_log.ListResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/apply-logs/cleanup": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按保留天数清理历史下发记录,或删除全部记录,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-apply-log" + ], + "summary": "清理配置下发日志", + "parameters": [ + { + "description": "清理参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/apply_log.CleanupInput" + } + } + ], + "responses": { + "200": { + "description": "清理结果", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/apply_log.CleanupResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/cloudflare/connection": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "获取 Cloudflare 连接", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/cloudflare.ConnectionView" + } + } + } + ] + } + } + } + }, + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "保存 Cloudflare 连接", + "parameters": [ + { + "description": "连接参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/cloudflare.ConnectionInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/cloudflare.ConnectionView" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/cloudflare/connection/clear": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "清除 Cloudflare 连接", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/cloudflare/connection/verify": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "测试 Cloudflare 连接", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/cloudflare.ConnectionView" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/cloudflare/domains/available": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "获取可加入 Cloudflare 指向的域名", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/cloudflare.AvailableDomain" + } + } + } + } + ] + } + } + } + } + }, + "/api/v1/d/cloudflare/groups": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "获取 Cloudflare 指向分组", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/cloudflare.GroupItem" + } + } + } + } + ] + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "创建 Cloudflare 指向分组", + "parameters": [ + { + "description": "分组参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/cloudflare.GroupInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/cloudflare.GroupItem" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/cloudflare/groups/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "获取 Cloudflare 指向分组详情", + "parameters": [ + { + "type": "integer", + "description": "分组 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/cloudflare.GroupDetail" + } + } + } + ] + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/cloudflare/groups/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "删除 Cloudflare 指向分组", + "parameters": [ + { + "type": "integer", + "description": "分组 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/cloudflare/groups/{id}/members": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "获取 Cloudflare 指向分组成员", + "parameters": [ + { + "type": "integer", + "description": "分组 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/cloudflare.MemberItem" + } + } + } + } + ] + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "添加 Cloudflare 指向成员", + "parameters": [ + { + "type": "integer", + "description": "分组 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "成员参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/cloudflare.MemberCreateInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/cloudflare.MemberItem" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/cloudflare/groups/{id}/members/{memberId}/remove": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "移出 Cloudflare 指向成员", + "parameters": [ + { + "type": "integer", + "description": "分组 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "成员 ID", + "name": "memberId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/cloudflare/groups/{id}/members/{memberId}/sync": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "同步 Cloudflare 指向成员", + "parameters": [ + { + "type": "integer", + "description": "分组 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "成员 ID", + "name": "memberId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/cloudflare.SyncReceipt" + } + } + } + ] + } + } + } + } + }, + "/api/v1/d/cloudflare/groups/{id}/members/{memberId}/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "更新 Cloudflare 指向成员", + "parameters": [ + { + "type": "integer", + "description": "分组 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "成员 ID", + "name": "memberId", + "in": "path", + "required": true + }, + { + "description": "成员参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/cloudflare.MemberUpdateInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/cloudflare.MemberItem" + } + } + } + ] + } + } + } + } + }, + "/api/v1/d/cloudflare/groups/{id}/sync": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "同步 Cloudflare 指向分组", + "parameters": [ + { + "type": "integer", + "description": "分组 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/cloudflare.SyncReceipt" + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/cloudflare/groups/{id}/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "更新 Cloudflare 指向分组", + "parameters": [ + { + "type": "integer", + "description": "分组 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "分组参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/cloudflare.GroupInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/cloudflare.GroupItem" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/cloudflare/overview": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-cloudflare" + ], + "summary": "获取 Cloudflare 指向总览", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/cloudflare.Overview" + } + } + } + ] + } + } + } + } + }, + "/api/v1/d/config-versions": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回所有已发布的 OpenResty 配置版本摘要,按创建时间倒序排列,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-config-version" + ], + "summary": "获取配置版本列表", + "responses": { + "200": { + "description": "配置版本列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.ConfigVersionSummary" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/config-versions/active": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前正在使用的配置版本,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-config-version" + ], + "summary": "获取当前活跃配置版本", + "responses": { + "200": { + "description": "活跃配置版本", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.ConfigVersion" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限、不存在或无活跃版本", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/config-versions/cleanup": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "删除超出保留数量的非活跃配置版本,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-config-version" + ], + "summary": "清理历史配置版本", + "parameters": [ + { + "description": "清理参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/config_version.CleanupInput" + } + } + ], + "responses": { + "200": { + "description": "清理结果", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/config_version.CleanupResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/config-versions/diff": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "对比当前草稿配置与活跃版本之间的差异,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-config-version" + ], + "summary": "对比草稿与活跃配置", + "responses": { + "200": { + "description": "配置差异", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/config_version.ConfigDiffResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/config-versions/preview": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "渲染并返回当前草稿配置的预览结果,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-config-version" + ], + "summary": "预览当前草稿配置", + "responses": { + "200": { + "description": "配置预览", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/config_version.ConfigPreviewResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/config-versions/publish": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "将当前草稿配置发布为新版本,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-config-version" + ], + "summary": "发布配置版本", + "parameters": [ + { + "type": "boolean", + "description": "是否强制发布", + "name": "force", + "in": "query" + } + ], + "responses": { + "200": { + "description": "发布成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.ConfigVersion" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/config-versions/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回指定配置版本的完整快照与渲染内容,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-config-version" + ], + "summary": "获取配置版本详情", + "parameters": [ + { + "type": "integer", + "description": "配置版本 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "配置版本详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.ConfigVersion" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或版本不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/config-versions/{id}/activate": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "将指定历史版本设为当前活跃配置,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-config-version" + ], + "summary": "激活配置版本", + "parameters": [ + { + "type": "integer", + "description": "配置版本 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "激活成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.ConfigVersion" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或版本不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/dashboard/overview": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "聚合节点与可观测性数据,返回 OpenFlare 控制台仪表盘概览,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-dashboard" + ], + "summary": "获取仪表盘概览", + "responses": { + "200": { + "description": "仪表盘概览", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/dashboard.OverviewPayload" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/dns-accounts": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回全部 DNS 提供商账号,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "列出 DNS 账号", + "responses": { + "200": { + "description": "DNS 账号列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.DNSAccount" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "创建新的 DNS 提供商账号,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "创建 DNS 账号", + "parameters": [ + { + "description": "DNS 账号参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/tls.DNSAccountInput" + } + } + ], + "responses": { + "200": { + "description": "创建成功的 DNS 账号", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.DNSAccount" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/dns-accounts/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 删除 DNS 提供商账号,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "删除 DNS 账号", + "parameters": [ + { + "type": "integer", + "description": "DNS 账号 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/dns-accounts/{id}/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 更新 DNS 提供商账号,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "更新 DNS 账号", + "parameters": [ + { + "type": "integer", + "description": "DNS 账号 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "DNS 账号参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/tls.DNSAccountInput" + } + } + ], + "responses": { + "200": { + "description": "更新后的 DNS 账号", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.DNSAccount" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/nodes": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回所有节点及最新配置下发记录,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-node" + ], + "summary": "获取节点列表", + "responses": { + "200": { + "description": "节点列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/node.View" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "创建新的边缘节点记录,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-node" + ], + "summary": "创建节点", + "parameters": [ + { + "description": "节点参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/node.Input" + } + } + ], + "responses": { + "200": { + "description": "创建成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/nodes/bootstrap-token": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回全局节点发现引导令牌,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-node" + ], + "summary": "获取引导令牌", + "responses": { + "200": { + "description": "引导令牌", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.BootstrapView" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/nodes/bootstrap-token/rotate": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "重新生成全局节点发现引导令牌,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-node" + ], + "summary": "轮换引导令牌", + "responses": { + "200": { + "description": "新引导令牌", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.BootstrapView" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/nodes/{id}/agent-release": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回指定节点可用的最新 Agent 版本信息,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-node" + ], + "summary": "获取 Agent 发布信息", + "parameters": [ + { + "type": "integer", + "description": "节点 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "发布渠道", + "name": "channel", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Agent 发布信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.AgentReleaseInfo" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或节点不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/nodes/{id}/agent-update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "向指定节点下发 Agent 自更新指令,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-node" + ], + "summary": "请求 Agent 更新", + "parameters": [ + { + "type": "integer", + "description": "节点 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "更新参数(可选)", + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/node.AgentUpdateInput" + } + } + ], + "responses": { + "200": { + "description": "更新请求已下发", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或节点不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/nodes/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "删除指定节点记录,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-node" + ], + "summary": "删除节点", + "parameters": [ + { + "type": "integer", + "description": "节点 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或节点不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/nodes/{id}/force-sync": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "向指定节点下发强制同步当前活跃配置的指令,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-node" + ], + "summary": "请求强制同步配置", + "parameters": [ + { + "type": "integer", + "description": "节点 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "同步请求已下发", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或节点不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/nodes/{id}/observability": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回指定节点的指标、健康事件与流量分析数据,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-node" + ], + "summary": "获取节点可观测性数据", + "parameters": [ + { + "type": "integer", + "description": "节点 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "统计时间范围(小时)", + "name": "hours", + "in": "query" + }, + { + "type": "integer", + "description": "返回记录数量上限", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "可观测性数据", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.ObservabilityView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或节点不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/nodes/{id}/observability/cleanup": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "清理指定节点的历史健康事件记录,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-node" + ], + "summary": "清理节点健康事件", + "parameters": [ + { + "type": "integer", + "description": "节点 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "清理结果", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.HealthEventCleanupResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或节点不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/nodes/{id}/openresty-restart": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "向指定节点下发 OpenResty 重启指令,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-node" + ], + "summary": "请求重启 OpenResty", + "parameters": [ + { + "type": "integer", + "description": "节点 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "重启请求已下发", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或节点不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/nodes/{id}/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "更新指定节点的配置信息,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-node" + ], + "summary": "更新节点", + "parameters": [ + { + "type": "integer", + "description": "节点 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "节点参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/node.Input" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/node.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或节点不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/option": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回全部非敏感 OpenFlare 配置项,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-option" + ], + "summary": "列出 OpenFlare 配置项", + "responses": { + "200": { + "description": "配置项列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.OpenFlareOption" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/option/geoip/lookup": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按提供商与 IP 查询地理位置信息,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-option" + ], + "summary": "GeoIP 地址查询", + "parameters": [ + { + "description": "查询参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/option.geoIPLookupRequest" + } + } + ], + "responses": { + "200": { + "description": "GeoIP 查询结果", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/option.geoIPLookupView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/option/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "更新单个 OpenFlare 配置项,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-option" + ], + "summary": "更新 OpenFlare 配置项", + "parameters": [ + { + "description": "配置项", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.OpenFlareOption" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/option/update-batch": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "批量更新多个 OpenFlare 配置项,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-option" + ], + "summary": "批量更新 OpenFlare 配置项", + "parameters": [ + { + "description": "批量配置项", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/option.optionBatchPayload" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/origins": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回所有源站及关联代理规则数量,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-origin" + ], + "summary": "获取源站列表", + "responses": { + "200": { + "description": "源站列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/origin.View" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "创建新的上游源站记录,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-origin" + ], + "summary": "创建源站", + "parameters": [ + { + "description": "源站参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/origin.Input" + } + } + ], + "responses": { + "200": { + "description": "创建成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/origin.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/origins/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回指定源站信息及关联代理规则摘要,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-origin" + ], + "summary": "获取源站详情", + "parameters": [ + { + "type": "integer", + "description": "源站 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "源站详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/origin.DetailView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或源站不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/origins/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "删除指定源站记录,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-origin" + ], + "summary": "删除源站", + "parameters": [ + { + "type": "integer", + "description": "源站 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或源站不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/origins/{id}/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "更新指定源站的配置信息,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-origin" + ], + "summary": "更新源站", + "parameters": [ + { + "type": "integer", + "description": "源站 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "源站参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/origin.Input" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/origin.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或源站不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回全部 OpenFlare Pages 项目,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "列出 Pages 项目", + "responses": { + "200": { + "description": "Pages 项目列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/pages.View" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "创建新的 OpenFlare Pages 项目,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "创建 Pages 项目", + "parameters": [ + { + "description": "项目参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/pages.Input" + } + } + ], + "responses": { + "200": { + "description": "创建成功的项目", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/deployments/{deployment_id}/files": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回指定部署包含的文件清单,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "列出 Pages 部署文件", + "parameters": [ + { + "type": "integer", + "description": "部署 ID", + "name": "deployment_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "部署文件列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/pages.DeploymentFileView" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "部署不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 返回 Pages 项目详情,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "获取 Pages 项目详情", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Pages 项目详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 删除 OpenFlare Pages 项目,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "删除 Pages 项目", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/deployments": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回指定项目的全部部署记录,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "列出 Pages 部署", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "部署列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/pages.DeploymentView" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/deployments/upload": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "为指定项目上传静态资源压缩包(zip/tar.gz/tar.xz/tar.bz2/tar/7z),需要管理员权限", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "上传 Pages 部署包", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "file", + "description": "部署包文件", + "name": "package", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "部署记录", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.DeploymentView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/deployments/upload-from-url": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "已弃用的一次性 URL 导入;使用 trusted_internal 策略兼容内网与自签名证书,不创建持久部署源", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "从 URL 导入 Pages 部署包", + "deprecated": true, + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "下载链接", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/pages.UploadFromURLInput" + } + } + ], + "responses": { + "200": { + "description": "部署记录", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.DeploymentView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/deployments/{deployment_id}/activate": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "将指定部署设为项目当前生效版本,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "激活 Pages 部署", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "部署 ID", + "name": "deployment_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "激活后的项目", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目或部署不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/deployments/{deployment_id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "删除指定项目的部署记录,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "删除 Pages 部署", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "部署 ID", + "name": "deployment_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目或部署不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/source": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回项目部署源配置与运行状态,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "获取 Pages 部署源", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "部署源", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.SourceView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目或部署源不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/source/check": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "异步检查 GitHub Release 来源;Remote URL 来源不支持检查更新", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "检查 Pages 部署源", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "任务回执", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.SourceActionReceipt" + } + } + } + ] + } + }, + "400": { + "description": "当前来源不支持检查", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "部署源不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "409": { + "description": "来源任务正在执行", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/source/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "幂等删除持久部署源;已有部署历史与当前生产部署保持不变", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "删除 Pages 部署源", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "手动来源视图", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.SourceView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/source/sync": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "异步下载、校验并原子激活来源部署包;空请求体与空 JSON 对象均有效", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "同步并发布 Pages 部署源", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "同步参数", + "name": "request", + "in": "body", + "schema": { + "$ref": "#/definitions/pages.SourceSyncInput" + } + } + ], + "responses": { + "200": { + "description": "任务回执", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.SourceActionReceipt" + } + } + } + ] + } + }, + "400": { + "description": "参数或来源类型无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "部署源不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "409": { + "description": "来源任务正在执行", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/source/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "支持 Remote URL 与公开 GitHub Release 来源;敏感地址仅写入,不会在响应中返回", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "更新 Pages 部署源", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "部署源配置", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/pages.SourceUpdateInput" + } + } + ], + "responses": { + "200": { + "description": "更新结果", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.SourceUpdateResult" + } + } + } + ] + } + }, + "400": { + "description": "配置无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/pages/{id}/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 更新 OpenFlare Pages 项目,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-pages" + ], + "summary": "更新 Pages 项目", + "parameters": [ + { + "type": "integer", + "description": "项目 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "项目参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/pages.Input" + } + } + ], + "responses": { + "200": { + "description": "更新后的项目", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/pages.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "项目不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/proxy-routes": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回所有代理规则配置,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-proxy-route" + ], + "summary": "获取代理规则列表", + "responses": { + "200": { + "description": "代理规则列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/proxy_route.View" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "创建新的反向代理规则,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-proxy-route" + ], + "summary": "创建代理规则", + "parameters": [ + { + "description": "代理规则参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proxy_route.Input" + } + } + ], + "responses": { + "200": { + "description": "创建成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/proxy_route.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/proxy-routes/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回指定代理规则的完整配置,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-proxy-route" + ], + "summary": "获取代理规则详情", + "parameters": [ + { + "type": "integer", + "description": "代理规则 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "代理规则详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/proxy_route.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或规则不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/proxy-routes/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "删除指定代理规则,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-proxy-route" + ], + "summary": "删除代理规则", + "parameters": [ + { + "type": "integer", + "description": "代理规则 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或规则不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/proxy-routes/{id}/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "更新指定代理规则的配置,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-proxy-route" + ], + "summary": "更新代理规则", + "parameters": [ + { + "type": "integer", + "description": "代理规则 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "代理规则参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/proxy_route.Input" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/proxy_route.View" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或规则不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/status": { + "get": { + "description": "返回版本、认证源与系统公开配置,无需登录", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-option" + ], + "summary": "获取 OpenFlare 公开状态", + "responses": { + "200": { + "description": "公开状态", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/option.statusView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/tls-certificates": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回全部 TLS 证书(不含 PEM),需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "列出 TLS 证书", + "responses": { + "200": { + "description": "证书列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "从 PEM 文本创建 TLS 证书,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "创建 TLS 证书", + "parameters": [ + { + "description": "证书参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/tls.CertificateInput" + } + } + ], + "responses": { + "200": { + "description": "创建成功的证书", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/tls-certificates/apply": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "通过 ACME 申请新的 TLS 证书,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "申请 ACME 证书", + "parameters": [ + { + "description": "ACME 申请参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/tls.ApplyInput" + } + } + ], + "responses": { + "200": { + "description": "申请中的证书", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/tls-certificates/import-file": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "上传证书与私钥文件创建 TLS 证书,需要管理员权限", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "从文件导入 TLS 证书", + "parameters": [ + { + "type": "string", + "description": "证书名称", + "name": "name", + "in": "formData" + }, + { + "type": "string", + "description": "备注", + "name": "remark", + "in": "formData" + }, + { + "type": "file", + "description": "证书文件", + "name": "cert_file", + "in": "formData", + "required": true + }, + { + "type": "file", + "description": "私钥文件", + "name": "key_file", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "导入成功的证书", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/tls-certificates/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 返回 TLS 证书详情(不含 PEM),需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "获取 TLS 证书详情", + "parameters": [ + { + "type": "integer", + "description": "证书 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "证书详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/tls-certificates/{id}/content": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 返回证书与私钥 PEM 内容,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "获取 TLS 证书 PEM 内容", + "parameters": [ + { + "type": "integer", + "description": "证书 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "证书 PEM 内容", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/tls.CertificateContent" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/tls-certificates/{id}/convert-acme": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "将已上传证书转换为 ACME 自动续期模式,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "将证书转为 ACME 管理", + "parameters": [ + { + "type": "integer", + "description": "证书 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "ACME 申请参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/tls.ApplyInput" + } + } + ], + "responses": { + "200": { + "description": "转换后的证书", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/tls-certificates/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 删除 TLS 证书,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "删除 TLS 证书", + "parameters": [ + { + "type": "integer", + "description": "证书 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/tls-certificates/{id}/renew": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "手动触发 ACME 证书续期,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "续期 ACME 证书", + "parameters": [ + { + "type": "integer", + "description": "证书 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "续期后的证书", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/tls-certificates/{id}/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 更新 TLS 证书 PEM 信息,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "更新 TLS 证书", + "parameters": [ + { + "type": "integer", + "description": "证书 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "证书参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/tls.CertificateInput" + } + } + ], + "responses": { + "200": { + "description": "更新后的证书", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/tls-certificates/{id}/update-acme": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 更新 ACME 证书申请配置,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tls" + ], + "summary": "更新 ACME 证书配置", + "parameters": [ + { + "type": "integer", + "description": "证书 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "ACME 申请参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/tls.ApplyInput" + } + } + ], + "responses": { + "200": { + "description": "更新后的证书", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.TLSCertificate" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/uptimekuma/sync": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "将 OpenFlare 节点同步到 Uptime Kuma,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-option" + ], + "summary": "同步 Uptime Kuma", + "responses": { + "200": { + "description": "同步成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/ip-groups": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回全部 WAF IP 组,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "列出 WAF IP 组", + "responses": { + "200": { + "description": "IP 组列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/waf.IPGroupView" + } + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "创建新的 WAF IP 组,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "创建 WAF IP 组", + "parameters": [ + { + "description": "IP 组参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/waf.IPGroupInput" + } + } + ], + "responses": { + "200": { + "description": "创建成功的 IP 组", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.IPGroupView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/ip-groups/test": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "根据自动配置规则测试 IP 匹配结果(桩实现),需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "测试 WAF IP 组自动配置", + "parameters": [ + { + "description": "自动配置参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/waf.IPGroupAutoTestInput" + } + } + ], + "responses": { + "200": { + "description": "测试结果", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.IPGroupAutoTestResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/ip-groups/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 返回 WAF IP 组详情,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "获取 WAF IP 组详情", + "parameters": [ + { + "type": "integer", + "description": "IP 组 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "IP 组详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.IPGroupView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/ip-groups/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 删除 WAF IP 组,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "删除 WAF IP 组", + "parameters": [ + { + "type": "integer", + "description": "IP 组 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/ip-groups/{id}/sync": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "手动触发 WAF IP 组外部 IP 同步,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "同步 WAF IP 组", + "parameters": [ + { + "type": "integer", + "description": "IP 组 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "同步结果", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.IPGroupSyncResult" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/ip-groups/{id}/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 ID 更新 WAF IP 组,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "更新 WAF IP 组", + "parameters": [ + { + "type": "integer", + "description": "IP 组 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "IP 组参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/waf.IPGroupInput" + } + } + ], + "responses": { + "200": { + "description": "更新后的 IP 组", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.IPGroupView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/rule-groups": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "列出 WAF 规则", + "responses": { + "200": { + "description": "规则列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/waf.RuleView" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "创建 WAF 规则", + "parameters": [ + { + "description": "规则名称", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/waf.CreateRuleInput" + } + } + ], + "responses": { + "200": { + "description": "创建成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.RuleView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/rule-groups/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "获取 WAF 规则详情", + "parameters": [ + { + "type": "integer", + "description": "规则 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "规则详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.RuleView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/rule-groups/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "删除 WAF 规则", + "parameters": [ + { + "type": "integer", + "description": "规则 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/rule-groups/{id}/graph": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "保存 WAF 规则图", + "parameters": [ + { + "type": "integer", + "description": "规则 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "规则图和修订号", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/waf.SaveRuleGraphInput" + } + } + ], + "responses": { + "200": { + "description": "保存成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.RuleView" + } + } + } + ] + } + }, + "400": { + "description": "参数或规则图错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "409": { + "description": "修订冲突", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/rule-groups/{id}/meta": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "更新 WAF 规则元数据", + "parameters": [ + { + "type": "integer", + "description": "规则 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "规则元数据", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/waf.UpdateRuleMetaInput" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.RuleView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "无权限或不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/waf/sites/{route_id}/rule-groups": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回代理站点关联的 WAF 规则组绑定,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "获取站点 WAF 规则组", + "parameters": [ + { + "type": "integer", + "description": "代理路由 ID", + "name": "route_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "站点规则组绑定", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.SiteRuleGroupsView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "替换代理站点关联的 WAF 规则组列表,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-waf" + ], + "summary": "替换站点 WAF 规则组", + "parameters": [ + { + "type": "integer", + "description": "代理路由 ID", + "name": "route_id", + "in": "path", + "required": true + }, + { + "description": "规则组 ID 列表", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/waf.IDsRequest" + } + } + ], + "responses": { + "200": { + "description": "更新后的站点规则组绑定", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/waf.SiteRuleGroupsView" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/zones": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-zone" + ], + "summary": "获取 Zone 列表", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/zone.ListItem" + } + } + } + } + ] + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-zone" + ], + "summary": "创建 Zone", + "parameters": [ + { + "description": "Zone 参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/zone.Input" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.Zone" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/zones/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-zone" + ], + "summary": "删除 Zone", + "parameters": [ + { + "type": "integer", + "description": "Zone ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/zones/{id}/domains": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-zone" + ], + "summary": "创建 Zone 域名", + "parameters": [ + { + "type": "integer", + "description": "Zone ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "域名参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/zone.DomainInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.ZoneDomain" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/zones/{id}/domains/{domainId}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-zone" + ], + "summary": "删除 Zone 域名", + "parameters": [ + { + "type": "integer", + "description": "Zone ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "域名 ID", + "name": "domainId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/zones/{id}/domains/{domainId}/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-zone" + ], + "summary": "更新 Zone 域名", + "parameters": [ + { + "type": "integer", + "description": "Zone ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "域名 ID", + "name": "domainId", + "in": "path", + "required": true + }, + { + "description": "域名参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/zone.DomainInput" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.ZoneDomain" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/zones/{id}/overview": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-zone" + ], + "summary": "获取 Zone 概览", + "parameters": [ + { + "type": "integer", + "description": "Zone ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/zone.Overview" + } + } + } + ] + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/zones/{id}/stats": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "按 Zone 下全部域名聚合访问日志:唯一访问者、请求总数、已提供数据(字节)。range 支持 24h/7d/30d。", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-zone" + ], + "summary": "获取 Zone 流量统计", + "parameters": [ + { + "type": "integer", + "description": "Zone ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "时间范围:24h(默认)、7d、30d", + "name": "range", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/zone.Stats" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/d/zones/{id}/update": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-zone" + ], + "summary": "更新 Zone", + "parameters": [ + { + "type": "integer", + "description": "Zone ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Zone 参数", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/zone.Input" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.Zone" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/oauth/callback": { + "post": { + "description": "接收前端传回的 state 和 code,完成 OAuth/OIDC 认证并建立会话。支持登录(login)和账号绑定(bind)两种场景。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "OAuth 回调处理", + "parameters": [ + { + "description": "回调请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/oauth.CallbackRequest" + } + } + ], + "responses": { + "200": { + "description": "登录或绑定成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/oauth.OAuthCallbackResult" + } + } + } + ] + } + }, + "400": { + "description": "state 无效、参数错误或认证源错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "绑定场景未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "OAuth 认证失败或内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/oauth/external-accounts": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前登录用户已绑定的所有外部 OAuth 帐号信息,需要登录", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "获取外部帐号列表", + "responses": { + "200": { + "description": "外部帐号列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.ExternalAccountView" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/oauth/external-accounts/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "解除当前登录用户与指定外部帐号的绑定关系,需要登录", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "解除外部帐号绑定", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "外部帐号绑定记录 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "解除绑定成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "ID 无效或解除失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/oauth/login": { + "get": { + "description": "根据指定认证源生成 OAuth 授权 URL,前端跳转到该 URL 完成 OAuth 登录授权。source 参数为空时使用第一个启用的认证源。", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "获取登录授权地址", + "parameters": [ + { + "type": "string", + "description": "认证源名称,为空使用第一个启用的认证源", + "name": "source", + "in": "query" + } + ], + "responses": { + "200": { + "description": "授权 URL", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/oauth.OAuthAuthorizeResponse" + } + } + } + ] + } + }, + "400": { + "description": "认证源不存在或未配置", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "Redis 异常 or 构造 URL 失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/oauth/logout": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "清除当前用户的登录会话,完成退出。清除 Cookie 中的 Session 数据。", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "退出登录", + "responses": { + "200": { + "description": "退出成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "500": { + "description": "Session 清除失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/oauth/sources": { + "get": { + "description": "返回当前系统已启用的所有 OAuth 登录源,前端展示登录按钮列表时调用", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "获取可用登录源", + "responses": { + "200": { + "description": "登录源列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/oauth.AuthSourceView" + } + } + } + } + ] + } + } + } + } + }, + "/api/v1/oauth/user-info": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "获取当前登录用户信息", + "responses": { + "200": { + "description": "用户信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/oauth.BasicUserInfo" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/oauth/{source}/authorize": { + "get": { + "description": "根据指定认证源名称发起 OAuth 授权,支持 purpose 参数用于区分登录和账号绑定场景。认证源必须已启用。", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "发起指定认证源授权", + "parameters": [ + { + "type": "string", + "description": "认证源名称", + "name": "source", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "授权目的:login(登录)或 bind(绑定账号),默认 login", + "name": "purpose", + "in": "query" + } + ], + "responses": { + "200": { + "description": "授权 URL", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/oauth.OAuthAuthorizeResponse" + } + } + } + ] + } + }, + "400": { + "description": "认证源不存在或未启用", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "Redis 异常或构造 URL 失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/relay/heartbeat": { + "post": { + "security": [ + { + "AgentTokenAuth": [] + } + ], + "description": "Relay 节点定期上报运行状态与 frps 观测数据,返回运行时配置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-relay" + ], + "summary": "上报 Relay 心跳", + "parameters": [ + { + "description": "心跳载荷", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/relay.HeartbeatPayload" + } + } + ], + "responses": { + "200": { + "description": "心跳响应", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/relay.HeartbeatResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "Agent Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "节点类型不匹配", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/relay/ws": { + "get": { + "security": [ + { + "AgentTokenAuth": [] + } + ], + "description": "将已认证的 Relay 连接升级为 WebSocket 长连接,用于配置推送", + "tags": [ + "openflare-relay" + ], + "summary": "升级 Relay WebSocket 连接", + "responses": { + "401": { + "description": "Agent Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "节点类型不匹配", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/tunnel/apply-log": { + "post": { + "security": [ + { + "TunnelTokenAuth": [] + } + ], + "description": "Tunnel 客户端上报配置应用结果,服务端记录下发日志", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tunnel" + ], + "summary": "上报 Tunnel 配置下发结果", + "parameters": [ + { + "description": "下发结果载荷", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/flared.ApplyLogPayload" + } + } + ], + "responses": { + "200": { + "description": "下发日志记录", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.OpenFlareApplyLog" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "Tunnel Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "节点类型不匹配", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/tunnel/config/active": { + "get": { + "security": [ + { + "TunnelTokenAuth": [] + } + ], + "description": "返回 Tunnel 客户端当前应应用的完整路由配置(含中继列表与代理定义)", + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tunnel" + ], + "summary": "获取活跃隧道配置", + "responses": { + "200": { + "description": "隧道配置", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/flared.TunnelConfigResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "Tunnel Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "节点类型不匹配", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/tunnel/heartbeat": { + "post": { + "security": [ + { + "TunnelTokenAuth": [] + } + ], + "description": "Tunnel 客户端定期上报运行状态与中继连接信息,返回活跃配置元数据与隧道设置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openflare-tunnel" + ], + "summary": "上报 Tunnel 心跳", + "parameters": [ + { + "description": "心跳载荷", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/flared.HeartbeatPayload" + } + } + ], + "responses": { + "200": { + "description": "心跳响应", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/flared.HeartbeatResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "Tunnel Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "节点类型不匹配", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/tunnel/ws": { + "get": { + "security": [ + { + "TunnelTokenAuth": [] + } + ], + "description": "将已认证的 Tunnel 客户端连接升级为 WebSocket 长连接,用于配置推送", + "tags": [ + "openflare-tunnel" + ], + "summary": "升级 Tunnel WebSocket 连接", + "responses": { + "401": { + "description": "Tunnel Token 无效", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "节点类型不匹配", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/upload": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "支持各种类型的通用文件上传,支持自动文件类型检测、哈希计算与“秒传”去重", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "upload" + ], + "summary": "上传文件", + "parameters": [ + { + "type": "file", + "description": "要上传的文件", + "name": "file", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "业务分类 (例如: avatar, attachment, doc,默认为 generic)", + "name": "type", + "in": "formData" + }, + { + "type": "string", + "description": "额外的 JSON 格式元数据", + "name": "metadata", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "上传成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.Upload" + } + } + } + ] + } + }, + "400": { + "description": "请求参数错误或文件受限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "409": { + "description": "系统保留类型或存储只读", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/upload/my": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "分页获取当前登录用户上传的文件,支持文件名关键词、业务类型、扩展名过滤", + "produces": [ + "application/json" + ], + "tags": [ + "upload" + ], + "summary": "获取我的文件列表", + "parameters": [ + { + "type": "integer", + "description": "页码(默认 1)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量(默认 20,最大 100)", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "文件名关键词(模糊匹配)", + "name": "keyword", + "in": "query" + }, + { + "type": "string", + "description": "业务分类过滤", + "name": "type", + "in": "query" + }, + { + "type": "string", + "description": "扩展名过滤", + "name": "extension", + "in": "query" + } + ], + "responses": { + "200": { + "description": "查询成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/handler.listMyFilesResponse" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/upload/{id}": { + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "更新当前用户本人的文件名或访问权限模式 (AccessMode)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "upload" + ], + "summary": "更新我的文件信息", + "parameters": [ + { + "type": "string", + "description": "文件 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "更新字段", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.updateMyFileRequest" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.Upload" + } + } + } + ] + } + }, + "403": { + "description": "无权操作", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "文件不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "将当前用户本人的文件状态置为 deleted(软删除)", + "produces": [ + "application/json" + ], + "tags": [ + "upload" + ], + "summary": "删除我的文件", + "parameters": [ + { + "type": "string", + "description": "文件 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无权操作", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "文件不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "409": { + "description": "系统保留类型或存储只读", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user-info": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "获取当前登录用户信息", + "responses": { + "200": { + "description": "用户信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/oauth.BasicUserInfo" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/access-tokens": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前登录用户的所有 active access tokens(脱敏后)", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "获取当前用户的 AccessToken 列表", + "responses": { + "200": { + "description": "令牌列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.AccessToken" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "为当前用户新建一个 API 访问令牌,仅在此接口返回一次明文令牌值,请妥善保存。可通过 is_admin 字段赋予令牌管理员权限(仅管理员用户可设置)。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "创建一个新的 AccessToken", + "parameters": [ + { + "description": "令牌名称", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.createTokenRequest" + } + } + ], + "responses": { + "200": { + "description": "新建令牌成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/user.tokenResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误或超限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/access-tokens/{id}": { + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "撤销并删除一个属于当前用户的 API 访问令牌", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "删除一个 AccessToken", + "parameters": [ + { + "type": "string", + "description": "令牌ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/access-tokens/{id}/rotate": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "轮换(重新生成)一个属于当前用户的 API 访问令牌的密钥,旧令牌将立即失效", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "轮换一个 AccessToken", + "parameters": [ + { + "type": "string", + "description": "令牌ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "令牌轮换成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/user.tokenResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/change-password": { + "post": { + "description": "修改当前登录用户的密码。修改成功后,如果是首次明文登录的升级提示,则清除修改密码的提示状态。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "修改用户密码", + "parameters": [ + { + "description": "修改密码请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.changePasswordRequest" + } + } + ], + "responses": { + "200": { + "description": "修改密码成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "原密码错误或新密码不符合要求", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "请先登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/login": { + "post": { + "description": "使用用户名和密码登录,登录成功后建立 Session。若管理员已关闭密码登录功能则返回错误。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "用户密码登录", + "parameters": [ + { + "description": "登录请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.loginRequest" + } + } + ], + "responses": { + "200": { + "description": "登录成功,返回用户信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/oauth.BasicUserInfo" + } + } + } + ] + } + }, + "400": { + "description": "用户名或密码错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "服务内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/logout": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "清除用户登录 Session,完成退出", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "用户退出登录", + "responses": { + "200": { + "description": "退出成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "500": { + "description": "Session 清除失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/profile": { + "put": { + "description": "修改当前登录用户的昵称、邮箱、头像、简介、电话、性别、个人网站和所在地。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "修改当前登录用户的个人资料", + "parameters": [ + { + "description": "更新请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.updateProfileRequest" + } + } + ], + "responses": { + "200": { + "description": "修改成功,返回更新后的用户信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/oauth.BasicUserInfo" + } + } + } + ] + } + }, + "400": { + "description": "邮箱已被占用或参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/register": { + "post": { + "description": "使用用户名和密码注册新账号,注册成功后自动登录并建立 Session。密码长度不能少于 8 位。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "用户注册", + "parameters": [ + { + "description": "注册请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.registerRequest" + } + } + ], + "responses": { + "200": { + "description": "注册并登录成功,返回用户信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/oauth.BasicUserInfo" + } + } + } + ] + } + }, + "400": { + "description": "参数错误、用户名已存在或注册已关闭", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "服务内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/self": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "获取当前登录用户信息", + "responses": { + "200": { + "description": "用户信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/oauth.BasicUserInfo" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/send-email-code": { + "post": { + "description": "向指定邮箱发送验证码(用于注册场景)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "发送邮箱验证码", + "parameters": [ + { + "description": "发送验证码请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.sendEmailCodeRequest" + } + } + ], + "responses": { + "200": { + "description": "发送成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/f/{id}": { + "get": { + "description": "根据文件 ID 获取并提供已上传的临时或正式文件,若配置了缓存则优先走本地缓存,否则从 S3 等后端存储读取并流式返回", + "produces": [ + "application/octet-stream" + ], + "tags": [ + "upload" + ], + "summary": "获取已上传文件", + "parameters": [ + { + "type": "string", + "description": "文件 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "图片质量 (low, medium, high, origin),默认为 origin", + "name": "quality", + "in": "query" + } + ], + "responses": { + "200": { + "description": "成功获取文件内容", + "schema": { + "type": "file" + } + }, + "400": { + "description": "文件 ID 格式错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "文件未找到", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "服务内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/robots.txt": { + "get": { + "description": "根据系统配置决定是否允许搜索引擎检索,并返回相应的 robots.txt 文件内容", + "produces": [ + "text/plain" + ], + "tags": [ + "config" + ], + "summary": "获取 robots.txt", + "responses": { + "200": { + "description": "robots.txt 内容", + "schema": { + "type": "string" + } + } + } + } + } + }, + "definitions": { + "Wavelet_OpenFlare_plugins_server_cap.ChallengeResponse": { + "type": "object", + "properties": { + "challenge": { + "type": "object", + "properties": { + "c": { + "type": "integer" + }, + "d": { + "type": "integer" + }, + "s": { + "type": "integer" + } + } + }, + "expires": { + "description": "ms timestamp", + "type": "integer" + }, + "token": { + "type": "string" + } + } + }, + "Wavelet_OpenFlare_plugins_server_cap.RedeemResponse": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "expires": { + "type": "integer" + }, + "success": { + "type": "boolean" + }, + "token": { + "type": "string" + } + } + }, + "Wavelet_OpenFlare_plugins_server_model_analytics.ClickHouseOperationalStats": { + "type": "object", + "properties": { + "active_parts": { + "type": "integer" + }, + "async_insert_bytes": { + "type": "integer" + }, + "async_insert_queue": { + "type": "integer" + }, + "batch_writers": { + "description": "BatchWriters reports in-process queue depth/drops/flush errors for CH writers.", + "type": "array", + "items": { + "$ref": "#/definitions/analytics.BatchWriterStats" + } + }, + "database": { + "type": "string" + }, + "pending_mutations": { + "type": "integer" + }, + "total_rows": { + "type": "integer" + } + } + }, + "Wavelet_OpenFlare_share_protocol.ActiveConfigMeta": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "Wavelet_OpenFlare_share_protocol.BufferedObservabilityRecord": { + "type": "object", + "properties": { + "access_logs": { + "type": "array", + "items": { + "$ref": "#/definitions/Wavelet_OpenFlare_share_protocol.NodeAccessLog" + } + }, + "captured_at_unix": { + "type": "integer" + }, + "edge_health": { + "$ref": "#/definitions/Wavelet_OpenFlare_share_protocol.NodeEdgeHealth" + }, + "host_metrics": { + "$ref": "#/definitions/Wavelet_OpenFlare_share_protocol.NodeMetricSnapshot" + } + } + }, + "Wavelet_OpenFlare_share_protocol.NodeAccessLog": { + "type": "object", + "properties": { + "bytes_sent": { + "description": "body bytes = 已提供数据", + "type": "integer" + }, + "cache_status": { + "description": "$upstream_cache_status", + "type": "string" + }, + "host": { + "type": "string" + }, + "logged_at_unix": { + "type": "integer" + }, + "path": { + "type": "string" + }, + "remote_addr": { + "type": "string" + }, + "request_length": { + "description": "接收数据", + "type": "integer" + }, + "request_time_ms": { + "description": "optional", + "type": "integer" + }, + "status_code": { + "type": "integer" + }, + "user_agent": { + "type": "string" + } + } + }, + "Wavelet_OpenFlare_share_protocol.NodeEdgeHealth": { + "type": "object", + "properties": { + "captured_at_unix": { + "type": "integer" + }, + "connections": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "Wavelet_OpenFlare_share_protocol.NodeHealthEvent": { + "type": "object", + "properties": { + "event_type": { + "type": "string" + }, + "message": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "severity": { + "type": "string" + }, + "triggered_at_unix": { + "type": "integer" + } + } + }, + "Wavelet_OpenFlare_share_protocol.NodeMetricSnapshot": { + "type": "object", + "properties": { + "captured_at_unix": { + "type": "integer" + }, + "cpu_usage_percent": { + "type": "number" + }, + "disk_read_bytes": { + "type": "integer" + }, + "disk_write_bytes": { + "type": "integer" + }, + "memory_total_bytes": { + "type": "integer" + }, + "memory_used_bytes": { + "type": "integer" + }, + "storage_total_bytes": { + "type": "integer" + }, + "storage_used_bytes": { + "type": "integer" + } + } + }, + "Wavelet_OpenFlare_share_protocol.NodeSystemProfile": { + "type": "object", + "properties": { + "architecture": { + "type": "string" + }, + "cpu_cores": { + "type": "integer" + }, + "cpu_model": { + "type": "string" + }, + "hostname": { + "type": "string" + }, + "kernel_version": { + "type": "string" + }, + "os_name": { + "type": "string" + }, + "os_version": { + "type": "string" + }, + "reported_at_unix": { + "type": "integer" + }, + "total_disk_bytes": { + "type": "integer" + }, + "total_memory_bytes": { + "type": "integer" + }, + "uptime_seconds": { + "type": "integer" + } + } + }, + "Wavelet_OpenFlare_share_protocol.PagesDeploymentHashResponse": { + "type": "object", + "properties": { + "deployment_id": { + "type": "integer" + }, + "hash": { + "type": "string" + } + } + }, + "Wavelet_OpenFlare_share_protocol.PagesProjectLatestHashResponse": { + "type": "object", + "properties": { + "deployment_id": { + "type": "integer" + }, + "file_count": { + "type": "integer" + }, + "hash": { + "type": "string" + }, + "package_size": { + "type": "integer" + }, + "project_id": { + "type": "integer" + }, + "total_size": { + "type": "integer" + } + } + }, + "Wavelet_OpenFlare_share_protocol.WAFIPGroup": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "ip_list": { + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "agent.ActiveConfigMeta": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "agent.ApplyLogPayload": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "main_config_checksum": { + "type": "string" + }, + "message": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "result": { + "type": "string" + }, + "route_config_checksum": { + "type": "string" + }, + "support_file_count": { + "type": "integer" + }, + "version": { + "type": "string" + } + } + }, + "agent.ConfigResponse": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "source_config_json": { + "type": "string" + }, + "support_files": { + "type": "array", + "items": { + "$ref": "#/definitions/agent.SupportFile" + } + }, + "version": { + "type": "string" + } + } + }, + "agent.HeartbeatResponse": { + "type": "object", + "properties": { + "active_config": { + "$ref": "#/definitions/agent.ActiveConfigMeta" + }, + "agent_settings": { + "$ref": "#/definitions/agent.Settings" + }, + "node": { + "$ref": "#/definitions/model.OpenFlareNode" + }, + "waf_ip_groups": { + "type": "array", + "items": { + "$ref": "#/definitions/agent.WAFIPGroup" + } + } + } + }, + "agent.NodePayload": { + "type": "object", + "properties": { + "access_logs": { + "type": "array", + "items": { + "$ref": "#/definitions/Wavelet_OpenFlare_share_protocol.NodeAccessLog" + } + }, + "buffered": { + "type": "array", + "items": { + "$ref": "#/definitions/Wavelet_OpenFlare_share_protocol.BufferedObservabilityRecord" + } + }, + "current_version": { + "type": "string" + }, + "edge_health": { + "$ref": "#/definitions/Wavelet_OpenFlare_share_protocol.NodeEdgeHealth" + }, + "ext_version": { + "type": "string" + }, + "health_events": { + "type": "array", + "items": { + "$ref": "#/definitions/Wavelet_OpenFlare_share_protocol.NodeHealthEvent" + } + }, + "host_metrics": { + "$ref": "#/definitions/Wavelet_OpenFlare_share_protocol.NodeMetricSnapshot" + }, + "ip": { + "type": "string" + }, + "last_error": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "openresty_message": { + "type": "string" + }, + "openresty_status": { + "type": "string" + }, + "profile": { + "$ref": "#/definitions/Wavelet_OpenFlare_share_protocol.NodeSystemProfile" + }, + "schema_version": { + "type": "integer" + }, + "version": { + "type": "string" + }, + "waf_ip_group_checksums": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "agent.RegistrationResponse": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + } + } + }, + "agent.Settings": { + "type": "object", + "properties": { + "auto_update": { + "type": "boolean" + }, + "heartbeat_interval": { + "type": "integer" + }, + "restart_openresty_now": { + "type": "boolean" + }, + "update_channel": { + "type": "string" + }, + "update_now": { + "type": "boolean" + }, + "update_repo": { + "type": "string" + }, + "update_tag": { + "type": "string" + }, + "websocket_upgrade_enabled": { + "type": "boolean" + } + } + }, + "agent.SupportFile": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "agent.WAFIPGroup": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "ip_list": { + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "agent.WAFIPGroupSyncInput": { + "type": "object", + "properties": { + "checksums": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "ids": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "agent.WAFIPGroupSyncResult": { + "type": "object", + "properties": { + "groups": { + "type": "array", + "items": { + "$ref": "#/definitions/Wavelet_OpenFlare_share_protocol.WAFIPGroup" + } + } + } + }, + "analytics.BatchWriterStats": { + "type": "object", + "properties": { + "cap": { + "type": "integer" + }, + "depth": { + "type": "integer" + }, + "drops": { + "type": "integer" + }, + "flush_errors": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "running": { + "type": "boolean" + } + } + }, + "apply_log.CleanupInput": { + "type": "object", + "properties": { + "delete_all": { + "type": "boolean" + }, + "retention_days": { + "type": "integer" + } + } + }, + "apply_log.CleanupResult": { + "type": "object", + "properties": { + "cutoff": { + "type": "string" + }, + "delete_all": { + "type": "boolean" + }, + "deleted_count": { + "type": "integer" + }, + "retention_days": { + "type": "integer" + } + } + }, + "apply_log.ListResult": { + "type": "object", + "properties": { + "current": { + "type": "integer" + }, + "rows": { + "type": "array", + "items": { + "$ref": "#/definitions/model.OpenFlareApplyLog" + } + }, + "total": { + "type": "integer" + }, + "totalPage": { + "type": "integer" + } + } + }, + "auth_source.AuthSourceRequest": { + "type": "object", + "properties": { + "client_id": { + "type": "string" + }, + "client_secret": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "icon_url": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "openid_discovery_url": { + "type": "string" + }, + "scopes": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "auth_source.ToggleAuthSourceRequest": { + "type": "object", + "properties": { + "is_active": { + "type": "boolean" + } + } + }, + "cache.updateCacheConfigRequest": { + "type": "object", + "required": [ + "max_size_mb", + "ttl_minutes" + ], + "properties": { + "lru_enabled": { + "type": "boolean" + }, + "max_size_mb": { + "type": "integer", + "minimum": 1 + }, + "ttl_minutes": { + "type": "integer", + "minimum": 0 + } + } + }, + "cap.challengeRequest": { + "type": "object", + "properties": { + "scope": { + "type": "string" + } + } + }, + "cap.redeemRequest": { + "type": "object", + "required": [ + "solutions", + "token" + ], + "properties": { + "scope": { + "type": "string" + }, + "solutions": { + "type": "array", + "items": { + "type": "integer" + } + }, + "token": { + "type": "string" + } + } + }, + "cloudflare.AvailableDomain": { + "type": "object", + "properties": { + "domain": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "zone_domain": { + "type": "string" + }, + "zone_id": { + "type": "integer" + } + } + }, + "cloudflare.ConnectionInput": { + "type": "object", + "properties": { + "api_token": { + "type": "string" + }, + "dns_account_id": { + "type": "integer" + }, + "source": { + "type": "string" + } + } + }, + "cloudflare.ConnectionView": { + "type": "object", + "properties": { + "configured": { + "type": "boolean" + }, + "dns_account_id": { + "type": "integer" + }, + "ready": { + "type": "boolean" + }, + "source": { + "type": "string" + }, + "status": { + "type": "string" + }, + "verified_at": { + "type": "string" + } + } + }, + "cloudflare.GroupDetail": { + "type": "object", + "properties": { + "group": { + "$ref": "#/definitions/cloudflare.GroupItem" + }, + "members": { + "type": "array", + "items": { + "$ref": "#/definitions/cloudflare.MemberItem" + } + } + } + }, + "cloudflare.GroupInput": { + "type": "object", + "properties": { + "backup_node_id": { + "type": "integer" + }, + "default_proxied": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "primary_node_id": { + "type": "integer" + } + } + }, + "cloudflare.GroupItem": { + "type": "object", + "properties": { + "active_node": { + "$ref": "#/definitions/cloudflare.NodeOption" + }, + "backup_node": { + "$ref": "#/definitions/cloudflare.NodeOption" + }, + "created_at": { + "type": "string" + }, + "default_proxied": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "member_count": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "primary_node": { + "$ref": "#/definitions/cloudflare.NodeOption" + }, + "updated_at": { + "type": "string" + } + } + }, + "cloudflare.MemberCreateInput": { + "type": "object", + "properties": { + "proxied": { + "type": "boolean" + }, + "zone_domain_id": { + "type": "integer" + } + } + }, + "cloudflare.MemberItem": { + "type": "object", + "properties": { + "desired_ip": { + "type": "string" + }, + "domain": { + "type": "string" + }, + "group_id": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "last_error": { + "type": "string" + }, + "proxied": { + "type": "boolean" + }, + "sync_status": { + "type": "string" + }, + "synced_at": { + "type": "string" + }, + "zone_domain_id": { + "type": "integer" + }, + "zone_id": { + "type": "integer" + } + } + }, + "cloudflare.MemberUpdateInput": { + "type": "object", + "properties": { + "proxied": { + "type": "boolean" + } + } + }, + "cloudflare.NodeOption": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "ip": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "cloudflare.Overview": { + "type": "object", + "properties": { + "connection": { + "$ref": "#/definitions/cloudflare.ConnectionView" + }, + "error_count": { + "type": "integer" + }, + "group_count": { + "type": "integer" + }, + "member_count": { + "type": "integer" + }, + "ok_count": { + "type": "integer" + }, + "pending_count": { + "type": "integer" + } + } + }, + "cloudflare.SyncReceipt": { + "type": "object", + "properties": { + "task_id": { + "type": "string" + } + } + }, + "config_version.CleanupInput": { + "type": "object", + "properties": { + "keep_count": { + "type": "integer" + } + } + }, + "config_version.CleanupResult": { + "type": "object", + "properties": { + "deleted_count": { + "type": "integer" + }, + "message": { + "type": "string" + } + } + }, + "config_version.ConfigDiffResult": { + "type": "object", + "properties": { + "active_version": { + "type": "string" + }, + "active_website_count": { + "type": "integer" + }, + "added_domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "added_sites": { + "type": "array", + "items": { + "type": "string" + } + }, + "changed_option_details": { + "type": "array", + "items": { + "$ref": "#/definitions/config_version.ConfigOptionDiffItem" + } + }, + "changed_option_keys": { + "type": "array", + "items": { + "type": "string" + } + }, + "current_website_count": { + "type": "integer" + }, + "main_config_changed": { + "type": "boolean" + }, + "modified_domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "modified_sites": { + "type": "array", + "items": { + "type": "string" + } + }, + "removed_domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "removed_sites": { + "type": "array", + "items": { + "type": "string" + } + }, + "waf_config_changed": { + "type": "boolean" + } + } + }, + "config_version.ConfigOptionDiffItem": { + "type": "object", + "properties": { + "current_value": { + "type": "string" + }, + "key": { + "type": "string" + }, + "previous_value": { + "type": "string" + } + } + }, + "config_version.ConfigPreviewResult": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "main_config": { + "type": "string" + }, + "rendered_config": { + "type": "string" + }, + "route_config": { + "type": "string" + }, + "route_count": { + "type": "integer" + }, + "snapshot_json": { + "type": "string" + }, + "support_files": { + "type": "array", + "items": { + "$ref": "#/definitions/config_version.SupportFile" + } + }, + "website_count": { + "type": "integer" + } + } + }, + "config_version.SupportFile": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "dashboard.Capacity": { + "type": "object", + "properties": { + "average_cpu_usage_percent": { + "type": "number" + }, + "average_memory_usage_percent": { + "type": "number" + }, + "high_cpu_nodes": { + "type": "integer" + }, + "high_memory_nodes": { + "type": "integer" + }, + "high_storage_nodes": { + "type": "integer" + } + } + }, + "dashboard.OverviewPayload": { + "type": "object", + "properties": { + "capacity": { + "$ref": "#/definitions/dashboard.Capacity" + }, + "distributions": { + "$ref": "#/definitions/dashboard.distributionsPayload" + }, + "generated_at": {}, + "nodes": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "summary": { + "$ref": "#/definitions/dashboard.Summary" + }, + "traffic": { + "$ref": "#/definitions/dashboard.Traffic" + }, + "trends": { + "$ref": "#/definitions/dashboard.trendsPayload" + } + } + }, + "dashboard.Summary": { + "type": "object", + "properties": { + "offline_nodes": { + "type": "integer" + }, + "online_nodes": { + "type": "integer" + }, + "pending_nodes": { + "type": "integer" + }, + "total_nodes": { + "type": "integer" + }, + "unhealthy_nodes": { + "type": "integer" + } + } + }, + "dashboard.Traffic": { + "type": "object", + "properties": { + "error_count": { + "type": "integer" + }, + "estimated_qps": { + "type": "number" + }, + "reported_nodes": { + "type": "integer" + }, + "request_count": { + "type": "integer" + }, + "unique_visitors": { + "type": "integer" + } + } + }, + "dashboard.distributionsPayload": { + "type": "object", + "properties": { + "source_countries": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "status_codes": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "top_domains": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + } + } + }, + "dashboard.trendsPayload": { + "type": "object", + "properties": { + "capacity_24h": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "disk_io_24h": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "network_24h": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + }, + "traffic_24h": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + } + } + }, + "db_manage.DBOverviewResponse": { + "type": "object", + "properties": { + "connections": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "size": { + "type": "string" + }, + "table_count": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "db_manage.ExecuteSQLRequest": { + "type": "object", + "required": [ + "sql" + ], + "properties": { + "sql": { + "type": "string" + } + } + }, + "db_manage.ExecuteSQLResponse": { + "type": "object", + "properties": { + "affected_rows": { + "type": "integer" + }, + "columns": { + "type": "array", + "items": { + "type": "string" + } + }, + "execution_time_ms": { + "type": "integer" + }, + "results": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": {} + } + }, + "type": { + "description": "\"select\" 或 \"exec\"", + "type": "string" + } + } + }, + "diskcache.Status": { + "type": "object", + "properties": { + "base_path": { + "type": "string" + }, + "keys_count": { + "type": "integer" + }, + "lru_enabled": { + "type": "boolean" + }, + "max_size_mb": { + "type": "integer" + }, + "total_size": { + "type": "integer" + }, + "ttl_minutes": { + "type": "integer" + } + } + }, + "flared.ApplyLogPayload": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "main_config_checksum": { + "type": "string" + }, + "message": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "result": { + "type": "string" + }, + "route_config_checksum": { + "type": "string" + }, + "support_file_count": { + "type": "integer" + }, + "version": { + "type": "string" + } + } + }, + "flared.HeartbeatPayload": { + "type": "object", + "properties": { + "client_version": { + "type": "string" + }, + "connected_relays": { + "type": "array", + "items": { + "$ref": "#/definitions/protocol.FlaredConnectedRelay" + } + }, + "current_checksum": { + "type": "string" + }, + "current_version": { + "type": "string" + }, + "frp_version": { + "type": "string" + }, + "ip": { + "type": "string" + }, + "tunnel_status": { + "type": "string" + } + } + }, + "flared.HeartbeatResponse": { + "type": "object", + "properties": { + "active_config": { + "$ref": "#/definitions/Wavelet_OpenFlare_share_protocol.ActiveConfigMeta" + }, + "tunnel_settings": { + "$ref": "#/definitions/protocol.RelaySettings" + } + } + }, + "flared.TunnelConfigResponse": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "proxies": { + "type": "array", + "items": { + "$ref": "#/definitions/protocol.FlaredProxyEntry" + } + }, + "relays": { + "type": "array", + "items": { + "$ref": "#/definitions/protocol.FlaredRelayInfo" + } + }, + "version": { + "type": "string" + } + } + }, + "handler.batchDownloadRequest": { + "type": "object", + "required": [ + "ids" + ], + "properties": { + "ids": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + } + } + }, + "handler.distributionItem": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "size": { + "type": "integer" + } + } + }, + "handler.fileStatsResponse": { + "type": "object", + "properties": { + "categories": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.distributionItem" + } + }, + "total_count": { + "type": "integer" + }, + "total_size": { + "type": "integer" + }, + "trend": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.trendItem" + } + }, + "types": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.distributionItem" + } + } + } + }, + "handler.listFilesResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/model.Upload" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "handler.listMyFilesResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/model.Upload" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "handler.trendItem": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "date": { + "type": "string" + }, + "size": { + "type": "integer" + } + } + }, + "handler.updateMyFileRequest": { + "type": "object", + "properties": { + "access_mode": { + "type": "integer", + "enum": [ + 0, + 1 + ] + }, + "file_name": { + "type": "string", + "maxLength": 255 + } + } + }, + "logger.LogEntry": { + "type": "object", + "properties": { + "data": { + "description": "一行日志原文(含换行符)", + "type": "string" + }, + "index": { + "description": "全局递增序号", + "type": "integer" + } + } + }, + "logs.accessLogItem": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "headers": { + "type": "string" + }, + "id": { + "type": "string", + "example": "0" + }, + "ip": { + "type": "string" + }, + "latency": { + "type": "integer" + }, + "method": { + "type": "string" + }, + "nickname": { + "type": "string" + }, + "path": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "user_agent": { + "type": "string" + }, + "user_id": { + "type": "string", + "example": "0" + }, + "username": { + "type": "string" + } + } + }, + "logs.accessLogsResponse": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/logs.accessLogItem" + } + }, + "total": { + "type": "integer" + } + } + }, + "logs.browserItem": { + "type": "object", + "properties": { + "browser": { + "type": "string" + }, + "count": { + "type": "integer" + } + } + }, + "logs.logsAnalyticsResponse": { + "type": "object", + "properties": { + "browsers": { + "type": "array", + "items": { + "$ref": "#/definitions/logs.browserItem" + } + }, + "top_users": { + "type": "array", + "items": { + "$ref": "#/definitions/logs.topUserItem" + } + }, + "trend": { + "type": "array", + "items": { + "$ref": "#/definitions/logs.trendItem" + } + } + } + }, + "logs.logsResponse": { + "type": "object", + "properties": { + "has_more": { + "type": "boolean" + }, + "lines": { + "type": "array", + "items": { + "$ref": "#/definitions/logger.LogEntry" + } + }, + "next_cursor": { + "description": "用于加载更早日志的 cursor", + "type": "integer" + } + } + }, + "logs.topUserItem": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "nickname": { + "type": "string" + }, + "user_id": { + "type": "string", + "example": "0" + }, + "username": { + "type": "string" + } + } + }, + "logs.trendItem": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "date": { + "type": "string" + } + } + }, + "model.AccessToken": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_admin": { + "type": "boolean" + }, + "masked_token": { + "type": "string" + }, + "name": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + } + }, + "model.AcmeAccount": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "updated_at": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "model.AuthSource": { + "type": "object", + "properties": { + "client_id": { + "type": "string" + }, + "client_secret_configured": { + "type": "boolean" + }, + "created_at": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "icon_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "openid_discovery_url": { + "type": "string" + }, + "scopes": { + "type": "string" + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "model.ConfigVersion": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "created_by": { + "type": "string" + }, + "id": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "main_config": { + "type": "string" + }, + "rendered_config": { + "type": "string" + }, + "snapshot_json": { + "type": "string" + }, + "support_files_json": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "model.ConfigVersionSummary": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "created_by": { + "type": "string" + }, + "id": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "version": { + "type": "string" + } + } + }, + "model.DNSAccount": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "model.ExternalAccountView": { + "type": "object", + "properties": { + "auth_source_id": { + "type": "integer" + }, + "auth_source_label": { + "type": "string" + }, + "auth_source_name": { + "type": "string" + }, + "auth_source_type": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "external_username": { + "type": "string" + }, + "id": { + "type": "integer" + } + } + }, + "model.OpenFlareApplyLog": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "main_config_checksum": { + "type": "string" + }, + "message": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "result": { + "type": "string" + }, + "route_config_checksum": { + "type": "string" + }, + "support_file_count": { + "type": "integer" + }, + "version": { + "type": "string" + } + } + }, + "model.OpenFlareHealthEvent": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "event_type": { + "type": "string" + }, + "first_triggered_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "last_triggered_at": { + "type": "string" + }, + "message": { + "type": "string" + }, + "metadata_json": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "reported_at": { + "type": "string" + }, + "resolved_at": { + "type": "string" + }, + "severity": { + "type": "string" + }, + "status": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "model.OpenFlareNode": { + "type": "object", + "properties": { + "auto_update_enabled": { + "type": "boolean" + }, + "capabilities_json": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "current_version": { + "type": "string" + }, + "ext_version": { + "type": "string" + }, + "geo_latitude": { + "type": "number" + }, + "geo_longitude": { + "type": "number" + }, + "geo_manual_override": { + "type": "boolean" + }, + "geo_name": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "ip": { + "type": "string" + }, + "ip_manual_override": { + "type": "boolean" + }, + "last_error": { + "type": "string" + }, + "last_seen_at": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "node_type": { + "type": "string" + }, + "openresty_message": { + "type": "string" + }, + "openresty_status": { + "type": "string" + }, + "relay_agent_access_addr": { + "type": "string" + }, + "relay_bind_port": { + "type": "integer" + }, + "relay_client_access_addr": { + "type": "string" + }, + "relay_client_proxy_url": { + "type": "string" + }, + "relay_status": { + "type": "string" + }, + "relay_vhost_http_port": { + "type": "integer" + }, + "relay_web_server_enabled": { + "type": "boolean" + }, + "restart_openresty_requested": { + "type": "boolean" + }, + "status": { + "type": "string" + }, + "update_channel": { + "type": "string" + }, + "update_requested": { + "type": "boolean" + }, + "update_tag": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "model.OpenFlareNodeSystemProfile": { + "type": "object", + "properties": { + "architecture": { + "type": "string" + }, + "cpu_cores": { + "type": "integer" + }, + "cpu_model": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "hostname": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "kernel_version": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "os_name": { + "type": "string" + }, + "os_version": { + "type": "string" + }, + "reported_at": { + "type": "string" + }, + "total_disk_bytes": { + "type": "integer" + }, + "total_memory_bytes": { + "type": "integer" + }, + "updated_at": { + "type": "string" + }, + "uptime_seconds": { + "type": "integer" + } + } + }, + "model.OpenFlareOption": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "model.PushChannel": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "description": { + "description": "备注", + "type": "string" + }, + "enabled": { + "description": "通道是否启用", + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "name": { + "description": "通道名称,仅英文字母和下划线,唯一", + "type": "string" + }, + "other": { + "description": "请求体/SMTP 密码等", + "type": "string" + }, + "token": { + "description": "鉴权令牌或发信用户名等", + "type": "string" + }, + "type": { + "description": "通道类型:custom, lark, email", + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "url": { + "description": "请求地址,HTTPS 协议或 SMTP 地址", + "type": "string" + } + } + }, + "model.PushEvent": { + "type": "object", + "properties": { + "channels": { + "description": "推送渠道列表,如 [\"lark\"]", + "type": "array", + "items": { + "type": "string" + } + }, + "created_at": { + "type": "string" + }, + "enabled": { + "description": "是否启用", + "type": "boolean" + }, + "event_key": { + "description": "如 admin_login", + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "description": "如 管理员登录", + "type": "string" + }, + "targets": { + "description": "推送目标用户/邮箱列表", + "type": "array", + "items": { + "type": "string" + } + }, + "task_type": { + "description": "关联的异步任务类型", + "type": "string" + }, + "template": { + "description": "消息模板 JSON", + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "model.PushHistory": { + "type": "object", + "properties": { + "channel": { + "type": "string" + }, + "content": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "error_msg": { + "type": "string" + }, + "event_key": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "level": { + "type": "string" + }, + "status": { + "description": "success / failed", + "type": "string" + }, + "target": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "model.Schedule": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "cron": { + "type": "string" + }, + "id": { + "type": "string", + "example": "0" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "payload": { + "type": "string" + }, + "task_type": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "model.SystemConfig": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "key": { + "type": "string" + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "value": { + "type": "string" + }, + "visibility": { + "type": "integer" + } + } + }, + "model.TLSCertificate": { + "type": "object", + "properties": { + "acme_account_id": { + "type": "integer" + }, + "apply_message": { + "type": "string" + }, + "apply_status": { + "type": "string" + }, + "auto_renew": { + "type": "boolean" + }, + "created_at": { + "type": "string" + }, + "disable_cname": { + "type": "boolean" + }, + "dns1": { + "type": "string" + }, + "dns2": { + "type": "string" + }, + "dns_account_id": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "key_algorithm": { + "type": "string" + }, + "name": { + "type": "string" + }, + "not_after": { + "type": "string" + }, + "not_before": { + "type": "string" + }, + "other_domains": { + "type": "string" + }, + "primary_domain": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "remark": { + "type": "string" + }, + "skip_dns": { + "type": "boolean" + }, + "updated_at": { + "type": "string" + } + } + }, + "model.TaskExecution": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "duration": { + "type": "integer" + }, + "error_message": { + "type": "string" + }, + "finished_at": { + "type": "string" + }, + "id": { + "type": "string", + "example": "0" + }, + "log": { + "type": "string" + }, + "max_retry": { + "type": "integer" + }, + "payload": { + "type": "string" + }, + "result": { + "type": "string" + }, + "retry_count": { + "type": "integer" + }, + "retryable": { + "type": "boolean" + }, + "started_at": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/model.TaskExecutionStatus" + }, + "task_id": { + "type": "string" + }, + "task_name": { + "type": "string" + }, + "task_type": { + "type": "string" + }, + "triggered_by": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "model.TaskExecutionStatus": { + "type": "string", + "enum": [ + "pending", + "running", + "succeeded", + "failed" + ], + "x-enum-varnames": [ + "TaskExecutionStatusPending", + "TaskExecutionStatusRunning", + "TaskExecutionStatusSucceeded", + "TaskExecutionStatusFailed" + ] + }, + "model.Template": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_system": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "model.Upload": { + "type": "object", + "properties": { + "access_mode": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "extension": { + "description": "文件后缀名 (不含点,如 png, pdf)", + "type": "string" + }, + "file_name": { + "description": "原始文件名 (例如: image.png)", + "type": "string" + }, + "file_path": { + "description": "文件相对路径 / S3 Key", + "type": "string" + }, + "file_size": { + "description": "文件大小(字节)", + "type": "integer" + }, + "hash": { + "description": "文件哈希 (SHA-256/MD5,可用于排重)", + "type": "string" + }, + "id": { + "type": "string", + "example": "0" + }, + "metadata": { + "description": "业务扩展元数据", + "allOf": [ + { + "$ref": "#/definitions/model.UploadMetadata" + } + ] + }, + "mime_type": { + "description": "媒体类型 (MIME, 如 image/png)", + "type": "string" + }, + "status": { + "description": "状态", + "allOf": [ + { + "$ref": "#/definitions/model.UploadStatus" + } + ] + }, + "type": { + "description": "业务标识类型 (如 avatar, doc, attachment)", + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "user_id": { + "type": "string", + "example": "0" + } + } + }, + "model.UploadMetadata": { + "type": "object", + "properties": { + "bucket": { + "description": "存储桶名称 (适用于 S3 等)", + "type": "string" + }, + "client_ip": { + "description": "上传者 IP", + "type": "string" + }, + "duration": { + "description": "音视频时长 (s)", + "type": "number" + }, + "extra": { + "description": "其它任意业务自定义元数据", + "type": "object", + "additionalProperties": {} + }, + "height": { + "description": "图像/视频高度 (px)", + "type": "integer" + }, + "original_mime": { + "description": "原始 MIME 类型", + "type": "string" + }, + "user_agent": { + "description": "上传者的 UA", + "type": "string" + }, + "width": { + "description": "图像/视频宽度 (px)", + "type": "integer" + } + } + }, + "model.UploadStatus": { + "type": "string", + "enum": [ + "pending", + "used", + "deleted" + ], + "x-enum-comments": { + "UploadStatusDeleted": "已删除", + "UploadStatusPending": "待使用", + "UploadStatusUsed": "已使用" + }, + "x-enum-descriptions": [ + "待使用", + "已使用", + "已删除" + ], + "x-enum-varnames": [ + "UploadStatusPending", + "UploadStatusUsed", + "UploadStatusDeleted" + ] + }, + "model.Zone": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "domain": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "updated_at": { + "type": "string" + } + } + }, + "model.ZoneDomain": { + "type": "object", + "properties": { + "cert_id": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "domain": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "proxy_route_id": { + "type": "integer" + }, + "updated_at": { + "type": "string" + }, + "zone_id": { + "type": "integer" + } + } + }, + "node.AgentReleaseInfo": { + "type": "object", + "properties": { + "body": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "current_version": { + "type": "string" + }, + "has_update": { + "type": "boolean" + }, + "html_url": { + "type": "string" + }, + "prerelease": { + "type": "boolean" + }, + "published_at": { + "type": "string" + }, + "requested_channel": { + "type": "string" + }, + "requested_tag": { + "type": "string" + }, + "tag_name": { + "type": "string" + }, + "update_requested": { + "type": "boolean" + } + } + }, + "node.AgentUpdateInput": { + "type": "object", + "properties": { + "channel": { + "type": "string" + }, + "tag_name": { + "type": "string" + } + } + }, + "node.BootstrapView": { + "type": "object", + "properties": { + "discovery_token": { + "type": "string" + } + } + }, + "node.HealthEventCleanupResult": { + "type": "object", + "properties": { + "deleted_count": { + "type": "integer" + }, + "node_id": { + "type": "string" + } + } + }, + "node.Input": { + "type": "object", + "properties": { + "auto_update_enabled": { + "type": "boolean" + }, + "geo_latitude": { + "type": "number" + }, + "geo_longitude": { + "type": "number" + }, + "geo_manual_override": { + "type": "boolean" + }, + "geo_name": { + "type": "string" + }, + "ip": { + "type": "string" + }, + "ip_manual_override": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "node_type": { + "type": "string" + }, + "relay_agent_access_addr": { + "type": "string" + }, + "relay_bind_port": { + "type": "integer" + }, + "relay_client_access_addr": { + "type": "string" + }, + "relay_client_proxy_url": { + "type": "string" + }, + "relay_vhost_http_port": { + "type": "integer" + }, + "relay_web_server_enabled": { + "type": "boolean" + } + } + }, + "node.ObservabilityView": { + "type": "object", + "properties": { + "analytics": { + "$ref": "#/definitions/observability.NodeAnalytics" + }, + "health_events": { + "type": "array", + "items": { + "$ref": "#/definitions/model.OpenFlareHealthEvent" + } + }, + "metric_snapshots": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.NodeMetricSnapshotView" + } + }, + "node_id": { + "type": "string" + }, + "profile": { + "$ref": "#/definitions/model.OpenFlareNodeSystemProfile" + }, + "relay_dashboard": { + "$ref": "#/definitions/observability.RelayDashboardSnapshot" + }, + "trends": { + "$ref": "#/definitions/observability.NodeTrends" + } + } + }, + "node.View": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "auto_update_enabled": { + "type": "boolean" + }, + "created_at": { + "type": "string" + }, + "current_version": { + "type": "string" + }, + "ext_version": { + "type": "string" + }, + "geo_latitude": { + "type": "number" + }, + "geo_longitude": { + "type": "number" + }, + "geo_manual_override": { + "type": "boolean" + }, + "geo_name": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "ip": { + "type": "string" + }, + "ip_manual_override": { + "type": "boolean" + }, + "last_error": { + "type": "string" + }, + "last_seen_at": {}, + "latest_apply_at": { + "type": "string" + }, + "latest_apply_checksum": { + "type": "string" + }, + "latest_apply_message": { + "type": "string" + }, + "latest_apply_result": { + "type": "string" + }, + "latest_main_config_checksum": { + "type": "string" + }, + "latest_route_config_checksum": { + "type": "string" + }, + "latest_support_file_count": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "node_type": { + "type": "string" + }, + "openresty_message": { + "type": "string" + }, + "openresty_status": { + "type": "string" + }, + "relay_agent_access_addr": { + "type": "string" + }, + "relay_bind_port": { + "type": "integer" + }, + "relay_client_access_addr": { + "type": "string" + }, + "relay_client_proxy_url": { + "type": "string" + }, + "relay_status": { + "type": "string" + }, + "relay_vhost_http_port": { + "type": "integer" + }, + "relay_web_server_enabled": { + "type": "boolean" + }, + "restart_openresty_requested": { + "type": "boolean" + }, + "status": { + "type": "string" + }, + "update_channel": { + "type": "string" + }, + "update_requested": { + "type": "boolean" + }, + "update_tag": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "oauth.AuthSourceView": { + "type": "object", + "properties": { + "client_secret_configured": { + "type": "boolean" + }, + "display_name": { + "type": "string" + }, + "icon_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "oauth.BasicUserInfo": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string" + }, + "bio": { + "type": "string" + }, + "email": { + "type": "string" + }, + "gender": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_admin": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "need_change_password": { + "type": "boolean" + }, + "nickname": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "username": { + "type": "string" + }, + "website": { + "type": "string" + } + } + }, + "oauth.CallbackRequest": { + "type": "object", + "required": [ + "code", + "state" + ], + "properties": { + "code": { + "type": "string" + }, + "state": { + "type": "string" + } + } + }, + "oauth.OAuthAuthorizeResponse": { + "type": "object", + "properties": { + "authorize_url": { + "type": "string" + } + } + }, + "oauth.OAuthCallbackResult": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "user": { + "$ref": "#/definitions/oauth.BasicUserInfo" + } + } + }, + "observability.AccessLogCleanupInput": { + "type": "object", + "properties": { + "retention_days": { + "type": "integer" + } + } + }, + "observability.AccessLogCleanupResult": { + "type": "object", + "properties": { + "cutoff": { + "type": "string" + }, + "deleted_count": { + "type": "integer" + }, + "retention_days": { + "type": "integer" + } + } + }, + "observability.AccessLogIPAnalysisSummary": { + "type": "object", + "properties": { + "bandwidth_served": { + "type": "integer" + }, + "bytes_received": { + "type": "integer" + }, + "error_count": { + "type": "integer" + }, + "total_requests": { + "type": "integer" + }, + "unique_hosts": { + "type": "integer" + }, + "unique_paths": { + "type": "integer" + } + } + }, + "observability.AccessLogIPAnalysisView": { + "type": "object", + "properties": { + "device_types": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "generated_at": { + "type": "string" + }, + "hours": { + "type": "integer" + }, + "remote_addr": { + "type": "string" + }, + "status_codes": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "summary": { + "$ref": "#/definitions/observability.AccessLogIPAnalysisSummary" + }, + "top_browsers": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "top_hosts": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "top_paths": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "top_user_agents": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + } + } + }, + "observability.AccessLogIPSummaryList": { + "type": "object", + "properties": { + "has_more": { + "type": "boolean" + }, + "hours": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.AccessLogIPSummaryView" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "since": { + "type": "string" + }, + "sort_by": { + "type": "string" + }, + "sort_order": { + "type": "string" + }, + "total_ip": { + "type": "integer" + }, + "until": { + "type": "string" + } + } + }, + "observability.AccessLogIPSummaryView": { + "type": "object", + "properties": { + "bytes_received": { + "type": "integer" + }, + "bytes_sent": { + "type": "integer" + }, + "last_seen_at": { + "type": "string" + }, + "recent_requests": { + "description": "RecentRequests is deprecated and always 0.", + "type": "integer" + }, + "region": { + "type": "string" + }, + "remote_addr": { + "type": "string" + }, + "success_2xx_count": { + "type": "integer" + }, + "success_ratio": { + "type": "number" + }, + "total_requests": { + "type": "integer" + } + } + }, + "observability.AccessLogIPTrendPoint": { + "type": "object", + "properties": { + "bucket_started_at": { + "type": "string" + }, + "request_count": { + "type": "integer" + } + } + }, + "observability.AccessLogIPTrendView": { + "type": "object", + "properties": { + "bucket_minutes": { + "type": "integer" + }, + "hours": { + "type": "integer" + }, + "points": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.AccessLogIPTrendPoint" + } + }, + "remote_addr": { + "type": "string" + } + } + }, + "observability.AccessLogList": { + "type": "object", + "properties": { + "has_more": { + "type": "boolean" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.AccessLogView" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total_ip": { + "type": "integer" + }, + "total_record": { + "type": "integer" + } + } + }, + "observability.AccessLogOverview": { + "type": "object", + "properties": { + "bucket_minutes": { + "type": "integer" + }, + "device_types": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "generated_at": { + "type": "string" + }, + "hours": { + "type": "integer" + }, + "status_codes": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "summary": { + "$ref": "#/definitions/observability.AccessLogOverviewSummary" + }, + "top_browsers": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "top_hosts": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "top_ips": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "top_operating_systems": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "top_paths": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "top_user_agents": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "trends": { + "$ref": "#/definitions/observability.AccessLogOverviewTrends" + } + } + }, + "observability.AccessLogOverviewMetricPoint": { + "type": "object", + "properties": { + "bucket_started_at": { + "type": "string" + }, + "value": { + "type": "integer" + } + } + }, + "observability.AccessLogOverviewSummary": { + "type": "object", + "properties": { + "bandwidth_served": { + "type": "integer" + }, + "total_requests": { + "type": "integer" + }, + "total_visits": { + "type": "integer" + } + } + }, + "observability.AccessLogOverviewTrends": { + "type": "object", + "properties": { + "bandwidth": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.AccessLogOverviewMetricPoint" + } + }, + "requests": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.AccessLogOverviewMetricPoint" + } + }, + "visits": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.AccessLogOverviewMetricPoint" + } + } + } + }, + "observability.AccessLogView": { + "type": "object", + "properties": { + "bytes_sent": { + "type": "integer" + }, + "cache_status": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "host": { + "type": "string" + }, + "id": { + "type": "string" + }, + "logged_at": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "node_name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "region": { + "type": "string" + }, + "remote_addr": { + "type": "string" + }, + "request_length": { + "type": "integer" + }, + "request_time_ms": { + "type": "integer" + }, + "status_code": { + "type": "integer" + }, + "user_agent": { + "type": "string" + } + } + }, + "observability.CapacityTrendPoint": { + "type": "object", + "properties": { + "average_cpu_usage_percent": { + "type": "number" + }, + "average_memory_usage_percent": { + "type": "number" + }, + "bucket_started_at": { + "type": "string" + }, + "reported_nodes": { + "type": "integer" + } + } + }, + "observability.DiskIOTrendPoint": { + "type": "object", + "properties": { + "bucket_started_at": { + "type": "string" + }, + "disk_read_bytes": { + "type": "integer" + }, + "disk_write_bytes": { + "type": "integer" + }, + "reported_nodes": { + "type": "integer" + } + } + }, + "observability.DistributionItem": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "integer" + } + } + }, + "observability.FoldedAccessLogIPList": { + "type": "object", + "properties": { + "bucket_started_at": { + "type": "string" + }, + "fold_minutes": { + "type": "integer" + }, + "has_more": { + "type": "boolean" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.FoldedAccessLogIPView" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "sort_by": { + "type": "string" + }, + "sort_order": { + "type": "string" + }, + "total_ip": { + "type": "integer" + } + } + }, + "observability.FoldedAccessLogIPView": { + "type": "object", + "properties": { + "client_error_count": { + "type": "integer" + }, + "last_seen_at": { + "type": "string" + }, + "remote_addr": { + "type": "string" + }, + "request_count": { + "type": "integer" + }, + "server_error_count": { + "type": "integer" + }, + "success_count": { + "type": "integer" + } + } + }, + "observability.FoldedAccessLogList": { + "type": "object", + "properties": { + "fold_minutes": { + "type": "integer" + }, + "has_more": { + "type": "boolean" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.FoldedAccessLogView" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total_bucket": { + "type": "integer" + }, + "total_ip": { + "type": "integer" + }, + "total_record": { + "type": "integer" + } + } + }, + "observability.FoldedAccessLogView": { + "type": "object", + "properties": { + "bucket_started_at": { + "type": "string" + }, + "client_error_count": { + "type": "integer" + }, + "request_count": { + "type": "integer" + }, + "server_error_count": { + "type": "integer" + }, + "success_count": { + "type": "integer" + }, + "unique_host_count": { + "type": "integer" + }, + "unique_ip_count": { + "type": "integer" + } + } + }, + "observability.HealthSummary": { + "type": "object", + "properties": { + "active_alerts": { + "type": "integer" + }, + "critical_alerts": { + "type": "integer" + }, + "has_capacity_risk": { + "type": "boolean" + }, + "has_runtime_risk": { + "type": "boolean" + }, + "has_traffic_risk": { + "type": "boolean" + }, + "info_alerts": { + "type": "integer" + }, + "resolved_alerts": { + "type": "integer" + }, + "warning_alerts": { + "type": "integer" + } + } + }, + "observability.NetworkTrendPoint": { + "type": "object", + "properties": { + "bucket_started_at": { + "type": "string" + }, + "bytes_provided": { + "description": "sum(bytes_sent)", + "type": "integer" + }, + "bytes_received": { + "description": "sum(request_length)", + "type": "integer" + }, + "reported_nodes": { + "type": "integer" + } + } + }, + "observability.NodeAnalytics": { + "type": "object", + "properties": { + "distributions": { + "$ref": "#/definitions/observability.TrafficDistributions" + }, + "health": { + "$ref": "#/definitions/observability.HealthSummary" + }, + "traffic": { + "$ref": "#/definitions/observability.TrafficWindowSummary" + } + } + }, + "observability.NodeMetricSnapshotView": { + "type": "object", + "properties": { + "captured_at": { + "type": "string" + }, + "cpu_usage_percent": { + "type": "number" + }, + "disk_read_bytes": { + "type": "integer" + }, + "disk_write_bytes": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "memory_total_bytes": { + "type": "integer" + }, + "memory_used_bytes": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "openresty_connections": { + "type": "integer" + }, + "storage_total_bytes": { + "type": "integer" + }, + "storage_used_bytes": { + "type": "integer" + } + } + }, + "observability.NodeTrends": { + "type": "object", + "properties": { + "capacity_24h": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.CapacityTrendPoint" + } + }, + "disk_io_24h": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DiskIOTrendPoint" + } + }, + "network_24h": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.NetworkTrendPoint" + } + }, + "traffic_24h": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.TrafficTrendPoint" + } + } + } + }, + "observability.RelayDashboardSnapshot": { + "type": "object", + "properties": { + "client_counts": { + "type": "integer" + }, + "offline_proxies": { + "type": "integer" + }, + "online_proxies": { + "type": "integer" + }, + "proxies": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.RelayProxyStat" + } + }, + "total_connections": { + "type": "integer" + }, + "total_proxies": { + "type": "integer" + } + } + }, + "observability.RelayProxyStat": { + "type": "object", + "properties": { + "client_addr": { + "type": "string" + }, + "client_version": { + "type": "string" + }, + "last_close_time": { + "type": "string" + }, + "last_start_time": { + "type": "string" + }, + "name": { + "type": "string" + }, + "status": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "observability.TrafficDistributions": { + "type": "object", + "properties": { + "source_countries": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "status_codes": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + }, + "top_domains": { + "type": "array", + "items": { + "$ref": "#/definitions/observability.DistributionItem" + } + } + } + }, + "observability.TrafficTrendPoint": { + "type": "object", + "properties": { + "bucket_started_at": { + "type": "string" + }, + "error_count": { + "type": "integer" + }, + "request_count": { + "type": "integer" + }, + "status_2xx_count": { + "type": "integer" + }, + "status_4xx_count": { + "type": "integer" + }, + "status_5xx_count": { + "type": "integer" + }, + "unique_visitor_count": { + "type": "integer" + } + } + }, + "observability.TrafficWindowSummary": { + "type": "object", + "properties": { + "error_count": { + "type": "integer" + }, + "error_rate_percent": { + "type": "number" + }, + "estimated_qps": { + "type": "number" + }, + "request_count": { + "type": "integer" + }, + "unique_visitor_count": { + "type": "integer" + }, + "window_ended_at": { + "type": "string" + }, + "window_started_at": { + "type": "string" + } + } + }, + "option.geoIPLookupRequest": { + "type": "object", + "properties": { + "ip": { + "type": "string" + }, + "provider": { + "type": "string" + } + } + }, + "option.geoIPLookupView": { + "type": "object", + "properties": { + "ip": { + "type": "string" + }, + "iso_code": { + "type": "string" + }, + "latitude": { + "type": "number" + }, + "longitude": { + "type": "number" + }, + "name": { + "type": "string" + }, + "provider": { + "type": "string" + } + } + }, + "option.optionBatchPayload": { + "type": "object", + "properties": { + "options": { + "type": "array", + "items": { + "$ref": "#/definitions/model.OpenFlareOption" + } + } + } + }, + "option.publicAuthSourceView": { + "type": "object", + "properties": { + "authorize_url": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "icon_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "option.statusView": { + "type": "object", + "properties": { + "auth_sources": { + "type": "array", + "items": { + "$ref": "#/definitions/option.publicAuthSourceView" + } + }, + "cap_login_enabled": { + "type": "boolean" + }, + "email_verification": { + "type": "boolean" + }, + "password_register_enabled": { + "type": "boolean" + }, + "server_address": { + "type": "string" + }, + "start_time": { + "type": "integer" + }, + "version": { + "type": "string" + } + } + }, + "origin.DetailView": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "remark": { + "type": "string" + }, + "route_count": { + "type": "integer" + }, + "routes": { + "type": "array", + "items": { + "$ref": "#/definitions/origin.RouteSummary" + } + }, + "updated_at": { + "type": "string" + } + } + }, + "origin.Input": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "name": { + "type": "string" + }, + "remark": { + "type": "string" + } + } + }, + "origin.RouteSummary": { + "type": "object", + "properties": { + "domain": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "origin_url": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "origin.View": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "remark": { + "type": "string" + }, + "route_count": { + "type": "integer" + }, + "updated_at": { + "type": "string" + } + } + }, + "pages.DeploymentFileView": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "deployment_id": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "path": { + "type": "string" + }, + "size": { + "type": "integer" + } + } + }, + "pages.DeploymentView": { + "type": "object", + "properties": { + "activated_at": { + "type": "string" + }, + "checksum": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "created_by": { + "type": "string" + }, + "deployment_number": { + "type": "integer" + }, + "file_count": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "project_id": { + "type": "integer" + }, + "source_label": { + "type": "string" + }, + "source_type": { + "type": "string" + }, + "status": { + "type": "string" + }, + "total_size": { + "type": "integer" + }, + "trigger_type": { + "type": "string" + }, + "upload_id": { + "type": "string", + "example": "0" + } + } + }, + "pages.Input": { + "type": "object", + "properties": { + "api_proxy_enabled": { + "type": "boolean" + }, + "api_proxy_pass": { + "type": "string" + }, + "api_proxy_path": { + "type": "string" + }, + "api_proxy_rewrite": { + "type": "string" + }, + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "entry_file": { + "type": "string" + }, + "name": { + "type": "string" + }, + "root_dir": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "spa_fallback_enabled": { + "type": "boolean" + }, + "spa_fallback_path": { + "type": "string" + } + } + }, + "pages.SourceActionReceipt": { + "type": "object", + "properties": { + "action": { + "type": "string" + }, + "execution_id": { + "type": "string" + }, + "task_id": { + "type": "string" + } + } + }, + "pages.SourceRevisionView": { + "type": "object", + "properties": { + "asset_name": { + "type": "string" + }, + "label": { + "type": "string" + }, + "revision": { + "type": "string" + } + } + }, + "pages.SourceSyncInput": { + "type": "object", + "properties": { + "confirmed_revision": { + "type": "string" + } + } + }, + "pages.SourceUpdateInput": { + "type": "object", + "properties": { + "allow_insecure": { + "type": "boolean" + }, + "asset_name": { + "type": "string" + }, + "auto_update_enabled": { + "type": "boolean" + }, + "check_interval_minutes": { + "type": "integer" + }, + "release_selector": { + "type": "string" + }, + "release_tag": { + "type": "string" + }, + "remote_url": { + "type": "string" + }, + "repository_url": { + "type": "string" + }, + "source_type": { + "type": "string" + } + } + }, + "pages.SourceUpdateResult": { + "type": "object", + "properties": { + "check_task": { + "$ref": "#/definitions/pages.SourceActionReceipt" + }, + "source": { + "$ref": "#/definitions/pages.SourceView" + }, + "warning": { + "type": "string" + } + } + }, + "pages.SourceView": { + "type": "object", + "properties": { + "allow_insecure": { + "type": "boolean" + }, + "asset_name": { + "type": "string" + }, + "auto_update_enabled": { + "type": "boolean" + }, + "check_interval_minutes": { + "type": "integer" + }, + "github_repository": { + "type": "string" + }, + "last_applied": { + "$ref": "#/definitions/pages.SourceRevisionView" + }, + "last_checked_at": { + "type": "string" + }, + "last_error": { + "type": "string" + }, + "last_seen": { + "$ref": "#/definitions/pages.SourceRevisionView" + }, + "last_synced_at": { + "type": "string" + }, + "next_check_at": { + "type": "string" + }, + "release_selector": { + "type": "string" + }, + "release_tag": { + "type": "string" + }, + "remote_url": { + "type": "string" + }, + "source_type": { + "type": "string" + }, + "sync_status": { + "type": "string" + }, + "update_available": { + "type": "boolean" + } + } + }, + "pages.UploadFromURLInput": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + } + }, + "pages.View": { + "type": "object", + "properties": { + "active_deployment": { + "$ref": "#/definitions/pages.DeploymentView" + }, + "active_deployment_id": { + "type": "integer" + }, + "api_proxy_enabled": { + "type": "boolean" + }, + "api_proxy_pass": { + "type": "string" + }, + "api_proxy_path": { + "type": "string" + }, + "api_proxy_rewrite": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "deployment_count": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "entry_file": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "root_dir": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "spa_fallback_enabled": { + "type": "boolean" + }, + "spa_fallback_path": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "protocol.AgentNodeHealthEvent": { + "type": "object", + "properties": { + "event_type": { + "type": "string" + }, + "message": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "severity": { + "type": "string" + }, + "triggered_at_unix": { + "type": "integer" + } + } + }, + "protocol.AgentNodeMetricSnapshot": { + "type": "object", + "properties": { + "captured_at_unix": { + "type": "integer" + }, + "cpu_usage_percent": { + "type": "number" + }, + "disk_read_bytes": { + "type": "integer" + }, + "disk_write_bytes": { + "type": "integer" + }, + "memory_total_bytes": { + "type": "integer" + }, + "memory_used_bytes": { + "type": "integer" + }, + "storage_total_bytes": { + "type": "integer" + }, + "storage_used_bytes": { + "type": "integer" + } + } + }, + "protocol.AgentNodeSystemProfile": { + "type": "object", + "properties": { + "architecture": { + "type": "string" + }, + "cpu_cores": { + "type": "integer" + }, + "cpu_model": { + "type": "string" + }, + "hostname": { + "type": "string" + }, + "kernel_version": { + "type": "string" + }, + "os_name": { + "type": "string" + }, + "os_version": { + "type": "string" + }, + "reported_at_unix": { + "type": "integer" + }, + "total_disk_bytes": { + "type": "integer" + }, + "total_memory_bytes": { + "type": "integer" + }, + "uptime_seconds": { + "type": "integer" + } + } + }, + "protocol.FlaredConnectedRelay": { + "type": "object", + "properties": { + "proxy_count": { + "type": "integer" + }, + "relay_node_id": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "protocol.FlaredProxyEntry": { + "type": "object", + "properties": { + "custom_domains": { + "type": "array", + "items": { + "type": "string" + } + }, + "local_addr": { + "type": "string" + }, + "local_port": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "protocol.FlaredRelayInfo": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "auth_token": { + "type": "string" + }, + "proxy_url": { + "type": "string" + }, + "relay_node_id": { + "type": "string" + } + } + }, + "protocol.RelayConfig": { + "type": "object", + "properties": { + "auth_token": { + "type": "string" + }, + "bind_port": { + "type": "integer" + }, + "log_level": { + "type": "string" + }, + "vhost_http_port": { + "type": "integer" + }, + "web_server_enabled": { + "type": "boolean" + }, + "web_server_port": { + "type": "integer" + } + } + }, + "protocol.RelayProxyStat": { + "type": "object", + "properties": { + "client_addr": { + "type": "string" + }, + "client_version": { + "type": "string" + }, + "last_close_time": { + "type": "string" + }, + "last_start_time": { + "type": "string" + }, + "name": { + "type": "string" + }, + "status": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "protocol.RelaySettings": { + "type": "object", + "properties": { + "auto_update": { + "type": "boolean" + }, + "heartbeat_interval": { + "type": "integer" + }, + "update_channel": { + "type": "string" + }, + "update_now": { + "type": "boolean" + }, + "update_repo": { + "type": "string" + }, + "update_tag": { + "type": "string" + }, + "websocket_upgrade_enabled": { + "type": "boolean" + } + } + }, + "proxy_route.CustomHeaderInput": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "proxy_route.Input": { + "type": "object", + "properties": { + "basic_auth_enabled": { + "type": "boolean" + }, + "basic_auth_password": { + "type": "string" + }, + "basic_auth_username": { + "type": "string" + }, + "cache_enabled": { + "type": "boolean" + }, + "cache_policy": { + "type": "string" + }, + "cache_rules": { + "type": "array", + "items": { + "type": "string" + } + }, + "custom_headers": { + "type": "array", + "items": { + "$ref": "#/definitions/proxy_route.CustomHeaderInput" + } + }, + "enable_https": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "limit_conn_per_ip": { + "type": "integer" + }, + "limit_conn_per_server": { + "type": "integer" + }, + "limit_rate": { + "type": "string" + }, + "limit_req_per_ip": { + "type": "string" + }, + "origin_address": { + "type": "string" + }, + "origin_host": { + "type": "string" + }, + "origin_id": { + "type": "integer" + }, + "origin_port": { + "type": "string" + }, + "origin_scheme": { + "type": "string" + }, + "origin_uri": { + "type": "string" + }, + "origin_url": { + "type": "string" + }, + "pages_project_id": { + "type": "integer" + }, + "redirect_http": { + "type": "boolean" + }, + "site_name": { + "type": "string" + }, + "tunnel_id": { + "type": "integer" + }, + "tunnel_node_id": { + "type": "integer" + }, + "tunnel_target_addr": { + "type": "string" + }, + "tunnel_target_protocol": { + "type": "string" + }, + "upstream_type": { + "type": "string" + }, + "upstreams": { + "type": "array", + "items": { + "type": "string" + } + }, + "zone_domain_ids": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "proxy_route.View": { + "type": "object", + "properties": { + "basic_auth_enabled": { + "type": "boolean" + }, + "basic_auth_password": { + "type": "string" + }, + "basic_auth_username": { + "type": "string" + }, + "cache_enabled": { + "type": "boolean" + }, + "cache_policy": { + "type": "string" + }, + "cache_rule_list": { + "type": "array", + "items": { + "type": "string" + } + }, + "cache_rules": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "custom_header_list": { + "type": "array", + "items": { + "$ref": "#/definitions/proxy_route.CustomHeaderInput" + } + }, + "custom_headers": { + "type": "string" + }, + "enable_https": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "limit_conn_per_ip": { + "type": "integer" + }, + "limit_conn_per_server": { + "type": "integer" + }, + "limit_rate": { + "type": "string" + }, + "limit_req_per_ip": { + "type": "string" + }, + "origin_host": { + "type": "string" + }, + "origin_id": { + "type": "integer" + }, + "origin_url": { + "type": "string" + }, + "pages_project_id": { + "type": "integer" + }, + "redirect_http": { + "type": "boolean" + }, + "site_name": { + "type": "string" + }, + "tunnel_id": { + "type": "integer" + }, + "tunnel_node_id": { + "type": "integer" + }, + "tunnel_target_addr": { + "type": "string" + }, + "tunnel_target_protocol": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "upstream_list": { + "type": "array", + "items": { + "type": "string" + } + }, + "upstream_type": { + "type": "string" + }, + "upstreams": { + "type": "string" + }, + "zone_domain_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "zone_domains": { + "type": "array", + "items": { + "$ref": "#/definitions/proxy_route.ZoneDomainView" + } + } + } + }, + "proxy_route.ZoneDomainView": { + "type": "object", + "properties": { + "cert_id": { + "type": "integer" + }, + "domain": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "zone_id": { + "type": "integer" + } + } + }, + "push.Config": { + "type": "object", + "properties": { + "channel": { + "description": "渠道名称,例如 \"lark\", \"custom\", \"email\" 等,唯一标识", + "type": "string" + }, + "ext": { + "description": "预留拓展 JSON 配置", + "type": "object", + "additionalProperties": {} + }, + "key": { + "description": "AppID 或 SMTP 用户名", + "type": "string" + }, + "secret": { + "description": "签名密钥或 SMTP 密码/Token", + "type": "string" + }, + "url": { + "description": "Webhook 地址或 SMTP 地址", + "type": "string" + } + } + }, + "push.CreateChannelRequest": { + "type": "object", + "required": [ + "name", + "type" + ], + "properties": { + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "other": { + "type": "string" + }, + "token": { + "type": "string" + }, + "type": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "push.CreateEventRequest": { + "type": "object", + "properties": { + "channels": { + "type": "array", + "items": { + "type": "string" + } + }, + "enabled": { + "type": "boolean" + }, + "event_key": { + "type": "string" + }, + "targets": { + "type": "array", + "items": { + "type": "string" + } + }, + "task_type": { + "description": "关联的异步任务类型", + "type": "string" + }, + "template": { + "type": "string" + } + } + }, + "push.Definition": { + "type": "object", + "properties": { + "description": { + "description": "short description", + "type": "string" + }, + "fields": { + "description": "form fields", + "type": "array", + "items": { + "$ref": "#/definitions/push.Field" + } + }, + "name": { + "description": "display name", + "type": "string" + }, + "type": { + "description": "channel type (e.g., custom, lark, email)", + "type": "string" + } + } + }, + "push.EventMetadata": { + "type": "object", + "properties": { + "default_template": { + "$ref": "#/definitions/push.NotificationMessage" + }, + "description": { + "type": "string" + }, + "key": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "push.Field": { + "type": "object", + "properties": { + "description": { + "description": "field explanation/help text", + "type": "string" + }, + "key": { + "description": "unique key for the field (e.g. url, token, other)", + "type": "string" + }, + "label": { + "description": "human readable label (e.g. \"Webhook 地址\")", + "type": "string" + }, + "placeholder": { + "description": "input placeholder", + "type": "string" + }, + "required": { + "description": "whether this field is required", + "type": "boolean" + }, + "type": { + "description": "input type: \"text\" | \"password\" | \"textarea\"", + "type": "string" + } + } + }, + "push.NotificationMessage": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "ext": { + "type": "object", + "additionalProperties": {} + }, + "level": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "push.TestChannelRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "other": { + "type": "string" + }, + "target": { + "type": "string" + }, + "token": { + "type": "string" + }, + "type": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "push.TestPushRequest": { + "type": "object", + "required": [ + "config" + ], + "properties": { + "config": { + "$ref": "#/definitions/push.Config" + }, + "target": { + "type": "string" + } + } + }, + "push.UpdateChannelRequest": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "other": { + "type": "string" + }, + "token": { + "type": "string" + }, + "type": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "push.UpdateEventRequest": { + "type": "object", + "required": [ + "template" + ], + "properties": { + "channels": { + "type": "array", + "items": { + "type": "string" + } + }, + "enabled": { + "type": "boolean" + }, + "targets": { + "type": "array", + "items": { + "type": "string" + } + }, + "template": { + "type": "string" + } + } + }, + "push.pushHistoriesResponse": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "$ref": "#/definitions/model.PushHistory" + } + }, + "total": { + "type": "integer" + } + } + }, + "relay.HeartbeatPayload": { + "type": "object", + "properties": { + "frp_version": { + "type": "string" + }, + "frps_client_count": { + "type": "integer" + }, + "frps_connections": { + "type": "integer" + }, + "frps_proxies": { + "type": "array", + "items": { + "$ref": "#/definitions/protocol.RelayProxyStat" + } + }, + "frps_proxy_count": { + "type": "integer" + }, + "health_events": { + "type": "array", + "items": { + "$ref": "#/definitions/protocol.AgentNodeHealthEvent" + } + }, + "ip": { + "type": "string" + }, + "name": { + "type": "string" + }, + "profile": { + "$ref": "#/definitions/protocol.AgentNodeSystemProfile" + }, + "relay_status": { + "type": "string" + }, + "snapshot": { + "$ref": "#/definitions/protocol.AgentNodeMetricSnapshot" + }, + "version": { + "type": "string" + } + } + }, + "relay.HeartbeatResponse": { + "type": "object", + "properties": { + "relay_config": { + "$ref": "#/definitions/protocol.RelayConfig" + }, + "relay_settings": { + "$ref": "#/definitions/protocol.RelaySettings" + } + } + }, + "response.Any": { + "type": "object", + "properties": { + "data": {}, + "error_msg": { + "type": "string", + "example": "" + } + } + }, + "status.DatabaseInfoResponse": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "status.LogDatabaseStatus": { + "type": "object", + "properties": { + "active_database": { + "type": "string" + }, + "available_targets": { + "type": "array", + "items": { + "type": "string" + } + }, + "clickhouse": { + "$ref": "#/definitions/Wavelet_OpenFlare_plugins_server_model_analytics.ClickHouseOperationalStats" + }, + "migration": { + "description": "idle | migrating", + "type": "string" + }, + "retention_days": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + } + } + }, + "status.SystemStatusResponse": { + "type": "object", + "properties": { + "alloc": { + "type": "string" + }, + "buck_hash_sys": { + "type": "string" + }, + "frees": { + "type": "integer" + }, + "gc_sys": { + "type": "string" + }, + "heap_alloc": { + "type": "string" + }, + "heap_idle": { + "type": "string" + }, + "heap_inuse": { + "type": "string" + }, + "heap_objects": { + "type": "integer" + }, + "heap_released": { + "type": "string" + }, + "heap_sys": { + "type": "string" + }, + "last_gc_time": { + "type": "string" + }, + "last_pause": { + "type": "string" + }, + "lookups": { + "type": "integer" + }, + "mallocs": { + "type": "integer" + }, + "mcache_inuse": { + "type": "string" + }, + "mcache_sys": { + "type": "string" + }, + "mspan_inuse": { + "type": "string" + }, + "mspan_sys": { + "type": "string" + }, + "next_gc": { + "type": "string" + }, + "num_gc": { + "type": "integer" + }, + "num_goroutine": { + "type": "integer" + }, + "other_sys": { + "type": "string" + }, + "pause_total_ns": { + "type": "string" + }, + "stack_inuse": { + "type": "string" + }, + "stack_sys": { + "type": "string" + }, + "sys": { + "type": "string" + }, + "total_alloc": { + "type": "string" + }, + "uptime": { + "type": "string" + } + } + }, + "system_config.CreateSystemConfigRequest": { + "type": "object", + "required": [ + "key", + "type", + "value" + ], + "properties": { + "description": { + "type": "string", + "maxLength": 255 + }, + "key": { + "type": "string", + "maxLength": 64 + }, + "type": { + "type": "string", + "enum": [ + "system", + "business" + ] + }, + "value": { + "type": "string" + }, + "visibility": { + "type": "integer", + "enum": [ + 0, + 1 + ] + } + } + }, + "system_config.TestSMTPRequest": { + "type": "object", + "required": [ + "smtp_host", + "smtp_password", + "smtp_port", + "smtp_username", + "to" + ], + "properties": { + "smtp_host": { + "type": "string", + "maxLength": 255 + }, + "smtp_password": { + "type": "string", + "maxLength": 255 + }, + "smtp_port": { + "type": "integer" + }, + "smtp_username": { + "type": "string", + "maxLength": 255 + }, + "to": { + "type": "string" + } + } + }, + "system_config.TestSMTPResponse": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "log": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + }, + "system_config.UpdateSystemConfigRequest": { + "type": "object", + "required": [ + "value" + ], + "properties": { + "description": { + "type": "string", + "maxLength": 255 + }, + "value": { + "type": "string" + }, + "visibility": { + "type": "integer", + "enum": [ + 0, + 1 + ] + } + } + }, + "task.CreateScheduleRequest": { + "type": "object", + "required": [ + "cron", + "is_active", + "name", + "task_type" + ], + "properties": { + "cron": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "payload": { + "type": "string" + }, + "task_type": { + "type": "string" + } + } + }, + "task.DispatchTaskRequest": { + "type": "object", + "required": [ + "task_type" + ], + "properties": { + "end_time": { + "type": "string" + }, + "payload": { + "type": "string" + }, + "start_time": { + "type": "string" + }, + "task_type": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + } + }, + "task.TaskMeta": { + "type": "object", + "properties": { + "asynq_task": { + "type": "string" + }, + "description": { + "type": "string" + }, + "max_retry": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "params": { + "type": "array", + "items": { + "$ref": "#/definitions/task.TaskParam" + } + }, + "queue": { + "type": "string" + }, + "retryable": { + "description": "是否支持手动重试", + "type": "boolean" + }, + "supports_time": { + "type": "boolean" + }, + "type": { + "type": "string" + } + } + }, + "task.TaskParam": { + "type": "object", + "properties": { + "description": { + "description": "描述", + "type": "string" + }, + "label": { + "description": "显示名称", + "type": "string" + }, + "name": { + "description": "参数键名", + "type": "string" + }, + "placeholder": { + "description": "占位符", + "type": "string" + }, + "required": { + "description": "是否必填", + "type": "boolean" + }, + "type": { + "description": "类型:string, text, number, boolean", + "type": "string" + } + } + }, + "task.UpdateScheduleRequest": { + "type": "object", + "required": [ + "cron", + "is_active", + "name", + "task_type" + ], + "properties": { + "cron": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "payload": { + "type": "string" + }, + "task_type": { + "type": "string" + } + } + }, + "template.CreateTemplateRequest": { + "type": "object", + "required": [ + "content", + "key", + "name", + "type" + ], + "properties": { + "content": { + "type": "string" + }, + "description": { + "type": "string", + "maxLength": 255 + }, + "key": { + "type": "string", + "maxLength": 80 + }, + "name": { + "type": "string", + "maxLength": 100 + }, + "subject": { + "type": "string", + "maxLength": 255 + }, + "type": { + "type": "string", + "maxLength": 20 + } + } + }, + "template.UpdateTemplateRequest": { + "type": "object", + "required": [ + "content", + "name", + "type" + ], + "properties": { + "content": { + "type": "string" + }, + "description": { + "type": "string", + "maxLength": 255 + }, + "name": { + "type": "string", + "maxLength": 100 + }, + "subject": { + "type": "string", + "maxLength": 255 + }, + "type": { + "type": "string", + "maxLength": 20 + } + } + }, + "tls.ApplyInput": { + "type": "object", + "properties": { + "acme_account_id": { + "type": "integer" + }, + "auto_renew": { + "type": "boolean" + }, + "disable_cname": { + "type": "boolean" + }, + "dns1": { + "type": "string" + }, + "dns2": { + "type": "string" + }, + "dns_account_id": { + "type": "integer" + }, + "key_algorithm": { + "type": "string" + }, + "name": { + "type": "string" + }, + "other_domains": { + "type": "string" + }, + "primary_domain": { + "type": "string" + }, + "remark": { + "type": "string" + }, + "skip_dns": { + "type": "boolean" + } + } + }, + "tls.CertificateContent": { + "type": "object", + "properties": { + "acme_account_id": { + "type": "integer" + }, + "apply_message": { + "type": "string" + }, + "apply_status": { + "type": "string" + }, + "auto_renew": { + "type": "boolean" + }, + "cert_pem": { + "type": "string" + }, + "disable_cname": { + "type": "boolean" + }, + "dns1": { + "type": "string" + }, + "dns2": { + "type": "string" + }, + "dns_account_id": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "key_algorithm": { + "type": "string" + }, + "key_pem": { + "type": "string" + }, + "name": { + "type": "string" + }, + "other_domains": { + "type": "string" + }, + "primary_domain": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "remark": { + "type": "string" + }, + "skip_dns": { + "type": "boolean" + } + } + }, + "tls.CertificateInput": { + "type": "object", + "properties": { + "cert_pem": { + "type": "string" + }, + "key_pem": { + "type": "string" + }, + "name": { + "type": "string" + }, + "remark": { + "type": "string" + } + } + }, + "tls.DNSAccountInput": { + "type": "object", + "properties": { + "authorization": { + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "updater.Status": { + "type": "object", + "properties": { + "asset_name": { + "type": "string" + }, + "build_time": { + "type": "string" + }, + "can_upgrade": { + "type": "boolean" + }, + "current_version": { + "type": "string" + }, + "latest_version": { + "type": "string" + }, + "platform": { + "type": "string" + }, + "prerelease": { + "type": "boolean" + }, + "published_at": { + "type": "string" + }, + "release_name": { + "type": "string" + }, + "release_notes": { + "type": "string" + }, + "release_url": { + "type": "string" + }, + "update_available": { + "type": "boolean" + }, + "upstream_repository": { + "type": "string" + } + } + }, + "user.changePasswordRequest": { + "type": "object", + "properties": { + "new_password": { + "type": "string" + }, + "old_password": { + "type": "string" + } + } + }, + "user.createTokenRequest": { + "type": "object", + "properties": { + "is_admin": { + "type": "boolean" + }, + "name": { + "type": "string" + } + } + }, + "user.createUserRequest": { + "type": "object", + "required": [ + "email", + "password", + "username" + ], + "properties": { + "email": { + "type": "string", + "maxLength": 255 + }, + "is_active": { + "type": "boolean" + }, + "is_admin": { + "type": "boolean" + }, + "nickname": { + "type": "string", + "maxLength": 64 + }, + "password": { + "type": "string", + "maxLength": 64, + "minLength": 8 + }, + "username": { + "type": "string", + "maxLength": 64, + "minLength": 3 + } + } + }, + "user.listUsersResponse": { + "type": "object", + "properties": { + "total": { + "type": "integer" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/definitions/user.user" + } + } + } + }, + "user.loginRequest": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "password": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "user.registerRequest": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "nickname": { + "type": "string" + }, + "password": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "user.sendEmailCodeRequest": { + "type": "object", + "required": [ + "email", + "scene" + ], + "properties": { + "email": { + "type": "string" + }, + "scene": { + "type": "string" + } + } + }, + "user.tokenResponse": { + "type": "object", + "properties": { + "record": { + "$ref": "#/definitions/model.AccessToken" + }, + "token": { + "type": "string" + } + } + }, + "user.updateProfileRequest": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string" + }, + "bio": { + "type": "string" + }, + "email": { + "type": "string" + }, + "gender": { + "type": "string" + }, + "location": { + "type": "string" + }, + "nickname": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "website": { + "type": "string" + } + } + }, + "user.updateUserRequest": { + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string", + "maxLength": 255 + }, + "is_admin": { + "type": "boolean" + }, + "nickname": { + "type": "string", + "maxLength": 64 + }, + "password": { + "type": "string", + "maxLength": 64, + "minLength": 8 + } + } + }, + "user.updateUserStatusRequest": { + "type": "object", + "properties": { + "is_active": { + "type": "boolean" + } + } + }, + "user.user": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string" + }, + "bio": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "gender": { + "type": "string" + }, + "id": { + "type": "string", + "example": "0" + }, + "is_active": { + "type": "boolean" + }, + "is_admin": { + "type": "boolean" + }, + "last_login_at": { + "type": "string" + }, + "location": { + "type": "string" + }, + "nickname": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "username": { + "type": "string" + }, + "website": { + "type": "string" + } + } + }, + "waf.CreateRuleInput": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, + "waf.IDsRequest": { + "type": "object", + "properties": { + "ids": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "waf.IPGroupAutoTestInput": { + "type": "object", + "properties": { + "auto_config": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "waf.IPGroupAutoTestResult": { + "type": "object", + "properties": { + "lookback": { + "type": "string" + }, + "matched_count": { + "type": "integer" + }, + "matched_ips": { + "type": "array", + "items": { + "type": "string" + } + }, + "rule_count": { + "type": "integer" + }, + "tested_at": { + "type": "string" + } + } + }, + "waf.IPGroupExtIPView": { + "type": "object", + "properties": { + "captured_at": { + "type": "string" + }, + "ip": { + "type": "string" + } + } + }, + "waf.IPGroupInput": { + "type": "object", + "properties": { + "auto_config": { + "type": "array", + "items": { + "type": "integer" + } + }, + "enabled": { + "type": "boolean" + }, + "ip_list": { + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "type": "string" + }, + "subscription_format": { + "type": "string" + }, + "subscription_mapping_rule": { + "type": "string" + }, + "subscription_url": { + "type": "string" + }, + "sync_interval_minutes": { + "type": "integer" + }, + "type": { + "type": "string" + } + } + }, + "waf.IPGroupSyncResult": { + "type": "object", + "properties": { + "group": { + "$ref": "#/definitions/waf.IPGroupView" + }, + "ip_count": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "next_sync_at": { + "type": "string" + }, + "status": { + "type": "string" + }, + "synced_at": { + "type": "string" + } + } + }, + "waf.IPGroupView": { + "type": "object", + "properties": { + "auto_config": { + "type": "array", + "items": { + "type": "integer" + } + }, + "created_at": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "ext_ips": { + "type": "array", + "items": { + "$ref": "#/definitions/waf.IPGroupExtIPView" + } + }, + "id": { + "type": "integer" + }, + "ip_list": { + "type": "array", + "items": { + "type": "string" + } + }, + "last_sync_message": { + "type": "string" + }, + "last_sync_status": { + "type": "string" + }, + "last_synced_at": { + "type": "string" + }, + "name": { + "type": "string" + }, + "next_sync_at": { + "type": "string" + }, + "referenced_by_rule_count": { + "type": "integer" + }, + "subscription_format": { + "type": "string" + }, + "subscription_mapping_rule": { + "type": "string" + }, + "subscription_url": { + "type": "string" + }, + "sync_interval_minutes": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "waf.RuleEdge": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "source": { + "type": "string" + }, + "source_handle": { + "type": "string" + }, + "target": { + "type": "string" + } + } + }, + "waf.RuleGraph": { + "type": "object", + "properties": { + "edges": { + "type": "array", + "items": { + "$ref": "#/definitions/waf.RuleEdge" + } + }, + "nodes": { + "type": "array", + "items": { + "$ref": "#/definitions/waf.RuleNode" + } + }, + "schema_version": { + "type": "integer" + } + } + }, + "waf.RuleNode": { + "type": "object", + "properties": { + "config": { + "type": "array", + "items": { + "type": "integer" + } + }, + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "position": { + "$ref": "#/definitions/waf.RulePosition" + }, + "type": { + "$ref": "#/definitions/waf.RuleNodeType" + } + } + }, + "waf.RuleNodeType": { + "type": "string", + "enum": [ + "start", + "allow", + "block", + "ip_match", + "geo_match", + "pow", + "ua_check", + "security_check" + ], + "x-enum-varnames": [ + "RuleNodeStart", + "RuleNodeAllow", + "RuleNodeBlock", + "RuleNodeIPMatch", + "RuleNodeGeoMatch", + "RuleNodePoW", + "RuleNodeUACheck", + "RuleNodeSecurityCheck" + ] + }, + "waf.RulePosition": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + } + }, + "waf.RuleView": { + "type": "object", + "properties": { + "applied_site_count": { + "type": "integer" + }, + "applied_site_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "created_at": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "graph": { + "$ref": "#/definitions/waf.RuleGraph" + }, + "id": { + "type": "integer" + }, + "is_global": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "revision": { + "type": "integer" + }, + "updated_at": { + "type": "string" + } + } + }, + "waf.SaveRuleGraphInput": { + "type": "object", + "properties": { + "graph": { + "$ref": "#/definitions/waf.RuleGraph" + }, + "revision": { + "type": "integer" + } + } + }, + "waf.SiteRuleGroupsView": { + "type": "object", + "properties": { + "applied_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "applied_rule_groups": { + "type": "array", + "items": { + "$ref": "#/definitions/waf.RuleView" + } + }, + "global_rule_group": { + "$ref": "#/definitions/waf.RuleView" + }, + "route_id": { + "type": "integer" + }, + "rule_groups": { + "type": "array", + "items": { + "$ref": "#/definitions/waf.RuleView" + } + } + } + }, + "waf.UpdateRuleMetaInput": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "name": { + "type": "string" + } + } + }, + "zone.DomainInput": { + "type": "object", + "properties": { + "cert_id": { + "type": "integer" + }, + "domain": { + "type": "string" + } + } + }, + "zone.Input": { + "type": "object", + "properties": { + "domain": { + "type": "string" + } + } + }, + "zone.ListItem": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "domain": { + "type": "string" + }, + "domain_count": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "updated_at": { + "type": "string" + } + } + }, + "zone.Overview": { + "type": "object", + "properties": { + "domains": { + "type": "array", + "items": { + "$ref": "#/definitions/model.ZoneDomain" + } + }, + "zone": { + "$ref": "#/definitions/model.Zone" + } + } + }, + "zone.Stats": { + "type": "object", + "properties": { + "available": { + "type": "boolean" + }, + "bucket_minutes": { + "type": "integer" + }, + "bytes_sent": { + "type": "integer" + }, + "domain_count": { + "type": "integer" + }, + "range": { + "$ref": "#/definitions/zone.StatsRange" + }, + "range_hours": { + "type": "integer" + }, + "request_count": { + "type": "integer" + }, + "series": { + "type": "array", + "items": { + "$ref": "#/definitions/zone.StatsPoint" + } + }, + "unique_visitors": { + "type": "integer" + }, + "window_ended_at": { + "type": "string" + }, + "window_started_at": { + "type": "string" + } + } + }, + "zone.StatsPoint": { + "type": "object", + "properties": { + "bucket_started_at": { + "type": "string" + }, + "bytes_sent": { + "type": "integer" + }, + "request_count": { + "type": "integer" + }, + "unique_visitors": { + "type": "integer" + } + } + }, + "zone.StatsRange": { + "type": "string", + "enum": [ + "24h", + "7d", + "30d" + ], + "x-enum-varnames": [ + "StatsRange24h", + "StatsRange7d", + "StatsRange30d" + ] + } + }, + "securityDefinitions": { + "SessionCookie": { + "type": "apiKey", + "name": "session", + "in": "cookie" + } + } +} \ No newline at end of file diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml new file mode 100644 index 00000000..47fc0e58 --- /dev/null +++ b/backend/docs/swagger.yaml @@ -0,0 +1,13083 @@ +basePath: / +definitions: + Wavelet_OpenFlare_plugins_server_cap.ChallengeResponse: + properties: + challenge: + properties: + c: + type: integer + d: + type: integer + s: + type: integer + type: object + expires: + description: ms timestamp + type: integer + token: + type: string + type: object + Wavelet_OpenFlare_plugins_server_cap.RedeemResponse: + properties: + error: + type: string + expires: + type: integer + success: + type: boolean + token: + type: string + type: object + Wavelet_OpenFlare_plugins_server_model_analytics.ClickHouseOperationalStats: + properties: + active_parts: + type: integer + async_insert_bytes: + type: integer + async_insert_queue: + type: integer + batch_writers: + description: BatchWriters reports in-process queue depth/drops/flush errors + for CH writers. + items: + $ref: '#/definitions/analytics.BatchWriterStats' + type: array + database: + type: string + pending_mutations: + type: integer + total_rows: + type: integer + type: object + Wavelet_OpenFlare_share_protocol.ActiveConfigMeta: + properties: + checksum: + type: string + version: + type: string + type: object + Wavelet_OpenFlare_share_protocol.BufferedObservabilityRecord: + properties: + access_logs: + items: + $ref: '#/definitions/Wavelet_OpenFlare_share_protocol.NodeAccessLog' + type: array + captured_at_unix: + type: integer + edge_health: + $ref: '#/definitions/Wavelet_OpenFlare_share_protocol.NodeEdgeHealth' + host_metrics: + $ref: '#/definitions/Wavelet_OpenFlare_share_protocol.NodeMetricSnapshot' + type: object + Wavelet_OpenFlare_share_protocol.NodeAccessLog: + properties: + bytes_sent: + description: body bytes = 已提供数据 + type: integer + cache_status: + description: $upstream_cache_status + type: string + host: + type: string + logged_at_unix: + type: integer + path: + type: string + remote_addr: + type: string + request_length: + description: 接收数据 + type: integer + request_time_ms: + description: optional + type: integer + status_code: + type: integer + user_agent: + type: string + type: object + Wavelet_OpenFlare_share_protocol.NodeEdgeHealth: + properties: + captured_at_unix: + type: integer + connections: + type: integer + message: + type: string + status: + type: string + type: object + Wavelet_OpenFlare_share_protocol.NodeHealthEvent: + properties: + event_type: + type: string + message: + type: string + metadata: + additionalProperties: + type: string + type: object + severity: + type: string + triggered_at_unix: + type: integer + type: object + Wavelet_OpenFlare_share_protocol.NodeMetricSnapshot: + properties: + captured_at_unix: + type: integer + cpu_usage_percent: + type: number + disk_read_bytes: + type: integer + disk_write_bytes: + type: integer + memory_total_bytes: + type: integer + memory_used_bytes: + type: integer + storage_total_bytes: + type: integer + storage_used_bytes: + type: integer + type: object + Wavelet_OpenFlare_share_protocol.NodeSystemProfile: + properties: + architecture: + type: string + cpu_cores: + type: integer + cpu_model: + type: string + hostname: + type: string + kernel_version: + type: string + os_name: + type: string + os_version: + type: string + reported_at_unix: + type: integer + total_disk_bytes: + type: integer + total_memory_bytes: + type: integer + uptime_seconds: + type: integer + type: object + Wavelet_OpenFlare_share_protocol.PagesDeploymentHashResponse: + properties: + deployment_id: + type: integer + hash: + type: string + type: object + Wavelet_OpenFlare_share_protocol.PagesProjectLatestHashResponse: + properties: + deployment_id: + type: integer + file_count: + type: integer + hash: + type: string + package_size: + type: integer + project_id: + type: integer + total_size: + type: integer + type: object + Wavelet_OpenFlare_share_protocol.WAFIPGroup: + properties: + checksum: + type: string + enabled: + type: boolean + id: + type: integer + ip_list: + items: + type: string + type: array + name: + type: string + type: + type: string + type: object + agent.ActiveConfigMeta: + properties: + checksum: + type: string + version: + type: string + type: object + agent.ApplyLogPayload: + properties: + checksum: + type: string + main_config_checksum: + type: string + message: + type: string + node_id: + type: string + result: + type: string + route_config_checksum: + type: string + support_file_count: + type: integer + version: + type: string + type: object + agent.ConfigResponse: + properties: + checksum: + type: string + created_at: + type: string + source_config_json: + type: string + support_files: + items: + $ref: '#/definitions/agent.SupportFile' + type: array + version: + type: string + type: object + agent.HeartbeatResponse: + properties: + active_config: + $ref: '#/definitions/agent.ActiveConfigMeta' + agent_settings: + $ref: '#/definitions/agent.Settings' + node: + $ref: '#/definitions/model.OpenFlareNode' + waf_ip_groups: + items: + $ref: '#/definitions/agent.WAFIPGroup' + type: array + type: object + agent.NodePayload: + properties: + access_logs: + items: + $ref: '#/definitions/Wavelet_OpenFlare_share_protocol.NodeAccessLog' + type: array + buffered: + items: + $ref: '#/definitions/Wavelet_OpenFlare_share_protocol.BufferedObservabilityRecord' + type: array + current_version: + type: string + edge_health: + $ref: '#/definitions/Wavelet_OpenFlare_share_protocol.NodeEdgeHealth' + ext_version: + type: string + health_events: + items: + $ref: '#/definitions/Wavelet_OpenFlare_share_protocol.NodeHealthEvent' + type: array + host_metrics: + $ref: '#/definitions/Wavelet_OpenFlare_share_protocol.NodeMetricSnapshot' + ip: + type: string + last_error: + type: string + name: + type: string + node_id: + type: string + openresty_message: + type: string + openresty_status: + type: string + profile: + $ref: '#/definitions/Wavelet_OpenFlare_share_protocol.NodeSystemProfile' + schema_version: + type: integer + version: + type: string + waf_ip_group_checksums: + additionalProperties: + type: string + type: object + type: object + agent.RegistrationResponse: + properties: + access_token: + type: string + name: + type: string + node_id: + type: string + type: object + agent.Settings: + properties: + auto_update: + type: boolean + heartbeat_interval: + type: integer + restart_openresty_now: + type: boolean + update_channel: + type: string + update_now: + type: boolean + update_repo: + type: string + update_tag: + type: string + websocket_upgrade_enabled: + type: boolean + type: object + agent.SupportFile: + properties: + content: + type: string + path: + type: string + type: object + agent.WAFIPGroup: + properties: + checksum: + type: string + enabled: + type: boolean + id: + type: integer + ip_list: + items: + type: string + type: array + name: + type: string + type: + type: string + type: object + agent.WAFIPGroupSyncInput: + properties: + checksums: + additionalProperties: + type: string + type: object + ids: + items: + type: integer + type: array + type: object + agent.WAFIPGroupSyncResult: + properties: + groups: + items: + $ref: '#/definitions/Wavelet_OpenFlare_share_protocol.WAFIPGroup' + type: array + type: object + analytics.BatchWriterStats: + properties: + cap: + type: integer + depth: + type: integer + drops: + type: integer + flush_errors: + type: integer + name: + type: string + running: + type: boolean + type: object + apply_log.CleanupInput: + properties: + delete_all: + type: boolean + retention_days: + type: integer + type: object + apply_log.CleanupResult: + properties: + cutoff: + type: string + delete_all: + type: boolean + deleted_count: + type: integer + retention_days: + type: integer + type: object + apply_log.ListResult: + properties: + current: + type: integer + rows: + items: + $ref: '#/definitions/model.OpenFlareApplyLog' + type: array + total: + type: integer + totalPage: + type: integer + type: object + auth_source.AuthSourceRequest: + properties: + client_id: + type: string + client_secret: + type: string + display_name: + type: string + icon_url: + type: string + is_active: + type: boolean + name: + type: string + openid_discovery_url: + type: string + scopes: + type: string + type: + type: string + type: object + auth_source.ToggleAuthSourceRequest: + properties: + is_active: + type: boolean + type: object + cache.updateCacheConfigRequest: + properties: + lru_enabled: + type: boolean + max_size_mb: + minimum: 1 + type: integer + ttl_minutes: + minimum: 0 + type: integer + required: + - max_size_mb + - ttl_minutes + type: object + cap.challengeRequest: + properties: + scope: + type: string + type: object + cap.redeemRequest: + properties: + scope: + type: string + solutions: + items: + type: integer + type: array + token: + type: string + required: + - solutions + - token + type: object + cloudflare.AvailableDomain: + properties: + domain: + type: string + id: + type: integer + zone_domain: + type: string + zone_id: + type: integer + type: object + cloudflare.ConnectionInput: + properties: + api_token: + type: string + dns_account_id: + type: integer + source: + type: string + type: object + cloudflare.ConnectionView: + properties: + configured: + type: boolean + dns_account_id: + type: integer + ready: + type: boolean + source: + type: string + status: + type: string + verified_at: + type: string + type: object + cloudflare.GroupDetail: + properties: + group: + $ref: '#/definitions/cloudflare.GroupItem' + members: + items: + $ref: '#/definitions/cloudflare.MemberItem' + type: array + type: object + cloudflare.GroupInput: + properties: + backup_node_id: + type: integer + default_proxied: + type: boolean + enabled: + type: boolean + name: + type: string + primary_node_id: + type: integer + type: object + cloudflare.GroupItem: + properties: + active_node: + $ref: '#/definitions/cloudflare.NodeOption' + backup_node: + $ref: '#/definitions/cloudflare.NodeOption' + created_at: + type: string + default_proxied: + type: boolean + enabled: + type: boolean + id: + type: integer + member_count: + type: integer + name: + type: string + primary_node: + $ref: '#/definitions/cloudflare.NodeOption' + updated_at: + type: string + type: object + cloudflare.MemberCreateInput: + properties: + proxied: + type: boolean + zone_domain_id: + type: integer + type: object + cloudflare.MemberItem: + properties: + desired_ip: + type: string + domain: + type: string + group_id: + type: integer + id: + type: integer + last_error: + type: string + proxied: + type: boolean + sync_status: + type: string + synced_at: + type: string + zone_domain_id: + type: integer + zone_id: + type: integer + type: object + cloudflare.MemberUpdateInput: + properties: + proxied: + type: boolean + type: object + cloudflare.NodeOption: + properties: + id: + type: integer + ip: + type: string + name: + type: string + type: object + cloudflare.Overview: + properties: + connection: + $ref: '#/definitions/cloudflare.ConnectionView' + error_count: + type: integer + group_count: + type: integer + member_count: + type: integer + ok_count: + type: integer + pending_count: + type: integer + type: object + cloudflare.SyncReceipt: + properties: + task_id: + type: string + type: object + config_version.CleanupInput: + properties: + keep_count: + type: integer + type: object + config_version.CleanupResult: + properties: + deleted_count: + type: integer + message: + type: string + type: object + config_version.ConfigDiffResult: + properties: + active_version: + type: string + active_website_count: + type: integer + added_domains: + items: + type: string + type: array + added_sites: + items: + type: string + type: array + changed_option_details: + items: + $ref: '#/definitions/config_version.ConfigOptionDiffItem' + type: array + changed_option_keys: + items: + type: string + type: array + current_website_count: + type: integer + main_config_changed: + type: boolean + modified_domains: + items: + type: string + type: array + modified_sites: + items: + type: string + type: array + removed_domains: + items: + type: string + type: array + removed_sites: + items: + type: string + type: array + waf_config_changed: + type: boolean + type: object + config_version.ConfigOptionDiffItem: + properties: + current_value: + type: string + key: + type: string + previous_value: + type: string + type: object + config_version.ConfigPreviewResult: + properties: + checksum: + type: string + main_config: + type: string + rendered_config: + type: string + route_config: + type: string + route_count: + type: integer + snapshot_json: + type: string + support_files: + items: + $ref: '#/definitions/config_version.SupportFile' + type: array + website_count: + type: integer + type: object + config_version.SupportFile: + properties: + content: + type: string + path: + type: string + type: object + dashboard.Capacity: + properties: + average_cpu_usage_percent: + type: number + average_memory_usage_percent: + type: number + high_cpu_nodes: + type: integer + high_memory_nodes: + type: integer + high_storage_nodes: + type: integer + type: object + dashboard.OverviewPayload: + properties: + capacity: + $ref: '#/definitions/dashboard.Capacity' + distributions: + $ref: '#/definitions/dashboard.distributionsPayload' + generated_at: {} + nodes: + items: + items: {} + type: array + type: array + summary: + $ref: '#/definitions/dashboard.Summary' + traffic: + $ref: '#/definitions/dashboard.Traffic' + trends: + $ref: '#/definitions/dashboard.trendsPayload' + type: object + dashboard.Summary: + properties: + offline_nodes: + type: integer + online_nodes: + type: integer + pending_nodes: + type: integer + total_nodes: + type: integer + unhealthy_nodes: + type: integer + type: object + dashboard.Traffic: + properties: + error_count: + type: integer + estimated_qps: + type: number + reported_nodes: + type: integer + request_count: + type: integer + unique_visitors: + type: integer + type: object + dashboard.distributionsPayload: + properties: + source_countries: + items: + items: {} + type: array + type: array + status_codes: + items: + items: {} + type: array + type: array + top_domains: + items: + items: {} + type: array + type: array + type: object + dashboard.trendsPayload: + properties: + capacity_24h: + items: + items: {} + type: array + type: array + disk_io_24h: + items: + items: {} + type: array + type: array + network_24h: + items: + items: {} + type: array + type: array + traffic_24h: + items: + items: {} + type: array + type: array + type: object + db_manage.DBOverviewResponse: + properties: + connections: + type: integer + name: + type: string + size: + type: string + table_count: + type: integer + type: + type: string + version: + type: string + type: object + db_manage.ExecuteSQLRequest: + properties: + sql: + type: string + required: + - sql + type: object + db_manage.ExecuteSQLResponse: + properties: + affected_rows: + type: integer + columns: + items: + type: string + type: array + execution_time_ms: + type: integer + results: + items: + additionalProperties: {} + type: object + type: array + type: + description: '"select" 或 "exec"' + type: string + type: object + diskcache.Status: + properties: + base_path: + type: string + keys_count: + type: integer + lru_enabled: + type: boolean + max_size_mb: + type: integer + total_size: + type: integer + ttl_minutes: + type: integer + type: object + flared.ApplyLogPayload: + properties: + checksum: + type: string + main_config_checksum: + type: string + message: + type: string + node_id: + type: string + result: + type: string + route_config_checksum: + type: string + support_file_count: + type: integer + version: + type: string + type: object + flared.HeartbeatPayload: + properties: + client_version: + type: string + connected_relays: + items: + $ref: '#/definitions/protocol.FlaredConnectedRelay' + type: array + current_checksum: + type: string + current_version: + type: string + frp_version: + type: string + ip: + type: string + tunnel_status: + type: string + type: object + flared.HeartbeatResponse: + properties: + active_config: + $ref: '#/definitions/Wavelet_OpenFlare_share_protocol.ActiveConfigMeta' + tunnel_settings: + $ref: '#/definitions/protocol.RelaySettings' + type: object + flared.TunnelConfigResponse: + properties: + checksum: + type: string + proxies: + items: + $ref: '#/definitions/protocol.FlaredProxyEntry' + type: array + relays: + items: + $ref: '#/definitions/protocol.FlaredRelayInfo' + type: array + version: + type: string + type: object + handler.batchDownloadRequest: + properties: + ids: + items: + type: string + minItems: 1 + type: array + required: + - ids + type: object + handler.distributionItem: + properties: + count: + type: integer + name: + type: string + size: + type: integer + type: object + handler.fileStatsResponse: + properties: + categories: + items: + $ref: '#/definitions/handler.distributionItem' + type: array + total_count: + type: integer + total_size: + type: integer + trend: + items: + $ref: '#/definitions/handler.trendItem' + type: array + types: + items: + $ref: '#/definitions/handler.distributionItem' + type: array + type: object + handler.listFilesResponse: + properties: + items: + items: + $ref: '#/definitions/model.Upload' + type: array + page: + type: integer + page_size: + type: integer + total: + type: integer + type: object + handler.listMyFilesResponse: + properties: + items: + items: + $ref: '#/definitions/model.Upload' + type: array + page: + type: integer + page_size: + type: integer + total: + type: integer + type: object + handler.trendItem: + properties: + count: + type: integer + date: + type: string + size: + type: integer + type: object + handler.updateMyFileRequest: + properties: + access_mode: + enum: + - 0 + - 1 + type: integer + file_name: + maxLength: 255 + type: string + type: object + logger.LogEntry: + properties: + data: + description: 一行日志原文(含换行符) + type: string + index: + description: 全局递增序号 + type: integer + type: object + logs.accessLogItem: + properties: + created_at: + type: string + headers: + type: string + id: + example: "0" + type: string + ip: + type: string + latency: + type: integer + method: + type: string + nickname: + type: string + path: + type: string + status: + type: integer + user_agent: + type: string + user_id: + example: "0" + type: string + username: + type: string + type: object + logs.accessLogsResponse: + properties: + list: + items: + $ref: '#/definitions/logs.accessLogItem' + type: array + total: + type: integer + type: object + logs.browserItem: + properties: + browser: + type: string + count: + type: integer + type: object + logs.logsAnalyticsResponse: + properties: + browsers: + items: + $ref: '#/definitions/logs.browserItem' + type: array + top_users: + items: + $ref: '#/definitions/logs.topUserItem' + type: array + trend: + items: + $ref: '#/definitions/logs.trendItem' + type: array + type: object + logs.logsResponse: + properties: + has_more: + type: boolean + lines: + items: + $ref: '#/definitions/logger.LogEntry' + type: array + next_cursor: + description: 用于加载更早日志的 cursor + type: integer + type: object + logs.topUserItem: + properties: + count: + type: integer + nickname: + type: string + user_id: + example: "0" + type: string + username: + type: string + type: object + logs.trendItem: + properties: + count: + type: integer + date: + type: string + type: object + model.AccessToken: + properties: + created_at: + type: string + id: + type: integer + is_admin: + type: boolean + masked_token: + type: string + name: + type: string + updated_at: + type: string + user_id: + type: integer + type: object + model.AcmeAccount: + properties: + created_at: + type: string + email: + type: string + id: + type: integer + updated_at: + type: string + url: + type: string + type: object + model.AuthSource: + properties: + client_id: + type: string + client_secret_configured: + type: boolean + created_at: + type: string + display_name: + type: string + icon_url: + type: string + id: + type: integer + is_active: + type: boolean + name: + type: string + openid_discovery_url: + type: string + scopes: + type: string + type: + type: string + updated_at: + type: string + type: object + model.ConfigVersion: + properties: + checksum: + type: string + created_at: + type: string + created_by: + type: string + id: + type: string + is_active: + type: boolean + main_config: + type: string + rendered_config: + type: string + snapshot_json: + type: string + support_files_json: + type: string + version: + type: string + type: object + model.ConfigVersionSummary: + properties: + checksum: + type: string + created_at: + type: string + created_by: + type: string + id: + type: string + is_active: + type: boolean + version: + type: string + type: object + model.DNSAccount: + properties: + created_at: + type: string + id: + type: integer + name: + type: string + type: + type: string + updated_at: + type: string + type: object + model.ExternalAccountView: + properties: + auth_source_id: + type: integer + auth_source_label: + type: string + auth_source_name: + type: string + auth_source_type: + type: string + created_at: + type: string + email: + type: string + external_username: + type: string + id: + type: integer + type: object + model.OpenFlareApplyLog: + properties: + checksum: + type: string + created_at: + type: string + id: + type: integer + main_config_checksum: + type: string + message: + type: string + node_id: + type: string + result: + type: string + route_config_checksum: + type: string + support_file_count: + type: integer + version: + type: string + type: object + model.OpenFlareHealthEvent: + properties: + created_at: + type: string + event_type: + type: string + first_triggered_at: + type: string + id: + type: integer + last_triggered_at: + type: string + message: + type: string + metadata_json: + type: string + node_id: + type: string + reported_at: + type: string + resolved_at: + type: string + severity: + type: string + status: + type: string + updated_at: + type: string + type: object + model.OpenFlareNode: + properties: + auto_update_enabled: + type: boolean + capabilities_json: + type: string + created_at: + type: string + current_version: + type: string + ext_version: + type: string + geo_latitude: + type: number + geo_longitude: + type: number + geo_manual_override: + type: boolean + geo_name: + type: string + id: + type: integer + ip: + type: string + ip_manual_override: + type: boolean + last_error: + type: string + last_seen_at: + type: string + name: + type: string + node_id: + type: string + node_type: + type: string + openresty_message: + type: string + openresty_status: + type: string + relay_agent_access_addr: + type: string + relay_bind_port: + type: integer + relay_client_access_addr: + type: string + relay_client_proxy_url: + type: string + relay_status: + type: string + relay_vhost_http_port: + type: integer + relay_web_server_enabled: + type: boolean + restart_openresty_requested: + type: boolean + status: + type: string + update_channel: + type: string + update_requested: + type: boolean + update_tag: + type: string + updated_at: + type: string + version: + type: string + type: object + model.OpenFlareNodeSystemProfile: + properties: + architecture: + type: string + cpu_cores: + type: integer + cpu_model: + type: string + created_at: + type: string + hostname: + type: string + id: + type: integer + kernel_version: + type: string + node_id: + type: string + os_name: + type: string + os_version: + type: string + reported_at: + type: string + total_disk_bytes: + type: integer + total_memory_bytes: + type: integer + updated_at: + type: string + uptime_seconds: + type: integer + type: object + model.OpenFlareOption: + properties: + key: + type: string + value: + type: string + type: object + model.PushChannel: + properties: + created_at: + type: string + description: + description: 备注 + type: string + enabled: + description: 通道是否启用 + type: boolean + id: + type: integer + name: + description: 通道名称,仅英文字母和下划线,唯一 + type: string + other: + description: 请求体/SMTP 密码等 + type: string + token: + description: 鉴权令牌或发信用户名等 + type: string + type: + description: 通道类型:custom, lark, email + type: string + updated_at: + type: string + url: + description: 请求地址,HTTPS 协议或 SMTP 地址 + type: string + type: object + model.PushEvent: + properties: + channels: + description: 推送渠道列表,如 ["lark"] + items: + type: string + type: array + created_at: + type: string + enabled: + description: 是否启用 + type: boolean + event_key: + description: 如 admin_login + type: string + id: + type: integer + name: + description: 如 管理员登录 + type: string + targets: + description: 推送目标用户/邮箱列表 + items: + type: string + type: array + task_type: + description: 关联的异步任务类型 + type: string + template: + description: 消息模板 JSON + type: string + updated_at: + type: string + type: object + model.PushHistory: + properties: + channel: + type: string + content: + type: string + created_at: + type: string + error_msg: + type: string + event_key: + type: string + id: + type: integer + level: + type: string + status: + description: success / failed + type: string + target: + type: string + title: + type: string + type: object + model.Schedule: + properties: + created_at: + type: string + cron: + type: string + id: + example: "0" + type: string + is_active: + type: boolean + name: + type: string + payload: + type: string + task_type: + type: string + updated_at: + type: string + type: object + model.SystemConfig: + properties: + created_at: + type: string + description: + type: string + key: + type: string + type: + type: string + updated_at: + type: string + value: + type: string + visibility: + type: integer + type: object + model.TLSCertificate: + properties: + acme_account_id: + type: integer + apply_message: + type: string + apply_status: + type: string + auto_renew: + type: boolean + created_at: + type: string + disable_cname: + type: boolean + dns_account_id: + type: integer + dns1: + type: string + dns2: + type: string + id: + type: integer + key_algorithm: + type: string + name: + type: string + not_after: + type: string + not_before: + type: string + other_domains: + type: string + primary_domain: + type: string + provider: + type: string + remark: + type: string + skip_dns: + type: boolean + updated_at: + type: string + type: object + model.TaskExecution: + properties: + created_at: + type: string + duration: + type: integer + error_message: + type: string + finished_at: + type: string + id: + example: "0" + type: string + log: + type: string + max_retry: + type: integer + payload: + type: string + result: + type: string + retry_count: + type: integer + retryable: + type: boolean + started_at: + type: string + status: + $ref: '#/definitions/model.TaskExecutionStatus' + task_id: + type: string + task_name: + type: string + task_type: + type: string + triggered_by: + type: string + updated_at: + type: string + type: object + model.TaskExecutionStatus: + enum: + - pending + - running + - succeeded + - failed + type: string + x-enum-varnames: + - TaskExecutionStatusPending + - TaskExecutionStatusRunning + - TaskExecutionStatusSucceeded + - TaskExecutionStatusFailed + model.Template: + properties: + content: + type: string + created_at: + type: string + description: + type: string + id: + type: integer + is_system: + type: boolean + key: + type: string + name: + type: string + subject: + type: string + type: + type: string + updated_at: + type: string + type: object + model.Upload: + properties: + access_mode: + type: integer + created_at: + type: string + extension: + description: 文件后缀名 (不含点,如 png, pdf) + type: string + file_name: + description: '原始文件名 (例如: image.png)' + type: string + file_path: + description: 文件相对路径 / S3 Key + type: string + file_size: + description: 文件大小(字节) + type: integer + hash: + description: 文件哈希 (SHA-256/MD5,可用于排重) + type: string + id: + example: "0" + type: string + metadata: + allOf: + - $ref: '#/definitions/model.UploadMetadata' + description: 业务扩展元数据 + mime_type: + description: 媒体类型 (MIME, 如 image/png) + type: string + status: + allOf: + - $ref: '#/definitions/model.UploadStatus' + description: 状态 + type: + description: 业务标识类型 (如 avatar, doc, attachment) + type: string + updated_at: + type: string + user_id: + example: "0" + type: string + type: object + model.UploadMetadata: + properties: + bucket: + description: 存储桶名称 (适用于 S3 等) + type: string + client_ip: + description: 上传者 IP + type: string + duration: + description: 音视频时长 (s) + type: number + extra: + additionalProperties: {} + description: 其它任意业务自定义元数据 + type: object + height: + description: 图像/视频高度 (px) + type: integer + original_mime: + description: 原始 MIME 类型 + type: string + user_agent: + description: 上传者的 UA + type: string + width: + description: 图像/视频宽度 (px) + type: integer + type: object + model.UploadStatus: + enum: + - pending + - used + - deleted + type: string + x-enum-comments: + UploadStatusDeleted: 已删除 + UploadStatusPending: 待使用 + UploadStatusUsed: 已使用 + x-enum-descriptions: + - 待使用 + - 已使用 + - 已删除 + x-enum-varnames: + - UploadStatusPending + - UploadStatusUsed + - UploadStatusDeleted + model.Zone: + properties: + created_at: + type: string + domain: + type: string + id: + type: integer + updated_at: + type: string + type: object + model.ZoneDomain: + properties: + cert_id: + type: integer + created_at: + type: string + domain: + type: string + id: + type: integer + proxy_route_id: + type: integer + updated_at: + type: string + zone_id: + type: integer + type: object + node.AgentReleaseInfo: + properties: + body: + type: string + channel: + type: string + current_version: + type: string + has_update: + type: boolean + html_url: + type: string + prerelease: + type: boolean + published_at: + type: string + requested_channel: + type: string + requested_tag: + type: string + tag_name: + type: string + update_requested: + type: boolean + type: object + node.AgentUpdateInput: + properties: + channel: + type: string + tag_name: + type: string + type: object + node.BootstrapView: + properties: + discovery_token: + type: string + type: object + node.HealthEventCleanupResult: + properties: + deleted_count: + type: integer + node_id: + type: string + type: object + node.Input: + properties: + auto_update_enabled: + type: boolean + geo_latitude: + type: number + geo_longitude: + type: number + geo_manual_override: + type: boolean + geo_name: + type: string + ip: + type: string + ip_manual_override: + type: boolean + name: + type: string + node_type: + type: string + relay_agent_access_addr: + type: string + relay_bind_port: + type: integer + relay_client_access_addr: + type: string + relay_client_proxy_url: + type: string + relay_vhost_http_port: + type: integer + relay_web_server_enabled: + type: boolean + type: object + node.ObservabilityView: + properties: + analytics: + $ref: '#/definitions/observability.NodeAnalytics' + health_events: + items: + $ref: '#/definitions/model.OpenFlareHealthEvent' + type: array + metric_snapshots: + items: + $ref: '#/definitions/observability.NodeMetricSnapshotView' + type: array + node_id: + type: string + profile: + $ref: '#/definitions/model.OpenFlareNodeSystemProfile' + relay_dashboard: + $ref: '#/definitions/observability.RelayDashboardSnapshot' + trends: + $ref: '#/definitions/observability.NodeTrends' + type: object + node.View: + properties: + access_token: + type: string + auto_update_enabled: + type: boolean + created_at: + type: string + current_version: + type: string + ext_version: + type: string + geo_latitude: + type: number + geo_longitude: + type: number + geo_manual_override: + type: boolean + geo_name: + type: string + id: + type: integer + ip: + type: string + ip_manual_override: + type: boolean + last_error: + type: string + last_seen_at: {} + latest_apply_at: + type: string + latest_apply_checksum: + type: string + latest_apply_message: + type: string + latest_apply_result: + type: string + latest_main_config_checksum: + type: string + latest_route_config_checksum: + type: string + latest_support_file_count: + type: integer + name: + type: string + node_id: + type: string + node_type: + type: string + openresty_message: + type: string + openresty_status: + type: string + relay_agent_access_addr: + type: string + relay_bind_port: + type: integer + relay_client_access_addr: + type: string + relay_client_proxy_url: + type: string + relay_status: + type: string + relay_vhost_http_port: + type: integer + relay_web_server_enabled: + type: boolean + restart_openresty_requested: + type: boolean + status: + type: string + update_channel: + type: string + update_requested: + type: boolean + update_tag: + type: string + updated_at: + type: string + version: + type: string + type: object + oauth.AuthSourceView: + properties: + client_secret_configured: + type: boolean + display_name: + type: string + icon_url: + type: string + id: + type: integer + is_active: + type: boolean + name: + type: string + type: + type: string + type: object + oauth.BasicUserInfo: + properties: + avatar_url: + type: string + bio: + type: string + email: + type: string + gender: + type: string + id: + type: integer + is_admin: + type: boolean + location: + type: string + need_change_password: + type: boolean + nickname: + type: string + phone: + type: string + username: + type: string + website: + type: string + type: object + oauth.CallbackRequest: + properties: + code: + type: string + state: + type: string + required: + - code + - state + type: object + oauth.OAuthAuthorizeResponse: + properties: + authorize_url: + type: string + type: object + oauth.OAuthCallbackResult: + properties: + status: + type: string + user: + $ref: '#/definitions/oauth.BasicUserInfo' + type: object + observability.AccessLogCleanupInput: + properties: + retention_days: + type: integer + type: object + observability.AccessLogCleanupResult: + properties: + cutoff: + type: string + deleted_count: + type: integer + retention_days: + type: integer + type: object + observability.AccessLogIPAnalysisSummary: + properties: + bandwidth_served: + type: integer + bytes_received: + type: integer + error_count: + type: integer + total_requests: + type: integer + unique_hosts: + type: integer + unique_paths: + type: integer + type: object + observability.AccessLogIPAnalysisView: + properties: + device_types: + items: + $ref: '#/definitions/observability.DistributionItem' + type: array + generated_at: + type: string + hours: + type: integer + remote_addr: + type: string + status_codes: + items: + $ref: '#/definitions/observability.DistributionItem' + type: array + summary: + $ref: '#/definitions/observability.AccessLogIPAnalysisSummary' + top_browsers: + items: + $ref: '#/definitions/observability.DistributionItem' + type: array + top_hosts: + items: + $ref: '#/definitions/observability.DistributionItem' + type: array + top_paths: + items: + $ref: '#/definitions/observability.DistributionItem' + type: array + top_user_agents: + items: + $ref: '#/definitions/observability.DistributionItem' + type: array + type: object + observability.AccessLogIPSummaryList: + properties: + has_more: + type: boolean + hours: + type: integer + items: + items: + $ref: '#/definitions/observability.AccessLogIPSummaryView' + type: array + page: + type: integer + page_size: + type: integer + since: + type: string + sort_by: + type: string + sort_order: + type: string + total_ip: + type: integer + until: + type: string + type: object + observability.AccessLogIPSummaryView: + properties: + bytes_received: + type: integer + bytes_sent: + type: integer + last_seen_at: + type: string + recent_requests: + description: RecentRequests is deprecated and always 0. + type: integer + region: + type: string + remote_addr: + type: string + success_2xx_count: + type: integer + success_ratio: + type: number + total_requests: + type: integer + type: object + observability.AccessLogIPTrendPoint: + properties: + bucket_started_at: + type: string + request_count: + type: integer + type: object + observability.AccessLogIPTrendView: + properties: + bucket_minutes: + type: integer + hours: + type: integer + points: + items: + $ref: '#/definitions/observability.AccessLogIPTrendPoint' + type: array + remote_addr: + type: string + type: object + observability.AccessLogList: + properties: + has_more: + type: boolean + items: + items: + $ref: '#/definitions/observability.AccessLogView' + type: array + page: + type: integer + page_size: + type: integer + total_ip: + type: integer + total_record: + type: integer + type: object + observability.AccessLogOverview: + properties: + bucket_minutes: + type: integer + device_types: + items: + $ref: '#/definitions/observability.DistributionItem' + type: array + generated_at: + type: string + hours: + type: integer + status_codes: + items: + $ref: '#/definitions/observability.DistributionItem' + type: array + summary: + $ref: '#/definitions/observability.AccessLogOverviewSummary' + top_browsers: + items: + $ref: '#/definitions/observability.DistributionItem' + type: array + top_hosts: + items: + $ref: '#/definitions/observability.DistributionItem' + type: array + top_ips: + items: + $ref: '#/definitions/observability.DistributionItem' + type: array + top_operating_systems: + items: + $ref: '#/definitions/observability.DistributionItem' + type: array + top_paths: + items: + $ref: '#/definitions/observability.DistributionItem' + type: array + top_user_agents: + items: + $ref: '#/definitions/observability.DistributionItem' + type: array + trends: + $ref: '#/definitions/observability.AccessLogOverviewTrends' + type: object + observability.AccessLogOverviewMetricPoint: + properties: + bucket_started_at: + type: string + value: + type: integer + type: object + observability.AccessLogOverviewSummary: + properties: + bandwidth_served: + type: integer + total_requests: + type: integer + total_visits: + type: integer + type: object + observability.AccessLogOverviewTrends: + properties: + bandwidth: + items: + $ref: '#/definitions/observability.AccessLogOverviewMetricPoint' + type: array + requests: + items: + $ref: '#/definitions/observability.AccessLogOverviewMetricPoint' + type: array + visits: + items: + $ref: '#/definitions/observability.AccessLogOverviewMetricPoint' + type: array + type: object + observability.AccessLogView: + properties: + bytes_sent: + type: integer + cache_status: + type: string + created_at: + type: string + host: + type: string + id: + type: string + logged_at: + type: string + node_id: + type: string + node_name: + type: string + path: + type: string + region: + type: string + remote_addr: + type: string + request_length: + type: integer + request_time_ms: + type: integer + status_code: + type: integer + user_agent: + type: string + type: object + observability.CapacityTrendPoint: + properties: + average_cpu_usage_percent: + type: number + average_memory_usage_percent: + type: number + bucket_started_at: + type: string + reported_nodes: + type: integer + type: object + observability.DiskIOTrendPoint: + properties: + bucket_started_at: + type: string + disk_read_bytes: + type: integer + disk_write_bytes: + type: integer + reported_nodes: + type: integer + type: object + observability.DistributionItem: + properties: + key: + type: string + value: + type: integer + type: object + observability.FoldedAccessLogIPList: + properties: + bucket_started_at: + type: string + fold_minutes: + type: integer + has_more: + type: boolean + items: + items: + $ref: '#/definitions/observability.FoldedAccessLogIPView' + type: array + page: + type: integer + page_size: + type: integer + sort_by: + type: string + sort_order: + type: string + total_ip: + type: integer + type: object + observability.FoldedAccessLogIPView: + properties: + client_error_count: + type: integer + last_seen_at: + type: string + remote_addr: + type: string + request_count: + type: integer + server_error_count: + type: integer + success_count: + type: integer + type: object + observability.FoldedAccessLogList: + properties: + fold_minutes: + type: integer + has_more: + type: boolean + items: + items: + $ref: '#/definitions/observability.FoldedAccessLogView' + type: array + page: + type: integer + page_size: + type: integer + total_bucket: + type: integer + total_ip: + type: integer + total_record: + type: integer + type: object + observability.FoldedAccessLogView: + properties: + bucket_started_at: + type: string + client_error_count: + type: integer + request_count: + type: integer + server_error_count: + type: integer + success_count: + type: integer + unique_host_count: + type: integer + unique_ip_count: + type: integer + type: object + observability.HealthSummary: + properties: + active_alerts: + type: integer + critical_alerts: + type: integer + has_capacity_risk: + type: boolean + has_runtime_risk: + type: boolean + has_traffic_risk: + type: boolean + info_alerts: + type: integer + resolved_alerts: + type: integer + warning_alerts: + type: integer + type: object + observability.NetworkTrendPoint: + properties: + bucket_started_at: + type: string + bytes_provided: + description: sum(bytes_sent) + type: integer + bytes_received: + description: sum(request_length) + type: integer + reported_nodes: + type: integer + type: object + observability.NodeAnalytics: + properties: + distributions: + $ref: '#/definitions/observability.TrafficDistributions' + health: + $ref: '#/definitions/observability.HealthSummary' + traffic: + $ref: '#/definitions/observability.TrafficWindowSummary' + type: object + observability.NodeMetricSnapshotView: + properties: + captured_at: + type: string + cpu_usage_percent: + type: number + disk_read_bytes: + type: integer + disk_write_bytes: + type: integer + id: + type: integer + memory_total_bytes: + type: integer + memory_used_bytes: + type: integer + node_id: + type: string + openresty_connections: + type: integer + storage_total_bytes: + type: integer + storage_used_bytes: + type: integer + type: object + observability.NodeTrends: + properties: + capacity_24h: + items: + $ref: '#/definitions/observability.CapacityTrendPoint' + type: array + disk_io_24h: + items: + $ref: '#/definitions/observability.DiskIOTrendPoint' + type: array + network_24h: + items: + $ref: '#/definitions/observability.NetworkTrendPoint' + type: array + traffic_24h: + items: + $ref: '#/definitions/observability.TrafficTrendPoint' + type: array + type: object + observability.RelayDashboardSnapshot: + properties: + client_counts: + type: integer + offline_proxies: + type: integer + online_proxies: + type: integer + proxies: + items: + $ref: '#/definitions/observability.RelayProxyStat' + type: array + total_connections: + type: integer + total_proxies: + type: integer + type: object + observability.RelayProxyStat: + properties: + client_addr: + type: string + client_version: + type: string + last_close_time: + type: string + last_start_time: + type: string + name: + type: string + status: + type: string + type: + type: string + type: object + observability.TrafficDistributions: + properties: + source_countries: + items: + $ref: '#/definitions/observability.DistributionItem' + type: array + status_codes: + items: + $ref: '#/definitions/observability.DistributionItem' + type: array + top_domains: + items: + $ref: '#/definitions/observability.DistributionItem' + type: array + type: object + observability.TrafficTrendPoint: + properties: + bucket_started_at: + type: string + error_count: + type: integer + request_count: + type: integer + status_2xx_count: + type: integer + status_4xx_count: + type: integer + status_5xx_count: + type: integer + unique_visitor_count: + type: integer + type: object + observability.TrafficWindowSummary: + properties: + error_count: + type: integer + error_rate_percent: + type: number + estimated_qps: + type: number + request_count: + type: integer + unique_visitor_count: + type: integer + window_ended_at: + type: string + window_started_at: + type: string + type: object + option.geoIPLookupRequest: + properties: + ip: + type: string + provider: + type: string + type: object + option.geoIPLookupView: + properties: + ip: + type: string + iso_code: + type: string + latitude: + type: number + longitude: + type: number + name: + type: string + provider: + type: string + type: object + option.optionBatchPayload: + properties: + options: + items: + $ref: '#/definitions/model.OpenFlareOption' + type: array + type: object + option.publicAuthSourceView: + properties: + authorize_url: + type: string + display_name: + type: string + icon_url: + type: string + id: + type: integer + name: + type: string + type: + type: string + type: object + option.statusView: + properties: + auth_sources: + items: + $ref: '#/definitions/option.publicAuthSourceView' + type: array + cap_login_enabled: + type: boolean + email_verification: + type: boolean + password_register_enabled: + type: boolean + server_address: + type: string + start_time: + type: integer + version: + type: string + type: object + origin.DetailView: + properties: + address: + type: string + created_at: + type: string + id: + type: integer + name: + type: string + remark: + type: string + route_count: + type: integer + routes: + items: + $ref: '#/definitions/origin.RouteSummary' + type: array + updated_at: + type: string + type: object + origin.Input: + properties: + address: + type: string + name: + type: string + remark: + type: string + type: object + origin.RouteSummary: + properties: + domain: + type: string + enabled: + type: boolean + id: + type: integer + origin_url: + type: string + updated_at: + type: string + type: object + origin.View: + properties: + address: + type: string + created_at: + type: string + id: + type: integer + name: + type: string + remark: + type: string + route_count: + type: integer + updated_at: + type: string + type: object + pages.DeploymentFileView: + properties: + checksum: + type: string + created_at: + type: string + deployment_id: + type: integer + id: + type: integer + path: + type: string + size: + type: integer + type: object + pages.DeploymentView: + properties: + activated_at: + type: string + checksum: + type: string + created_at: + type: string + created_by: + type: string + deployment_number: + type: integer + file_count: + type: integer + id: + type: integer + project_id: + type: integer + source_label: + type: string + source_type: + type: string + status: + type: string + total_size: + type: integer + trigger_type: + type: string + upload_id: + example: "0" + type: string + type: object + pages.Input: + properties: + api_proxy_enabled: + type: boolean + api_proxy_pass: + type: string + api_proxy_path: + type: string + api_proxy_rewrite: + type: string + description: + type: string + enabled: + type: boolean + entry_file: + type: string + name: + type: string + root_dir: + type: string + slug: + type: string + spa_fallback_enabled: + type: boolean + spa_fallback_path: + type: string + type: object + pages.SourceActionReceipt: + properties: + action: + type: string + execution_id: + type: string + task_id: + type: string + type: object + pages.SourceRevisionView: + properties: + asset_name: + type: string + label: + type: string + revision: + type: string + type: object + pages.SourceSyncInput: + properties: + confirmed_revision: + type: string + type: object + pages.SourceUpdateInput: + properties: + allow_insecure: + type: boolean + asset_name: + type: string + auto_update_enabled: + type: boolean + check_interval_minutes: + type: integer + release_selector: + type: string + release_tag: + type: string + remote_url: + type: string + repository_url: + type: string + source_type: + type: string + type: object + pages.SourceUpdateResult: + properties: + check_task: + $ref: '#/definitions/pages.SourceActionReceipt' + source: + $ref: '#/definitions/pages.SourceView' + warning: + type: string + type: object + pages.SourceView: + properties: + allow_insecure: + type: boolean + asset_name: + type: string + auto_update_enabled: + type: boolean + check_interval_minutes: + type: integer + github_repository: + type: string + last_applied: + $ref: '#/definitions/pages.SourceRevisionView' + last_checked_at: + type: string + last_error: + type: string + last_seen: + $ref: '#/definitions/pages.SourceRevisionView' + last_synced_at: + type: string + next_check_at: + type: string + release_selector: + type: string + release_tag: + type: string + remote_url: + type: string + source_type: + type: string + sync_status: + type: string + update_available: + type: boolean + type: object + pages.UploadFromURLInput: + properties: + url: + type: string + type: object + pages.View: + properties: + active_deployment: + $ref: '#/definitions/pages.DeploymentView' + active_deployment_id: + type: integer + api_proxy_enabled: + type: boolean + api_proxy_pass: + type: string + api_proxy_path: + type: string + api_proxy_rewrite: + type: string + created_at: + type: string + deployment_count: + type: integer + description: + type: string + enabled: + type: boolean + entry_file: + type: string + id: + type: integer + name: + type: string + root_dir: + type: string + slug: + type: string + spa_fallback_enabled: + type: boolean + spa_fallback_path: + type: string + updated_at: + type: string + type: object + protocol.AgentNodeHealthEvent: + properties: + event_type: + type: string + message: + type: string + metadata: + additionalProperties: + type: string + type: object + severity: + type: string + triggered_at_unix: + type: integer + type: object + protocol.AgentNodeMetricSnapshot: + properties: + captured_at_unix: + type: integer + cpu_usage_percent: + type: number + disk_read_bytes: + type: integer + disk_write_bytes: + type: integer + memory_total_bytes: + type: integer + memory_used_bytes: + type: integer + storage_total_bytes: + type: integer + storage_used_bytes: + type: integer + type: object + protocol.AgentNodeSystemProfile: + properties: + architecture: + type: string + cpu_cores: + type: integer + cpu_model: + type: string + hostname: + type: string + kernel_version: + type: string + os_name: + type: string + os_version: + type: string + reported_at_unix: + type: integer + total_disk_bytes: + type: integer + total_memory_bytes: + type: integer + uptime_seconds: + type: integer + type: object + protocol.FlaredConnectedRelay: + properties: + proxy_count: + type: integer + relay_node_id: + type: string + status: + type: string + type: object + protocol.FlaredProxyEntry: + properties: + custom_domains: + items: + type: string + type: array + local_addr: + type: string + local_port: + type: integer + name: + type: string + type: + type: string + type: object + protocol.FlaredRelayInfo: + properties: + address: + type: string + auth_token: + type: string + proxy_url: + type: string + relay_node_id: + type: string + type: object + protocol.RelayConfig: + properties: + auth_token: + type: string + bind_port: + type: integer + log_level: + type: string + vhost_http_port: + type: integer + web_server_enabled: + type: boolean + web_server_port: + type: integer + type: object + protocol.RelayProxyStat: + properties: + client_addr: + type: string + client_version: + type: string + last_close_time: + type: string + last_start_time: + type: string + name: + type: string + status: + type: string + type: + type: string + type: object + protocol.RelaySettings: + properties: + auto_update: + type: boolean + heartbeat_interval: + type: integer + update_channel: + type: string + update_now: + type: boolean + update_repo: + type: string + update_tag: + type: string + websocket_upgrade_enabled: + type: boolean + type: object + proxy_route.CustomHeaderInput: + properties: + key: + type: string + value: + type: string + type: object + proxy_route.Input: + properties: + basic_auth_enabled: + type: boolean + basic_auth_password: + type: string + basic_auth_username: + type: string + cache_enabled: + type: boolean + cache_policy: + type: string + cache_rules: + items: + type: string + type: array + custom_headers: + items: + $ref: '#/definitions/proxy_route.CustomHeaderInput' + type: array + enable_https: + type: boolean + enabled: + type: boolean + limit_conn_per_ip: + type: integer + limit_conn_per_server: + type: integer + limit_rate: + type: string + limit_req_per_ip: + type: string + origin_address: + type: string + origin_host: + type: string + origin_id: + type: integer + origin_port: + type: string + origin_scheme: + type: string + origin_uri: + type: string + origin_url: + type: string + pages_project_id: + type: integer + redirect_http: + type: boolean + site_name: + type: string + tunnel_id: + type: integer + tunnel_node_id: + type: integer + tunnel_target_addr: + type: string + tunnel_target_protocol: + type: string + upstream_type: + type: string + upstreams: + items: + type: string + type: array + zone_domain_ids: + items: + type: integer + type: array + type: object + proxy_route.View: + properties: + basic_auth_enabled: + type: boolean + basic_auth_password: + type: string + basic_auth_username: + type: string + cache_enabled: + type: boolean + cache_policy: + type: string + cache_rule_list: + items: + type: string + type: array + cache_rules: + type: string + created_at: + type: string + custom_header_list: + items: + $ref: '#/definitions/proxy_route.CustomHeaderInput' + type: array + custom_headers: + type: string + enable_https: + type: boolean + enabled: + type: boolean + id: + type: integer + limit_conn_per_ip: + type: integer + limit_conn_per_server: + type: integer + limit_rate: + type: string + limit_req_per_ip: + type: string + origin_host: + type: string + origin_id: + type: integer + origin_url: + type: string + pages_project_id: + type: integer + redirect_http: + type: boolean + site_name: + type: string + tunnel_id: + type: integer + tunnel_node_id: + type: integer + tunnel_target_addr: + type: string + tunnel_target_protocol: + type: string + updated_at: + type: string + upstream_list: + items: + type: string + type: array + upstream_type: + type: string + upstreams: + type: string + zone_domain_ids: + items: + type: integer + type: array + zone_domains: + items: + $ref: '#/definitions/proxy_route.ZoneDomainView' + type: array + type: object + proxy_route.ZoneDomainView: + properties: + cert_id: + type: integer + domain: + type: string + id: + type: integer + zone_id: + type: integer + type: object + push.Config: + properties: + channel: + description: 渠道名称,例如 "lark", "custom", "email" 等,唯一标识 + type: string + ext: + additionalProperties: {} + description: 预留拓展 JSON 配置 + type: object + key: + description: AppID 或 SMTP 用户名 + type: string + secret: + description: 签名密钥或 SMTP 密码/Token + type: string + url: + description: Webhook 地址或 SMTP 地址 + type: string + type: object + push.CreateChannelRequest: + properties: + description: + type: string + enabled: + type: boolean + name: + type: string + other: + type: string + token: + type: string + type: + type: string + url: + type: string + required: + - name + - type + type: object + push.CreateEventRequest: + properties: + channels: + items: + type: string + type: array + enabled: + type: boolean + event_key: + type: string + targets: + items: + type: string + type: array + task_type: + description: 关联的异步任务类型 + type: string + template: + type: string + type: object + push.Definition: + properties: + description: + description: short description + type: string + fields: + description: form fields + items: + $ref: '#/definitions/push.Field' + type: array + name: + description: display name + type: string + type: + description: channel type (e.g., custom, lark, email) + type: string + type: object + push.EventMetadata: + properties: + default_template: + $ref: '#/definitions/push.NotificationMessage' + description: + type: string + key: + type: string + name: + type: string + type: object + push.Field: + properties: + description: + description: field explanation/help text + type: string + key: + description: unique key for the field (e.g. url, token, other) + type: string + label: + description: human readable label (e.g. "Webhook 地址") + type: string + placeholder: + description: input placeholder + type: string + required: + description: whether this field is required + type: boolean + type: + description: 'input type: "text" | "password" | "textarea"' + type: string + type: object + push.NotificationMessage: + properties: + content: + type: string + ext: + additionalProperties: {} + type: object + level: + type: string + title: + type: string + type: object + push.TestChannelRequest: + properties: + name: + type: string + other: + type: string + target: + type: string + token: + type: string + type: + type: string + url: + type: string + type: object + push.TestPushRequest: + properties: + config: + $ref: '#/definitions/push.Config' + target: + type: string + required: + - config + type: object + push.UpdateChannelRequest: + properties: + description: + type: string + enabled: + type: boolean + other: + type: string + token: + type: string + type: + type: string + url: + type: string + required: + - type + type: object + push.UpdateEventRequest: + properties: + channels: + items: + type: string + type: array + enabled: + type: boolean + targets: + items: + type: string + type: array + template: + type: string + required: + - template + type: object + push.pushHistoriesResponse: + properties: + results: + items: + $ref: '#/definitions/model.PushHistory' + type: array + total: + type: integer + type: object + relay.HeartbeatPayload: + properties: + frp_version: + type: string + frps_client_count: + type: integer + frps_connections: + type: integer + frps_proxies: + items: + $ref: '#/definitions/protocol.RelayProxyStat' + type: array + frps_proxy_count: + type: integer + health_events: + items: + $ref: '#/definitions/protocol.AgentNodeHealthEvent' + type: array + ip: + type: string + name: + type: string + profile: + $ref: '#/definitions/protocol.AgentNodeSystemProfile' + relay_status: + type: string + snapshot: + $ref: '#/definitions/protocol.AgentNodeMetricSnapshot' + version: + type: string + type: object + relay.HeartbeatResponse: + properties: + relay_config: + $ref: '#/definitions/protocol.RelayConfig' + relay_settings: + $ref: '#/definitions/protocol.RelaySettings' + type: object + response.Any: + properties: + data: {} + error_msg: + example: "" + type: string + type: object + status.DatabaseInfoResponse: + properties: + name: + type: string + type: + type: string + version: + type: string + type: object + status.LogDatabaseStatus: + properties: + active_database: + type: string + available_targets: + items: + type: string + type: array + clickhouse: + $ref: '#/definitions/Wavelet_OpenFlare_plugins_server_model_analytics.ClickHouseOperationalStats' + migration: + description: idle | migrating + type: string + retention_days: + additionalProperties: + type: integer + type: object + type: object + status.SystemStatusResponse: + properties: + alloc: + type: string + buck_hash_sys: + type: string + frees: + type: integer + gc_sys: + type: string + heap_alloc: + type: string + heap_idle: + type: string + heap_inuse: + type: string + heap_objects: + type: integer + heap_released: + type: string + heap_sys: + type: string + last_gc_time: + type: string + last_pause: + type: string + lookups: + type: integer + mallocs: + type: integer + mcache_inuse: + type: string + mcache_sys: + type: string + mspan_inuse: + type: string + mspan_sys: + type: string + next_gc: + type: string + num_gc: + type: integer + num_goroutine: + type: integer + other_sys: + type: string + pause_total_ns: + type: string + stack_inuse: + type: string + stack_sys: + type: string + sys: + type: string + total_alloc: + type: string + uptime: + type: string + type: object + system_config.CreateSystemConfigRequest: + properties: + description: + maxLength: 255 + type: string + key: + maxLength: 64 + type: string + type: + enum: + - system + - business + type: string + value: + type: string + visibility: + enum: + - 0 + - 1 + type: integer + required: + - key + - type + - value + type: object + system_config.TestSMTPRequest: + properties: + smtp_host: + maxLength: 255 + type: string + smtp_password: + maxLength: 255 + type: string + smtp_port: + type: integer + smtp_username: + maxLength: 255 + type: string + to: + type: string + required: + - smtp_host + - smtp_password + - smtp_port + - smtp_username + - to + type: object + system_config.TestSMTPResponse: + properties: + error: + type: string + log: + type: string + success: + type: boolean + type: object + system_config.UpdateSystemConfigRequest: + properties: + description: + maxLength: 255 + type: string + value: + type: string + visibility: + enum: + - 0 + - 1 + type: integer + required: + - value + type: object + task.CreateScheduleRequest: + properties: + cron: + type: string + is_active: + type: boolean + name: + type: string + payload: + type: string + task_type: + type: string + required: + - cron + - is_active + - name + - task_type + type: object + task.DispatchTaskRequest: + properties: + end_time: + type: string + payload: + type: string + start_time: + type: string + task_type: + type: string + user_id: + type: integer + required: + - task_type + type: object + task.TaskMeta: + properties: + asynq_task: + type: string + description: + type: string + max_retry: + type: integer + name: + type: string + params: + items: + $ref: '#/definitions/task.TaskParam' + type: array + queue: + type: string + retryable: + description: 是否支持手动重试 + type: boolean + supports_time: + type: boolean + type: + type: string + type: object + task.TaskParam: + properties: + description: + description: 描述 + type: string + label: + description: 显示名称 + type: string + name: + description: 参数键名 + type: string + placeholder: + description: 占位符 + type: string + required: + description: 是否必填 + type: boolean + type: + description: 类型:string, text, number, boolean + type: string + type: object + task.UpdateScheduleRequest: + properties: + cron: + type: string + is_active: + type: boolean + name: + type: string + payload: + type: string + task_type: + type: string + required: + - cron + - is_active + - name + - task_type + type: object + template.CreateTemplateRequest: + properties: + content: + type: string + description: + maxLength: 255 + type: string + key: + maxLength: 80 + type: string + name: + maxLength: 100 + type: string + subject: + maxLength: 255 + type: string + type: + maxLength: 20 + type: string + required: + - content + - key + - name + - type + type: object + template.UpdateTemplateRequest: + properties: + content: + type: string + description: + maxLength: 255 + type: string + name: + maxLength: 100 + type: string + subject: + maxLength: 255 + type: string + type: + maxLength: 20 + type: string + required: + - content + - name + - type + type: object + tls.ApplyInput: + properties: + acme_account_id: + type: integer + auto_renew: + type: boolean + disable_cname: + type: boolean + dns_account_id: + type: integer + dns1: + type: string + dns2: + type: string + key_algorithm: + type: string + name: + type: string + other_domains: + type: string + primary_domain: + type: string + remark: + type: string + skip_dns: + type: boolean + type: object + tls.CertificateContent: + properties: + acme_account_id: + type: integer + apply_message: + type: string + apply_status: + type: string + auto_renew: + type: boolean + cert_pem: + type: string + disable_cname: + type: boolean + dns_account_id: + type: integer + dns1: + type: string + dns2: + type: string + id: + type: integer + key_algorithm: + type: string + key_pem: + type: string + name: + type: string + other_domains: + type: string + primary_domain: + type: string + provider: + type: string + remark: + type: string + skip_dns: + type: boolean + type: object + tls.CertificateInput: + properties: + cert_pem: + type: string + key_pem: + type: string + name: + type: string + remark: + type: string + type: object + tls.DNSAccountInput: + properties: + authorization: + type: string + name: + type: string + type: + type: string + type: object + updater.Status: + properties: + asset_name: + type: string + build_time: + type: string + can_upgrade: + type: boolean + current_version: + type: string + latest_version: + type: string + platform: + type: string + prerelease: + type: boolean + published_at: + type: string + release_name: + type: string + release_notes: + type: string + release_url: + type: string + update_available: + type: boolean + upstream_repository: + type: string + type: object + user.changePasswordRequest: + properties: + new_password: + type: string + old_password: + type: string + type: object + user.createTokenRequest: + properties: + is_admin: + type: boolean + name: + type: string + type: object + user.createUserRequest: + properties: + email: + maxLength: 255 + type: string + is_active: + type: boolean + is_admin: + type: boolean + nickname: + maxLength: 64 + type: string + password: + maxLength: 64 + minLength: 8 + type: string + username: + maxLength: 64 + minLength: 3 + type: string + required: + - email + - password + - username + type: object + user.listUsersResponse: + properties: + total: + type: integer + users: + items: + $ref: '#/definitions/user.user' + type: array + type: object + user.loginRequest: + properties: + code: + type: string + password: + type: string + username: + type: string + type: object + user.registerRequest: + properties: + code: + type: string + display_name: + type: string + email: + type: string + nickname: + type: string + password: + type: string + username: + type: string + type: object + user.sendEmailCodeRequest: + properties: + email: + type: string + scene: + type: string + required: + - email + - scene + type: object + user.tokenResponse: + properties: + record: + $ref: '#/definitions/model.AccessToken' + token: + type: string + type: object + user.updateProfileRequest: + properties: + avatar_url: + type: string + bio: + type: string + email: + type: string + gender: + type: string + location: + type: string + nickname: + type: string + phone: + type: string + website: + type: string + type: object + user.updateUserRequest: + properties: + email: + maxLength: 255 + type: string + is_admin: + type: boolean + nickname: + maxLength: 64 + type: string + password: + maxLength: 64 + minLength: 8 + type: string + required: + - email + type: object + user.updateUserStatusRequest: + properties: + is_active: + type: boolean + type: object + user.user: + properties: + avatar_url: + type: string + bio: + type: string + created_at: + type: string + email: + type: string + gender: + type: string + id: + example: "0" + type: string + is_active: + type: boolean + is_admin: + type: boolean + last_login_at: + type: string + location: + type: string + nickname: + type: string + phone: + type: string + updated_at: + type: string + username: + type: string + website: + type: string + type: object + waf.CreateRuleInput: + properties: + name: + type: string + type: object + waf.IDsRequest: + properties: + ids: + items: + type: integer + type: array + type: object + waf.IPGroupAutoTestInput: + properties: + auto_config: + items: + type: integer + type: array + type: object + waf.IPGroupAutoTestResult: + properties: + lookback: + type: string + matched_count: + type: integer + matched_ips: + items: + type: string + type: array + rule_count: + type: integer + tested_at: + type: string + type: object + waf.IPGroupExtIPView: + properties: + captured_at: + type: string + ip: + type: string + type: object + waf.IPGroupInput: + properties: + auto_config: + items: + type: integer + type: array + enabled: + type: boolean + ip_list: + items: + type: string + type: array + name: + type: string + subscription_format: + type: string + subscription_mapping_rule: + type: string + subscription_url: + type: string + sync_interval_minutes: + type: integer + type: + type: string + type: object + waf.IPGroupSyncResult: + properties: + group: + $ref: '#/definitions/waf.IPGroupView' + ip_count: + type: integer + message: + type: string + next_sync_at: + type: string + status: + type: string + synced_at: + type: string + type: object + waf.IPGroupView: + properties: + auto_config: + items: + type: integer + type: array + created_at: + type: string + enabled: + type: boolean + ext_ips: + items: + $ref: '#/definitions/waf.IPGroupExtIPView' + type: array + id: + type: integer + ip_list: + items: + type: string + type: array + last_sync_message: + type: string + last_sync_status: + type: string + last_synced_at: + type: string + name: + type: string + next_sync_at: + type: string + referenced_by_rule_count: + type: integer + subscription_format: + type: string + subscription_mapping_rule: + type: string + subscription_url: + type: string + sync_interval_minutes: + type: integer + type: + type: string + updated_at: + type: string + type: object + waf.RuleEdge: + properties: + id: + type: string + source: + type: string + source_handle: + type: string + target: + type: string + type: object + waf.RuleGraph: + properties: + edges: + items: + $ref: '#/definitions/waf.RuleEdge' + type: array + nodes: + items: + $ref: '#/definitions/waf.RuleNode' + type: array + schema_version: + type: integer + type: object + waf.RuleNode: + properties: + config: + items: + type: integer + type: array + id: + type: string + label: + type: string + position: + $ref: '#/definitions/waf.RulePosition' + type: + $ref: '#/definitions/waf.RuleNodeType' + type: object + waf.RuleNodeType: + enum: + - start + - allow + - block + - ip_match + - geo_match + - pow + - ua_check + - security_check + type: string + x-enum-varnames: + - RuleNodeStart + - RuleNodeAllow + - RuleNodeBlock + - RuleNodeIPMatch + - RuleNodeGeoMatch + - RuleNodePoW + - RuleNodeUACheck + - RuleNodeSecurityCheck + waf.RulePosition: + properties: + x: + type: number + "y": + type: number + type: object + waf.RuleView: + properties: + applied_site_count: + type: integer + applied_site_ids: + items: + type: integer + type: array + created_at: + type: string + enabled: + type: boolean + graph: + $ref: '#/definitions/waf.RuleGraph' + id: + type: integer + is_global: + type: boolean + name: + type: string + revision: + type: integer + updated_at: + type: string + type: object + waf.SaveRuleGraphInput: + properties: + graph: + $ref: '#/definitions/waf.RuleGraph' + revision: + type: integer + type: object + waf.SiteRuleGroupsView: + properties: + applied_ids: + items: + type: integer + type: array + applied_rule_groups: + items: + $ref: '#/definitions/waf.RuleView' + type: array + global_rule_group: + $ref: '#/definitions/waf.RuleView' + route_id: + type: integer + rule_groups: + items: + $ref: '#/definitions/waf.RuleView' + type: array + type: object + waf.UpdateRuleMetaInput: + properties: + enabled: + type: boolean + name: + type: string + type: object + zone.DomainInput: + properties: + cert_id: + type: integer + domain: + type: string + type: object + zone.Input: + properties: + domain: + type: string + type: object + zone.ListItem: + properties: + created_at: + type: string + domain: + type: string + domain_count: + type: integer + id: + type: integer + updated_at: + type: string + type: object + zone.Overview: + properties: + domains: + items: + $ref: '#/definitions/model.ZoneDomain' + type: array + zone: + $ref: '#/definitions/model.Zone' + type: object + zone.Stats: + properties: + available: + type: boolean + bucket_minutes: + type: integer + bytes_sent: + type: integer + domain_count: + type: integer + range: + $ref: '#/definitions/zone.StatsRange' + range_hours: + type: integer + request_count: + type: integer + series: + items: + $ref: '#/definitions/zone.StatsPoint' + type: array + unique_visitors: + type: integer + window_ended_at: + type: string + window_started_at: + type: string + type: object + zone.StatsPoint: + properties: + bucket_started_at: + type: string + bytes_sent: + type: integer + request_count: + type: integer + unique_visitors: + type: integer + type: object + zone.StatsRange: + enum: + - 24h + - 7d + - 30d + type: string + x-enum-varnames: + - StatsRange24h + - StatsRange7d + - StatsRange30d +info: + contact: + name: OpenFlare + url: https://github.com/Rain-kl/OpenFlare + description: OpenFlare 平台后端 API,提供用户认证、系统配置、任务调度与边缘节点管理能力。 + license: + name: Apache 2.0 + url: http://www.apache.org/licenses/LICENSE-2.0.html + title: OpenFlare API + version: 1.0.0 +paths: + /api/cap/challenge: + post: + consumes: + - application/json + description: 客户端获取 PoW 难题和签名的 JWT Token,并在后台计算。 + parameters: + - description: 可选范围限制参数 + in: body + name: request + schema: + $ref: '#/definitions/cap.challengeRequest' + produces: + - application/json + responses: + "200": + description: 成功返回 PoW 难题 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/Wavelet_OpenFlare_plugins_server_cap.ChallengeResponse' + type: object + "500": + description: 内部服务错误 + schema: + $ref: '#/definitions/response.Any' + summary: 生成人机验证难题 + tags: + - cap + /api/cap/redeem: + post: + consumes: + - application/json + description: 提交 PoW 解答进行核销,成功后返回一次性 X-Cap-Token 凭证 + parameters: + - description: 难题 Token 与解答 solutions 数组 + in: body + name: request + required: true + schema: + $ref: '#/definitions/cap.redeemRequest' + produces: + - application/json + responses: + "200": + description: 核销成功,返回 X-Cap-Token + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/Wavelet_OpenFlare_plugins_server_cap.RedeemResponse' + type: object + "400": + description: 参数错误或核销失败 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部服务错误 + schema: + $ref: '#/definitions/response.Any' + summary: 校验人机验证解答 + tags: + - cap + /api/health: + get: + description: 检查服务是否正常运行,可用于负载均衡存活探测 + produces: + - application/json + responses: + "200": + description: 服务正常 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + summary: 健康检查 + tags: + - health + /api/v1/admin/auth-sources: + get: + description: 返回所有已配置的 OAuth/OIDC 认证源列表,包括已启用和未启用的,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 认证源列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/model.AuthSource' + type: array + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取认证源列表 + tags: + - admin + post: + consumes: + - application/json + description: 创建一个新的 OAuth/OIDC 认证源配置,认证源名称必须唯一且符合命名规范,需要管理员权限 + parameters: + - description: 创建认证源参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/auth_source.AuthSourceRequest' + produces: + - application/json + responses: + "200": + description: 创建成功,返回认证源信息 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.AuthSource' + type: object + "400": + description: 参数错误或验证失败 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 创建认证源 + tags: + - admin + /api/v1/admin/auth-sources/{id}: + delete: + description: 删除指定认证源及其关联的所有外部帐号绑定记录,警告:删除后相关用户将无法通过该源登录,需要管理员权限 + parameters: + - description: 认证源 ID 或名称 + format: int64 + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: ID 无效或删除失败 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除认证源 + tags: + - admin + put: + consumes: + - application/json + description: 更新指定 ID 的认证源配置。若 client_secret 字段为空,则保留原有密钥不变,需要管理员权限 + parameters: + - description: 认证源 ID 或名称 + format: int64 + in: path + name: id + required: true + type: integer + - description: 更新认证源参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/auth_source.AuthSourceRequest' + produces: + - application/json + responses: + "200": + description: 更新成功,返回更新后的认证源信息 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.AuthSource' + type: object + "400": + description: 参数错误或验证失败 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新认证源 + tags: + - admin + /api/v1/admin/auth-sources/{id}/toggle: + put: + consumes: + - application/json + description: 启用或禁用指定认证源。尝试启用时将验证 Client ID 和 Client Secret 是否已配置,需要管理员权限 + parameters: + - description: 认证源 ID 或名称 + format: int64 + in: path + name: id + required: true + type: integer + - description: 启用状态 + in: body + name: request + required: true + schema: + $ref: '#/definitions/auth_source.ToggleAuthSourceRequest' + produces: + - application/json + responses: + "200": + description: 切换成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: 验证失败或认证源不存在 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 切换认证源启用状态 + tags: + - admin + /api/v1/admin/cache/clear: + post: + description: 清除系统磁盘缓存目录中的所有临时文件,并重置缓存容量和 Key 追踪数据 + produces: + - application/json + responses: + "200": + description: 清理成功 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 服务内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 清空缓存 + tags: + - admin + /api/v1/admin/cache/config: + post: + consumes: + - application/json + description: 更改磁盘缓存最大容量限制、文件生存时间(TTL)以及是否启用 LRU 淘汰淘汰算法,并进行热更新 + parameters: + - description: 缓存配置请求体 + in: body + name: request + required: true + schema: + $ref: '#/definitions/cache.updateCacheConfigRequest' + produces: + - application/json + responses: + "200": + description: 更新成功 + schema: + $ref: '#/definitions/response.Any' + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 服务内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新缓存配置 + tags: + - admin + /api/v1/admin/cache/status: + get: + description: 获取当前系统磁盘缓存的使用情况(已占用字节、Key 数量等)与策略配置 + produces: + - application/json + responses: + "200": + description: 获取成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/diskcache.Status' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取缓存状态 + tags: + - admin + /api/v1/admin/db-export: + get: + description: SQLite 时直接下载 .db 文件;PostgreSQL 时执行 pg_dump 并流式下载 .sql 文件,需要管理员权限 + produces: + - application/octet-stream + responses: + "200": + description: 数据库文件 + schema: + type: file + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 导出失败 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 导出数据库 + tags: + - admin + /api/v1/admin/db-info: + get: + description: 返回当前使用的数据库类型(sqlite/postgres)、名称/路径及版本字符串,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 获取成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/status.DatabaseInfoResponse' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取数据库信息 + tags: + - admin + /api/v1/admin/db-manage/overview: + get: + description: 获取数据库类型、版本、名称、文件大小、表数量及当前连接数,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 获取成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/db_manage.DBOverviewResponse' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取数据库运行概览 + tags: + - admin + /api/v1/admin/db-manage/query: + post: + consumes: + - application/json + description: 在当前数据库中执行任意自定义 SQL,如果是查询语句将返回格式化后的列与数据集,否则返回受影响行数,需要管理员权限 + parameters: + - description: SQL 请求参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/db_manage.ExecuteSQLRequest' + produces: + - application/json + responses: + "200": + description: 执行完毕 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/db_manage.ExecuteSQLResponse' + type: object + "400": + description: SQL 语句错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 执行 SQL 查询 + tags: + - admin + /api/v1/admin/db-manage/tables: + get: + description: 返回当前数据库的所有用户自定义表名称列表,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 获取成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + type: string + type: array + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取数据库所有表名 + tags: + - admin + /api/v1/admin/logs: + get: + description: 分页获取系统历史日志,cursor=0 获取最新日志,cursor>0 获取更早日志 + parameters: + - default: 0 + description: 日志游标,0=获取最新 + in: query + name: cursor + type: integer + - default: 200 + description: 每页条数 + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: 日志列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/logs.logsResponse' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取系统日志 + tags: + - admin + /api/v1/admin/logs/access: + get: + description: 分页并按照用户、接口路径、时间范围等维度检索用户访问日志列表(需要管理员权限,日志存储未启用时报错) + parameters: + - default: 1 + description: 页码 + in: query + name: page + type: integer + - default: 20 + description: 每页条数 + in: query + name: page_size + type: integer + - description: 用户名模糊搜索 + in: query + name: username + type: string + - description: 接口路径模糊搜索 + in: query + name: path + type: string + - description: 起始时间(RFC3339 或 YYYY-MM-DD HH:MM:SS) + in: query + name: start_time + type: string + - description: 结束时间(RFC3339 或 YYYY-MM-DD HH:MM:SS) + in: query + name: end_time + type: string + produces: + - application/json + responses: + "200": + description: 访问日志列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/logs.accessLogsResponse' + type: object + "400": + description: 日志存储未启用或参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取用户访问日志 + tags: + - admin + /api/v1/admin/logs/analytics: + get: + description: 聚合统计最近 7 天的每日访问趋势、浏览器分布以及前 10 名最活跃用户排行(需要管理员权限,日志存储未启用时报错) + produces: + - application/json + responses: + "200": + description: 分析统计数据 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/logs.logsAnalyticsResponse' + type: object + "400": + description: 日志存储未启用 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取访问日志分析数据 + tags: + - admin + /api/v1/admin/logs/ws: + get: + description: 通过 WebSocket 实时推送系统日志,需要管理员权限 + responses: {} + summary: 系统日志实时推送 + tags: + - admin + /api/v1/admin/push/channels: + get: + description: 返回系统配置的所有消息通道列表,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 消息通道列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/model.PushChannel' + type: array + type: object + security: + - SessionCookie: [] + summary: 获取所有消息通道 + tags: + - admin-push + post: + consumes: + - application/json + description: 新建一个消息通道配置,需要管理员权限 + parameters: + - description: 创建参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/push.CreateChannelRequest' + produces: + - application/json + responses: + "200": + description: 创建成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.PushChannel' + type: object + security: + - SessionCookie: [] + summary: 创建消息通道 + tags: + - admin-push + /api/v1/admin/push/channels/{id}: + delete: + description: 根据ID删除消息通道,需要管理员权限 + parameters: + - description: 通道ID + format: int64 + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除消息通道 + tags: + - admin-push + put: + consumes: + - application/json + description: 修改消息通道配置,需要管理员权限 + parameters: + - description: 通道ID + format: int64 + in: path + name: id + required: true + type: integer + - description: 更新参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/push.UpdateChannelRequest' + produces: + - application/json + responses: + "200": + description: 更新成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.PushChannel' + type: object + security: + - SessionCookie: [] + summary: 更新消息通道 + tags: + - admin-push + /api/v1/admin/push/channels/definitions: + get: + description: 返回系统支持的所有消息通道类型(如飞书、邮件、自定义、Telegram)的动态表单定义,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 通道配置定义列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/push.Definition' + type: array + type: object + security: + - SessionCookie: [] + summary: 获取所有消息通道配置字段定义 + tags: + - admin-push + /api/v1/admin/push/channels/test: + post: + consumes: + - application/json + description: 触发一次临时的或现有的通道连通性推送测试,需要管理员权限 + parameters: + - description: 测试参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/push.TestChannelRequest' + produces: + - application/json + responses: + "200": + description: 测试触发成功 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 测试通道连通性 + tags: + - admin-push + /api/v1/admin/push/events: + get: + description: 返回系统配置的通知事件列表,包括预置和自定义事件,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 通知事件列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/model.PushEvent' + type: array + type: object + security: + - SessionCookie: [] + summary: 获取所有通知事件 + tags: + - admin-push + post: + consumes: + - application/json + description: 绑定系统内置事件或异步任务、推送渠道、接收目标并创建通知事件配置,需要管理员权限 + parameters: + - description: 创建参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/push.CreateEventRequest' + produces: + - application/json + responses: + "200": + description: 创建成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.PushEvent' + type: object + security: + - SessionCookie: [] + summary: 创建通知事件 + tags: + - admin-push + /api/v1/admin/push/events/{id}: + delete: + description: 删除数据库中的特定通知事件配置,需要管理员权限 + parameters: + - description: 事件 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + security: + - SessionCookie: [] + summary: 删除通知事件配置 + tags: + - admin-push + put: + consumes: + - application/json + description: 更新已有通知事件的推送渠道、接收目标和内容模板,需要管理员权限 + parameters: + - description: 事件 ID + in: path + name: id + required: true + type: integer + - description: 更新参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/push.UpdateEventRequest' + produces: + - application/json + responses: + "200": + description: 修改成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + security: + - SessionCookie: [] + summary: 更新通知事件 + tags: + - admin-push + /api/v1/admin/push/events/{id}/toggle: + post: + description: 启用或禁用指定的通知事件 + parameters: + - description: 事件 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 切换成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + security: + - SessionCookie: [] + summary: 快捷切换通知事件启用状态 + tags: + - admin-push + /api/v1/admin/push/events/builtin: + get: + description: 返回系统定义的所有内置通知事件元数据,供前端下拉框选择,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 内置通知事件列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/push.EventMetadata' + type: array + type: object + security: + - SessionCookie: [] + summary: 获取所有内置通知事件 + tags: + - admin-push + /api/v1/admin/push/histories: + get: + description: 返回分页的通知历史日志数据,需要管理员权限 + parameters: + - description: 当前页码 + in: query + name: page + type: integer + - description: 分页大小 + in: query + name: page_size + type: integer + - description: 过滤事件名称 + in: query + name: event_key + type: string + - description: 过滤发送状态 + in: query + name: status + type: string + produces: + - application/json + responses: + "200": + description: 推送历史列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/push.pushHistoriesResponse' + type: object + security: + - SessionCookie: [] + summary: 分页获取通知推送历史 + tags: + - admin-push + /api/v1/admin/push/test: + post: + consumes: + - application/json + description: 接收临时通知渠道配置并在本地同步调用 Pusher.Send 发送测试消息 + parameters: + - description: 测试请求体 + in: body + name: request + required: true + schema: + $ref: '#/definitions/push.TestPushRequest' + produces: + - application/json + responses: + "200": + description: 测试成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + security: + - SessionCookie: [] + summary: 测试推送通道发送 + tags: + - admin-push + /api/v1/admin/status: + get: + description: 获取后端服务运行状态、Goroutine、内存指标等详细统计数据,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 获取成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/status.SystemStatusResponse' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取系统状态信息 + tags: + - admin + /api/v1/admin/status/log-database: + get: + description: 返回当前日志主库、迁移状态、各库保留天数与合法迁移目标,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 获取成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/status.LogDatabaseStatus' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取日志数据库状态 + tags: + - admin + /api/v1/admin/system-configs: + get: + description: 返回所有系统配置列表,支持按配置类型(system/business)过滤,需要管理员权限 + parameters: + - description: 配置类型(system/business) + in: query + name: type + type: string + produces: + - application/json + responses: + "200": + description: 系统配置列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/model.SystemConfig' + type: array + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取系统配置列表 + tags: + - admin + post: + consumes: + - application/json + description: 创建一条新的系统配置项,配置键不可重复,同时将新配置同步到 Redis,需要管理员权限 + parameters: + - description: 创建请求参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/system_config.CreateSystemConfigRequest' + produces: + - application/json + responses: + "200": + description: 创建成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: 参数错误或配置键已存在 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 创建系统配置 + tags: + - admin + /api/v1/admin/system-configs/{key}: + get: + description: 根据配置键获取对应的系统配置详情,需要管理员权限 + parameters: + - description: 配置键 + in: path + name: key + required: true + type: string + produces: + - application/json + responses: + "200": + description: 系统配置详情 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.SystemConfig' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 配置不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取单个系统配置 + tags: + - admin + put: + consumes: + - application/json + description: 根据配置键更新对应的配置内容,同时将更新同步到 Redis,需要管理员权限 + parameters: + - description: 配置键 + in: path + name: key + required: true + type: string + - description: 更新请求参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/system_config.UpdateSystemConfigRequest' + produces: + - application/json + responses: + "200": + description: 更新成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 配置不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新系统配置 + tags: + - admin + /api/v1/admin/system-configs/smtp/test: + post: + consumes: + - application/json + description: 使用传入的配置进行 SMTP 邮件发送测试,支持使用 ****** 占位符使用保存的数据库密码 + parameters: + - description: 测试请求参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/system_config.TestSMTPRequest' + produces: + - application/json + responses: + "200": + description: 测试执行完毕 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/system_config.TestSMTPResponse' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 测试 SMTP 邮件发送 + tags: + - admin + /api/v1/admin/tasks/dispatch: + post: + consumes: + - application/json + description: 手动触发指定类型的异步任务,支持指定时间范围和用户,需要管理员权限 + parameters: + - description: 任务请求参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/task.DispatchTaskRequest' + produces: + - application/json + responses: + "200": + description: 任务已入队 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: 任务类型不存在或参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 任务入队失败 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 下发异步任务 + tags: + - admin + /api/v1/admin/tasks/executions: + get: + description: 分页查询任务执行记录,支持按状态和任务类型筛选,需要管理员权限 + parameters: + - description: 状态筛选 (pending/running/succeeded/failed) + in: query + name: status + type: string + - description: 任务类型筛选 + in: query + name: task_type + type: string + - description: 任务类型前缀筛选(与 task_type / task_types 互斥,精确类型优先) + in: query + name: task_type_prefix + type: string + - description: 逗号分隔的精确任务类型列表(IN 筛选,优先于前缀) + in: query + name: task_types + type: string + - default: 1 + description: 页码 + in: query + name: page + type: integer + - default: 20 + description: 每页条数 + in: query + name: page_size + type: integer + produces: + - application/json + responses: + "200": + description: 任务执行记录列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: object + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 查询任务执行记录 + tags: + - admin + /api/v1/admin/tasks/executions/{id}: + get: + description: 根据 ID 查询任务执行记录详情,包含完整执行日志,需要管理员权限 + parameters: + - description: 任务执行记录 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 任务执行详情 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.TaskExecution' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 查询任务执行详情 + tags: + - admin + /api/v1/admin/tasks/executions/{id}/retry: + post: + description: 重新下发一条失败的任务,创建新的执行记录,需要管理员权限 + parameters: + - description: 任务执行记录 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 新任务的 TaskID + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: 任务不支持重试或参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 重试失败 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 重试失败任务 + tags: + - admin + /api/v1/admin/tasks/schedules: + get: + description: 返回管理员可管理的定时任务配置列表,包括名称、关联的异步任务类型、Cron 表达式和启用状态;系统内部排程不会暴露,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 定时任务列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/model.Schedule' + type: array + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取定时任务列表 + tags: + - admin + post: + consumes: + - application/json + description: 新增一个动态定时任务配置,关联已有的异步任务,配置 Cron 表达式和执行参数,并触发调度器热加载,需要管理员权限 + parameters: + - description: 创建定时任务请求参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/task.CreateScheduleRequest' + produces: + - application/json + responses: + "200": + description: 创建成功的定时任务信息 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.Schedule' + type: object + "400": + description: Cron 表达式无效、异步任务类型不存在或参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 保存定时任务失败 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 创建定时任务 + tags: + - admin + /api/v1/admin/tasks/schedules/{id}: + delete: + description: 删除指定的定时任务配置,并触发调度器热加载,需要管理员权限 + parameters: + - description: 定时任务 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 删除结果 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 定时任务不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 删除定时任务失败 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除定时任务 + tags: + - admin + put: + consumes: + - application/json + description: 修改一个定时任务的配置(名称、Cron 表达式、异步任务参数和是否启用等),并触发调度器热加载,需要管理员权限 + parameters: + - description: 定时任务 ID + in: path + name: id + required: true + type: integer + - description: 修改定时任务请求参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/task.UpdateScheduleRequest' + produces: + - application/json + responses: + "200": + description: 修改后的定时任务信息 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.Schedule' + type: object + "400": + description: Cron 表达式无效、参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 定时任务不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 修改定时任务失败 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 修改定时任务 + tags: + - admin + /api/v1/admin/tasks/types: + get: + description: 返回系统支持的所有可调度任务类型列表,包括任务名称、描述、是否支持时间范围等元数据,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 任务类型列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/task.TaskMeta' + type: array + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取支持的任务类型 + tags: + - admin + /api/v1/admin/templates: + get: + description: 返回所有通知模板列表,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 模板列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/model.Template' + type: array + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取模板列表 + tags: + - admin + post: + consumes: + - application/json + description: 创建一条新的自定义通知模板,模板标识符(Key)不可重复,需要管理员权限 + parameters: + - description: 创建请求参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/template.CreateTemplateRequest' + produces: + - application/json + responses: + "200": + description: 创建成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: 参数错误或模板标识符已存在 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 创建模板 + tags: + - admin + /api/v1/admin/templates/{key}: + delete: + description: 根据模板标识符删除对应模板,系统预置模板不可删除,需要管理员权限 + parameters: + - description: 模板标识符 + in: path + name: key + required: true + type: string + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: 不可删除系统模板 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 模板不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除模板 + tags: + - admin + get: + description: 根据模板标识符获取对应的模板详情,需要管理员权限 + parameters: + - description: 模板标识符 + in: path + name: key + required: true + type: string + produces: + - application/json + responses: + "200": + description: 模板详情 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.Template' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 模板不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取单个模板 + tags: + - admin + put: + consumes: + - application/json + description: 根据模板标识符更新对应的模板内容,需要管理员权限 + parameters: + - description: 模板标识符 + in: path + name: key + required: true + type: string + - description: 更新请求参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/template.UpdateTemplateRequest' + produces: + - application/json + responses: + "200": + description: 更新成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.Template' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 模板不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新模板 + tags: + - admin + /api/v1/admin/update: + get: + description: 从系统配置指定的 GitHub 上游仓库查询最新兼容 Release,并与当前服务版本比较 + produces: + - application/json + responses: + "200": + description: 更新状态 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/updater.Status' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 查询失败 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取应用更新状态 + tags: + - admin + /api/v1/admin/update/apply: + post: + description: 下载当前平台对应的 GitHub Actions Release 资产,替换当前二进制并重启进程 + produces: + - application/json + responses: + "200": + description: 升级已准备并即将重启 + schema: + $ref: '#/definitions/response.Any' + "400": + description: 当前版本不可升级 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 升级准备失败 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 下载并应用应用更新 + tags: + - admin + /api/v1/admin/uploads: + get: + description: 分页获取系统上传的文件列表,支持文件名关键词、业务类型、扩展名、上传用户ID过滤 + parameters: + - description: 页码(默认 1) + in: query + name: page + type: integer + - description: 每页数量(默认 20,最大 100) + in: query + name: page_size + type: integer + - description: 文件名关键词(模糊匹配) + in: query + name: keyword + type: string + - description: 业务分类过滤 + in: query + name: type + type: string + - description: 扩展名过滤 + in: query + name: extension + type: string + - description: 上传用户 ID + format: int64 + in: query + name: user_id + type: integer + produces: + - application/json + responses: + "200": + description: 查询成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/handler.listFilesResponse' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取文件列表 + tags: + - admin + /api/v1/admin/uploads/{id}: + delete: + description: 将文件状态置为 deleted(软删除),不会立即清理底层存储对象 + parameters: + - description: 文件 ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无权操作 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 文件不存在 + schema: + $ref: '#/definitions/response.Any' + "409": + description: 系统保留类型或存储只读 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除文件 + tags: + - admin + /api/v1/admin/uploads/download/{id}: + get: + description: 根据文件 ID 获取文件,以附件形式 (Attachment) 强制开启客户端浏览器下载 + parameters: + - description: 文件 ID + in: path + name: id + required: true + type: string + - description: 图片质量 (low, medium, high, origin),默认为 origin + in: query + name: quality + type: string + produces: + - application/octet-stream + responses: + "200": + description: 成功下载文件 + schema: + type: file + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 文件不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 服务内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 下载单文件 + tags: + - admin + /api/v1/admin/uploads/download/batch: + post: + consumes: + - application/json + description: 传入多个文件 ID,后台实时将其打包压缩为 ZIP 流并输出,自动处理文件名重复冲突 + parameters: + - description: 包含文件 ID 数组 of string 的请求体 + in: body + name: request + required: true + schema: + $ref: '#/definitions/handler.batchDownloadRequest' + produces: + - application/octet-stream + responses: + "200": + description: 成功下载打包后的 ZIP + schema: + type: file + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 打包失败 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 批量打包下载 + tags: + - admin + /api/v1/admin/uploads/stats: + get: + description: 返回系统级的总文件数、占用大小、最近 7 天新增趋势、文件类型/格式分布等数据 + produces: + - application/json + responses: + "200": + description: 获取成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/handler.fileStatsResponse' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取文件统计数据 + tags: + - admin + /api/v1/admin/uploads/types: + get: + description: 返回数据库中所有已上传文件实际拥有的业务类型列表 + produces: + - application/json + responses: + "200": + description: 业务类型列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + type: string + type: array + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取文件业务类型列表 + tags: + - admin + /api/v1/admin/users: + get: + description: 分页返回用户列表,支持按用户 ID 和用户名筛选,需要管理员权限 + parameters: + - in: query + name: email + type: string + - in: query + minimum: 1 + name: page + type: integer + - in: query + maximum: 100 + minimum: 1 + name: page_size + type: integer + - in: query + name: user_id + type: integer + - in: query + name: username + type: string + produces: + - application/json + responses: + "200": + description: 用户列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/user.listUsersResponse' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取用户列表 + tags: + - admin + post: + consumes: + - application/json + description: 创建一个本地密码登录的新用户,需要管理员权限 + parameters: + - description: 创建用户参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/user.createUserRequest' + produces: + - application/json + responses: + "200": + description: 创建成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/user.user' + type: object + "400": + description: 参数错误或用户名已存在 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 创建用户 + tags: + - admin + /api/v1/admin/users/{id}: + delete: + description: 删除指定非管理员用户,需要管理员权限,不能删除当前登录用户 + parameters: + - description: 用户 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限、尝试删除管理员或当前用户 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 用户不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除用户 + tags: + - admin + get: + description: 返回指定用户的完整个人资料和系统状态,需要管理员权限,不返回密码等敏感字段 + parameters: + - description: 用户 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 用户详情 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/user.user' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 用户不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取用户详情 + tags: + - admin + put: + consumes: + - application/json + description: 更新指定用户的昵称、邮箱、管理员权限,并可选重置密码,需要管理员权限 + parameters: + - description: 用户 ID + in: path + name: id + required: true + type: integer + - description: 更新参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/user.updateUserRequest' + produces: + - application/json + responses: + "200": + description: 更新成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限或尝试修改自身权限 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 用户不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新用户信息 + tags: + - admin + /api/v1/admin/users/{id}/status: + put: + consumes: + - application/json + description: 启用或禁用指定用户,管理员账号无法被禁用,需要管理员权限 + parameters: + - description: 用户 ID + in: path + name: id + required: true + type: integer + - description: 状态参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/user.updateUserStatusRequest' + produces: + - application/json + responses: + "200": + description: 更新成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限或尝试禁用管理员 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 用户不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新用户状态 + tags: + - admin + /api/v1/agent/apply-logs: + post: + consumes: + - application/json + description: 记录 Agent 配置下发与应用结果 + parameters: + - description: 应用日志 + in: body + name: body + required: true + schema: + $ref: '#/definitions/agent.ApplyLogPayload' + produces: + - application/json + responses: + "200": + description: 日志记录 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.OpenFlareApplyLog' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: Token 无效 + schema: + $ref: '#/definitions/response.Any' + security: + - AgentTokenAuth: [] + summary: 上报配置应用日志 + tags: + - openflare-agent + /api/v1/agent/config-versions/active: + get: + description: 返回当前生效的完整配置包,供 Agent 拉取并应用 + produces: + - application/json + responses: + "200": + description: 活跃配置 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/agent.ConfigResponse' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: Token 无效 + schema: + $ref: '#/definitions/response.Any' + security: + - AgentTokenAuth: [] + summary: 获取活跃配置版本 + tags: + - openflare-agent + /api/v1/agent/nodes/heartbeat: + post: + consumes: + - application/json + description: 上报节点状态、指标与健康事件,返回远程控制配置与活跃配置元信息 + parameters: + - description: 心跳数据 + in: body + name: body + required: true + schema: + $ref: '#/definitions/agent.NodePayload' + produces: + - application/json + responses: + "200": + description: 心跳成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/agent.HeartbeatResponse' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: Token 无效 + schema: + $ref: '#/definitions/response.Any' + security: + - AgentTokenAuth: [] + summary: Agent 心跳上报 + tags: + - openflare-agent + /api/v1/agent/nodes/register: + post: + consumes: + - application/json + description: 使用节点 access token 重新注册,或使用全局 discovery token 发现新节点;请求头需携带 X-Agent-Token + parameters: + - description: 节点上报数据 + in: body + name: body + required: true + schema: + $ref: '#/definitions/agent.NodePayload' + produces: + - application/json + responses: + "200": + description: 注册成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/agent.RegistrationResponse' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: Token 无效 + schema: + $ref: '#/definitions/response.Any' + security: + - AgentTokenAuth: [] + summary: 注册或发现 Agent 节点 + tags: + - openflare-agent + /api/v1/agent/pages/deployments/{deployment_id}/hash: + get: + description: 返回 upload 框架记录的 SHA-256 哈希,供 Agent 对比本地缓存并按需拉取部署包(兼容旧路径) + parameters: + - description: 部署 ID + in: path + name: deployment_id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/Wavelet_OpenFlare_share_protocol.PagesDeploymentHashResponse' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: Token 无效 + schema: + $ref: '#/definitions/response.Any' + security: + - AgentTokenAuth: [] + summary: 查询 Pages 部署包哈希 + tags: + - openflare-agent + /api/v1/agent/pages/deployments/{deployment_id}/package: + get: + description: 流式下载指定部署的静态资源压缩包,供 Agent 边缘分发(兼容旧路径) + parameters: + - description: 部署 ID + in: path + name: deployment_id + required: true + type: integer + produces: + - application/octet-stream + responses: + "200": + description: 部署包文件 + schema: + type: file + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: Token 无效 + schema: + $ref: '#/definitions/response.Any' + security: + - AgentTokenAuth: [] + summary: 下载 Pages 部署包 + tags: + - openflare-agent + /api/v1/agent/pages/projects/{project_id}/latest/hash: + get: + description: 按项目 ID 返回当前激活部署的包哈希(类似 latest 指针),Agent 无需关心具体部署 ID + parameters: + - description: Pages 项目 ID + in: path + name: project_id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/Wavelet_OpenFlare_share_protocol.PagesProjectLatestHashResponse' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: Token 无效 + schema: + $ref: '#/definitions/response.Any' + security: + - AgentTokenAuth: [] + summary: 查询 Pages 项目最新激活部署哈希 + tags: + - openflare-agent + /api/v1/agent/pages/projects/{project_id}/latest/package: + get: + description: 按项目 ID 下载当前激活部署的压缩包,供 Agent 边缘分发 + parameters: + - description: Pages 项目 ID + in: path + name: project_id + required: true + type: integer + produces: + - application/octet-stream + responses: + "200": + description: 部署包文件 + schema: + type: file + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: Token 无效 + schema: + $ref: '#/definitions/response.Any' + security: + - AgentTokenAuth: [] + summary: 下载 Pages 项目最新激活部署包 + tags: + - openflare-agent + /api/v1/agent/waf/ip-groups/sync: + post: + consumes: + - application/json + description: 按 ID 与校验和增量同步 WAF IP 组定义 + parameters: + - description: 同步请求 + in: body + name: body + required: true + schema: + $ref: '#/definitions/agent.WAFIPGroupSyncInput' + produces: + - application/json + responses: + "200": + description: 同步结果 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/agent.WAFIPGroupSyncResult' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: Token 无效 + schema: + $ref: '#/definitions/response.Any' + security: + - AgentTokenAuth: [] + summary: 同步 WAF IP 组 + tags: + - openflare-agent + /api/v1/agent/ws: + get: + description: 升级为 WebSocket 长连接,用于实时推送配置同步、WAF IP 组等指令;需携带 X-Agent-Token + responses: + "401": + description: Token 无效 + schema: + $ref: '#/definitions/response.Any' + security: + - AgentTokenAuth: [] + summary: Agent WebSocket 连接 + tags: + - openflare-agent + /api/v1/config/public: + get: + consumes: + - application/json + description: 返回系统配置表中 visibility 为 1 的配置键值集合 + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.Any' + summary: 获取公共配置 + tags: + - config + /api/v1/d/access-logs: + get: + description: 分页返回 OpenFlare 访问日志,支持按节点、IP、主机、路径与状态码筛选,需要管理员权限 + parameters: + - description: 节点 ID + in: query + name: node_id + type: string + - description: 客户端 IP + in: query + name: remote_addr + type: string + - description: 请求 Host + in: query + name: host + type: string + - description: 请求路径 + in: query + name: path + type: string + - description: HTTP 状态码(100-599) + in: query + name: status_code + type: integer + - description: 起始时间(RFC3339,需与 until 成对提供) + in: query + name: since + type: string + - description: 结束时间(RFC3339,需与 since 成对提供) + in: query + name: until + type: string + - description: 页码 + in: query + name: p + type: integer + - description: 每页条数 + in: query + name: page_size + type: integer + - description: 排序字段 + in: query + name: sort_by + type: string + - description: 排序方向 + in: query + name: sort_order + type: string + produces: + - application/json + responses: + "200": + description: 访问日志列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/observability.AccessLogList' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 列出访问日志 + tags: + - openflare-observability + /api/v1/d/access-logs/cleanup: + post: + consumes: + - application/json + description: 按保留天数清理过期访问日志记录,需要管理员权限 + parameters: + - description: 清理参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/observability.AccessLogCleanupInput' + produces: + - application/json + responses: + "200": + description: 清理结果 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/observability.AccessLogCleanupResult' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 清理访问日志 + tags: + - openflare-observability + /api/v1/d/access-logs/folds: + get: + description: 按时间桶聚合访问日志并分页返回,需要管理员权限 + parameters: + - description: 节点 ID + in: query + name: node_id + type: string + - description: 客户端 IP + in: query + name: remote_addr + type: string + - description: 请求 Host + in: query + name: host + type: string + - description: 请求路径 + in: query + name: path + type: string + - description: 折叠时间窗口(分钟) + in: query + name: fold_minutes + type: integer + - description: 页码 + in: query + name: p + type: integer + - description: 每页条数 + in: query + name: page_size + type: integer + - description: 排序字段 + in: query + name: sort_by + type: string + - description: 排序方向 + in: query + name: sort_order + type: string + produces: + - application/json + responses: + "200": + description: 折叠访问日志列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/observability.FoldedAccessLogList' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 列出折叠访问日志 + tags: + - openflare-observability + /api/v1/d/access-logs/folds/ip-summary: + get: + description: 在指定时间桶内按 IP 聚合访问统计,需要管理员权限 + parameters: + - description: 节点 ID + in: query + name: node_id + type: string + - description: 客户端 IP + in: query + name: remote_addr + type: string + - description: 请求 Host + in: query + name: host + type: string + - description: 请求路径 + in: query + name: path + type: string + - description: 时间桶起始时间 + in: query + name: bucket_started_at + type: string + - description: 折叠时间窗口(分钟) + in: query + name: fold_minutes + type: integer + - description: 页码 + in: query + name: p + type: integer + - description: 每页条数 + in: query + name: page_size + type: integer + - description: 排序字段 + in: query + name: sort_by + type: string + - description: 排序方向 + in: query + name: sort_order + type: string + produces: + - application/json + responses: + "200": + description: 折叠 IP 汇总列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/observability.FoldedAccessLogIPList' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 列出折叠访问日志 IP 汇总 + tags: + - openflare-observability + /api/v1/d/access-logs/ip-summary: + get: + description: 按 IP 聚合访问日志统计并分页返回;支持 hours 或 since/until 时间窗,需要管理员权限 + parameters: + - description: 节点 ID + in: query + name: node_id + type: string + - description: 客户端 IP + in: query + name: remote_addr + type: string + - description: 请求 Host + in: query + name: host + type: string + - description: 统计时间范围(小时,1-720,默认 168) + in: query + name: hours + type: integer + - description: 开始时间 RFC3339(与 until 同时提供时优先于 hours) + in: query + name: since + type: string + - description: 结束时间 RFC3339 + in: query + name: until + type: string + - description: 页码 + in: query + name: p + type: integer + - description: 每页条数 + in: query + name: page_size + type: integer + - description: 排序字段 total_requests|request_length|bytes_sent|success_ratio|last_seen_at|remote_addr + in: query + name: sort_by + type: string + - description: 排序方向 + in: query + name: sort_order + type: string + produces: + - application/json + responses: + "200": + description: IP 汇总列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/observability.AccessLogIPSummaryList' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 列出访问日志 IP 汇总 + tags: + - openflare-observability + /api/v1/d/access-logs/ip-summary/analysis: + get: + description: 返回指定 IP 的汇总指标与 Top 分布,需要管理员权限 + parameters: + - description: 节点 ID + in: query + name: node_id + type: string + - description: 客户端 IP + in: query + name: remote_addr + type: string + - description: 请求 Host + in: query + name: host + type: string + - description: 统计时间范围(小时) + in: query + name: hours + type: integer + produces: + - application/json + responses: + "200": + description: IP 访问分析 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/observability.AccessLogIPAnalysisView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取访问日志 IP 分析 + tags: + - openflare-observability + /api/v1/d/access-logs/ip-summary/trend: + get: + description: 返回指定 IP 在时间范围内的访问趋势数据,需要管理员权限 + parameters: + - description: 节点 ID + in: query + name: node_id + type: string + - description: 客户端 IP + in: query + name: remote_addr + type: string + - description: 请求 Host + in: query + name: host + type: string + - description: 统计时间范围(小时) + in: query + name: hours + type: integer + - description: 时间桶粒度(分钟) + in: query + name: bucket_minutes + type: integer + produces: + - application/json + responses: + "200": + description: IP 访问趋势 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/observability.AccessLogIPTrendView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取访问日志 IP 趋势 + tags: + - openflare-observability + /api/v1/d/access-logs/overview: + get: + description: 返回访问日志汇总指标、趋势与 Top 排行,需要管理员权限 + parameters: + - description: 节点 ID + in: query + name: node_id + type: string + - description: 请求 Host(单域名) + in: query + name: host + type: string + - collectionFormat: csv + description: 请求 Host 列表(多域名精确匹配) + in: query + items: + type: string + name: hosts + type: array + - description: 统计时间范围(小时) + in: query + name: hours + type: integer + - description: 趋势桶分钟数(1、3、5 或 60,默认 60) + in: query + name: bucket_minutes + type: integer + produces: + - application/json + responses: + "200": + description: 访问日志概览 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/observability.AccessLogOverview' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取访问日志概览 + tags: + - openflare-observability + /api/v1/d/acme-accounts/default: + get: + description: 返回系统默认 ACME 账号配置,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 默认 ACME 账号 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.AcmeAccount' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取默认 ACME 账号 + tags: + - openflare-tls + /api/v1/d/apply-logs: + get: + description: 分页返回节点配置下发记录,支持按节点 ID 筛选,需要管理员权限 + parameters: + - description: 节点 ID 筛选 + in: query + name: node_id + type: string + - description: 页码 + in: query + name: pageNo + type: integer + - description: 页码(别名) + in: query + name: page_no + type: integer + - description: 每页数量 + in: query + name: pageSize + type: integer + - description: 每页数量(别名) + in: query + name: page_size + type: integer + produces: + - application/json + responses: + "200": + description: 下发日志列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/apply_log.ListResult' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取配置下发日志 + tags: + - openflare-apply-log + /api/v1/d/apply-logs/cleanup: + post: + consumes: + - application/json + description: 按保留天数清理历史下发记录,或删除全部记录,需要管理员权限 + parameters: + - description: 清理参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/apply_log.CleanupInput' + produces: + - application/json + responses: + "200": + description: 清理结果 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/apply_log.CleanupResult' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 清理配置下发日志 + tags: + - openflare-apply-log + /api/v1/d/cloudflare/connection: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/cloudflare.ConnectionView' + type: object + security: + - SessionCookie: [] + summary: 获取 Cloudflare 连接 + tags: + - openflare-cloudflare + put: + consumes: + - application/json + parameters: + - description: 连接参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/cloudflare.ConnectionInput' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/cloudflare.ConnectionView' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 保存 Cloudflare 连接 + tags: + - openflare-cloudflare + /api/v1/d/cloudflare/connection/clear: + post: + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 清除 Cloudflare 连接 + tags: + - openflare-cloudflare + /api/v1/d/cloudflare/connection/verify: + post: + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/cloudflare.ConnectionView' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 测试 Cloudflare 连接 + tags: + - openflare-cloudflare + /api/v1/d/cloudflare/domains/available: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/cloudflare.AvailableDomain' + type: array + type: object + security: + - SessionCookie: [] + summary: 获取可加入 Cloudflare 指向的域名 + tags: + - openflare-cloudflare + /api/v1/d/cloudflare/groups: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/cloudflare.GroupItem' + type: array + type: object + security: + - SessionCookie: [] + summary: 获取 Cloudflare 指向分组 + tags: + - openflare-cloudflare + post: + consumes: + - application/json + parameters: + - description: 分组参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/cloudflare.GroupInput' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/cloudflare.GroupItem' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 创建 Cloudflare 指向分组 + tags: + - openflare-cloudflare + /api/v1/d/cloudflare/groups/{id}: + get: + parameters: + - description: 分组 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/cloudflare.GroupDetail' + type: object + "404": + description: Not Found + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取 Cloudflare 指向分组详情 + tags: + - openflare-cloudflare + /api/v1/d/cloudflare/groups/{id}/delete: + post: + parameters: + - description: 分组 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.Any' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.Any' + "404": + description: Not Found + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除 Cloudflare 指向分组 + tags: + - openflare-cloudflare + /api/v1/d/cloudflare/groups/{id}/members: + get: + parameters: + - description: 分组 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/cloudflare.MemberItem' + type: array + type: object + security: + - SessionCookie: [] + summary: 获取 Cloudflare 指向分组成员 + tags: + - openflare-cloudflare + post: + consumes: + - application/json + parameters: + - description: 分组 ID + in: path + name: id + required: true + type: integer + - description: 成员参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/cloudflare.MemberCreateInput' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/cloudflare.MemberItem' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.Any' + "409": + description: Conflict + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 添加 Cloudflare 指向成员 + tags: + - openflare-cloudflare + /api/v1/d/cloudflare/groups/{id}/members/{memberId}/remove: + post: + parameters: + - description: 分组 ID + in: path + name: id + required: true + type: integer + - description: 成员 ID + in: path + name: memberId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 移出 Cloudflare 指向成员 + tags: + - openflare-cloudflare + /api/v1/d/cloudflare/groups/{id}/members/{memberId}/sync: + post: + parameters: + - description: 分组 ID + in: path + name: id + required: true + type: integer + - description: 成员 ID + in: path + name: memberId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/cloudflare.SyncReceipt' + type: object + security: + - SessionCookie: [] + summary: 同步 Cloudflare 指向成员 + tags: + - openflare-cloudflare + /api/v1/d/cloudflare/groups/{id}/members/{memberId}/update: + post: + consumes: + - application/json + parameters: + - description: 分组 ID + in: path + name: id + required: true + type: integer + - description: 成员 ID + in: path + name: memberId + required: true + type: integer + - description: 成员参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/cloudflare.MemberUpdateInput' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/cloudflare.MemberItem' + type: object + security: + - SessionCookie: [] + summary: 更新 Cloudflare 指向成员 + tags: + - openflare-cloudflare + /api/v1/d/cloudflare/groups/{id}/sync: + post: + parameters: + - description: 分组 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/cloudflare.SyncReceipt' + type: object + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 同步 Cloudflare 指向分组 + tags: + - openflare-cloudflare + /api/v1/d/cloudflare/groups/{id}/update: + post: + consumes: + - application/json + parameters: + - description: 分组 ID + in: path + name: id + required: true + type: integer + - description: 分组参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/cloudflare.GroupInput' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/cloudflare.GroupItem' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.Any' + "404": + description: Not Found + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新 Cloudflare 指向分组 + tags: + - openflare-cloudflare + /api/v1/d/cloudflare/overview: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/cloudflare.Overview' + type: object + security: + - SessionCookie: [] + summary: 获取 Cloudflare 指向总览 + tags: + - openflare-cloudflare + /api/v1/d/config-versions: + get: + description: 返回所有已发布的 OpenResty 配置版本摘要,按创建时间倒序排列,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 配置版本列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/model.ConfigVersionSummary' + type: array + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取配置版本列表 + tags: + - openflare-config-version + /api/v1/d/config-versions/{id}: + get: + description: 返回指定配置版本的完整快照与渲染内容,需要管理员权限 + parameters: + - description: 配置版本 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 配置版本详情 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.ConfigVersion' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或版本不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取配置版本详情 + tags: + - openflare-config-version + /api/v1/d/config-versions/{id}/activate: + post: + description: 将指定历史版本设为当前活跃配置,需要管理员权限 + parameters: + - description: 配置版本 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 激活成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.ConfigVersion' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或版本不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 激活配置版本 + tags: + - openflare-config-version + /api/v1/d/config-versions/active: + get: + description: 返回当前正在使用的配置版本,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 活跃配置版本 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.ConfigVersion' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限、不存在或无活跃版本 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取当前活跃配置版本 + tags: + - openflare-config-version + /api/v1/d/config-versions/cleanup: + post: + consumes: + - application/json + description: 删除超出保留数量的非活跃配置版本,需要管理员权限 + parameters: + - description: 清理参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/config_version.CleanupInput' + produces: + - application/json + responses: + "200": + description: 清理结果 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/config_version.CleanupResult' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 清理历史配置版本 + tags: + - openflare-config-version + /api/v1/d/config-versions/diff: + get: + description: 对比当前草稿配置与活跃版本之间的差异,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 配置差异 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/config_version.ConfigDiffResult' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 对比草稿与活跃配置 + tags: + - openflare-config-version + /api/v1/d/config-versions/preview: + get: + description: 渲染并返回当前草稿配置的预览结果,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 配置预览 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/config_version.ConfigPreviewResult' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 预览当前草稿配置 + tags: + - openflare-config-version + /api/v1/d/config-versions/publish: + post: + description: 将当前草稿配置发布为新版本,需要管理员权限 + parameters: + - description: 是否强制发布 + in: query + name: force + type: boolean + produces: + - application/json + responses: + "200": + description: 发布成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.ConfigVersion' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 发布配置版本 + tags: + - openflare-config-version + /api/v1/d/dashboard/overview: + get: + description: 聚合节点与可观测性数据,返回 OpenFlare 控制台仪表盘概览,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 仪表盘概览 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/dashboard.OverviewPayload' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取仪表盘概览 + tags: + - openflare-dashboard + /api/v1/d/dns-accounts: + get: + description: 返回全部 DNS 提供商账号,需要管理员权限 + produces: + - application/json + responses: + "200": + description: DNS 账号列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/model.DNSAccount' + type: array + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 列出 DNS 账号 + tags: + - openflare-tls + post: + consumes: + - application/json + description: 创建新的 DNS 提供商账号,需要管理员权限 + parameters: + - description: DNS 账号参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/tls.DNSAccountInput' + produces: + - application/json + responses: + "200": + description: 创建成功的 DNS 账号 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.DNSAccount' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 创建 DNS 账号 + tags: + - openflare-tls + /api/v1/d/dns-accounts/{id}/delete: + post: + description: 按 ID 删除 DNS 提供商账号,需要管理员权限 + parameters: + - description: DNS 账号 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + $ref: '#/definitions/response.Any' + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除 DNS 账号 + tags: + - openflare-tls + /api/v1/d/dns-accounts/{id}/update: + post: + consumes: + - application/json + description: 按 ID 更新 DNS 提供商账号,需要管理员权限 + parameters: + - description: DNS 账号 ID + in: path + name: id + required: true + type: integer + - description: DNS 账号参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/tls.DNSAccountInput' + produces: + - application/json + responses: + "200": + description: 更新后的 DNS 账号 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.DNSAccount' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新 DNS 账号 + tags: + - openflare-tls + /api/v1/d/nodes: + get: + description: 返回所有节点及最新配置下发记录,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 节点列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/node.View' + type: array + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取节点列表 + tags: + - openflare-node + post: + consumes: + - application/json + description: 创建新的边缘节点记录,需要管理员权限 + parameters: + - description: 节点参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/node.Input' + produces: + - application/json + responses: + "200": + description: 创建成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/node.View' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 创建节点 + tags: + - openflare-node + /api/v1/d/nodes/{id}/agent-release: + get: + description: 返回指定节点可用的最新 Agent 版本信息,需要管理员权限 + parameters: + - description: 节点 ID + in: path + name: id + required: true + type: integer + - description: 发布渠道 + in: query + name: channel + type: string + produces: + - application/json + responses: + "200": + description: Agent 发布信息 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/node.AgentReleaseInfo' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或节点不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取 Agent 发布信息 + tags: + - openflare-node + /api/v1/d/nodes/{id}/agent-update: + post: + consumes: + - application/json + description: 向指定节点下发 Agent 自更新指令,需要管理员权限 + parameters: + - description: 节点 ID + in: path + name: id + required: true + type: integer + - description: 更新参数(可选) + in: body + name: body + schema: + $ref: '#/definitions/node.AgentUpdateInput' + produces: + - application/json + responses: + "200": + description: 更新请求已下发 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/node.View' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或节点不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 请求 Agent 更新 + tags: + - openflare-node + /api/v1/d/nodes/{id}/delete: + post: + description: 删除指定节点记录,需要管理员权限 + parameters: + - description: 节点 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或节点不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除节点 + tags: + - openflare-node + /api/v1/d/nodes/{id}/force-sync: + post: + description: 向指定节点下发强制同步当前活跃配置的指令,需要管理员权限 + parameters: + - description: 节点 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 同步请求已下发 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/node.View' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或节点不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 请求强制同步配置 + tags: + - openflare-node + /api/v1/d/nodes/{id}/observability: + get: + description: 返回指定节点的指标、健康事件与流量分析数据,需要管理员权限 + parameters: + - description: 节点 ID + in: path + name: id + required: true + type: integer + - description: 统计时间范围(小时) + in: query + name: hours + type: integer + - description: 返回记录数量上限 + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: 可观测性数据 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/node.ObservabilityView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或节点不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取节点可观测性数据 + tags: + - openflare-node + /api/v1/d/nodes/{id}/observability/cleanup: + post: + description: 清理指定节点的历史健康事件记录,需要管理员权限 + parameters: + - description: 节点 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 清理结果 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/node.HealthEventCleanupResult' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或节点不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 清理节点健康事件 + tags: + - openflare-node + /api/v1/d/nodes/{id}/openresty-restart: + post: + description: 向指定节点下发 OpenResty 重启指令,需要管理员权限 + parameters: + - description: 节点 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 重启请求已下发 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/node.View' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或节点不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 请求重启 OpenResty + tags: + - openflare-node + /api/v1/d/nodes/{id}/update: + post: + consumes: + - application/json + description: 更新指定节点的配置信息,需要管理员权限 + parameters: + - description: 节点 ID + in: path + name: id + required: true + type: integer + - description: 节点参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/node.Input' + produces: + - application/json + responses: + "200": + description: 更新成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/node.View' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或节点不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新节点 + tags: + - openflare-node + /api/v1/d/nodes/bootstrap-token: + get: + description: 返回全局节点发现引导令牌,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 引导令牌 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/node.BootstrapView' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取引导令牌 + tags: + - openflare-node + /api/v1/d/nodes/bootstrap-token/rotate: + post: + description: 重新生成全局节点发现引导令牌,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 新引导令牌 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/node.BootstrapView' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 轮换引导令牌 + tags: + - openflare-node + /api/v1/d/option: + get: + description: 返回全部非敏感 OpenFlare 配置项,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 配置项列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/model.OpenFlareOption' + type: array + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 列出 OpenFlare 配置项 + tags: + - openflare-option + /api/v1/d/option/geoip/lookup: + post: + consumes: + - application/json + description: 按提供商与 IP 查询地理位置信息,需要管理员权限 + parameters: + - description: 查询参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/option.geoIPLookupRequest' + produces: + - application/json + responses: + "200": + description: GeoIP 查询结果 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/option.geoIPLookupView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: GeoIP 地址查询 + tags: + - openflare-option + /api/v1/d/option/update: + post: + consumes: + - application/json + description: 更新单个 OpenFlare 配置项,需要管理员权限 + parameters: + - description: 配置项 + in: body + name: request + required: true + schema: + $ref: '#/definitions/model.OpenFlareOption' + produces: + - application/json + responses: + "200": + description: 更新成功 + schema: + $ref: '#/definitions/response.Any' + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新 OpenFlare 配置项 + tags: + - openflare-option + /api/v1/d/option/update-batch: + post: + consumes: + - application/json + description: 批量更新多个 OpenFlare 配置项,需要管理员权限 + parameters: + - description: 批量配置项 + in: body + name: request + required: true + schema: + $ref: '#/definitions/option.optionBatchPayload' + produces: + - application/json + responses: + "200": + description: 更新成功 + schema: + $ref: '#/definitions/response.Any' + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 批量更新 OpenFlare 配置项 + tags: + - openflare-option + /api/v1/d/origins: + get: + description: 返回所有源站及关联代理规则数量,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 源站列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/origin.View' + type: array + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取源站列表 + tags: + - openflare-origin + post: + consumes: + - application/json + description: 创建新的上游源站记录,需要管理员权限 + parameters: + - description: 源站参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/origin.Input' + produces: + - application/json + responses: + "200": + description: 创建成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/origin.View' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 创建源站 + tags: + - openflare-origin + /api/v1/d/origins/{id}: + get: + description: 返回指定源站信息及关联代理规则摘要,需要管理员权限 + parameters: + - description: 源站 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 源站详情 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/origin.DetailView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或源站不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取源站详情 + tags: + - openflare-origin + /api/v1/d/origins/{id}/delete: + post: + description: 删除指定源站记录,需要管理员权限 + parameters: + - description: 源站 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或源站不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除源站 + tags: + - openflare-origin + /api/v1/d/origins/{id}/update: + post: + consumes: + - application/json + description: 更新指定源站的配置信息,需要管理员权限 + parameters: + - description: 源站 ID + in: path + name: id + required: true + type: integer + - description: 源站参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/origin.Input' + produces: + - application/json + responses: + "200": + description: 更新成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/origin.View' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或源站不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新源站 + tags: + - openflare-origin + /api/v1/d/pages: + get: + description: 返回全部 OpenFlare Pages 项目,需要管理员权限 + produces: + - application/json + responses: + "200": + description: Pages 项目列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/pages.View' + type: array + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 列出 Pages 项目 + tags: + - openflare-pages + post: + consumes: + - application/json + description: 创建新的 OpenFlare Pages 项目,需要管理员权限 + parameters: + - description: 项目参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/pages.Input' + produces: + - application/json + responses: + "200": + description: 创建成功的项目 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/pages.View' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 创建 Pages 项目 + tags: + - openflare-pages + /api/v1/d/pages/{id}: + get: + description: 按 ID 返回 Pages 项目详情,需要管理员权限 + parameters: + - description: 项目 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Pages 项目详情 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/pages.View' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 项目不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取 Pages 项目详情 + tags: + - openflare-pages + /api/v1/d/pages/{id}/delete: + post: + description: 按 ID 删除 OpenFlare Pages 项目,需要管理员权限 + parameters: + - description: 项目 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + $ref: '#/definitions/response.Any' + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 项目不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除 Pages 项目 + tags: + - openflare-pages + /api/v1/d/pages/{id}/deployments: + get: + description: 返回指定项目的全部部署记录,需要管理员权限 + parameters: + - description: 项目 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 部署列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/pages.DeploymentView' + type: array + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 项目不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 列出 Pages 部署 + tags: + - openflare-pages + /api/v1/d/pages/{id}/deployments/{deployment_id}/activate: + post: + description: 将指定部署设为项目当前生效版本,需要管理员权限 + parameters: + - description: 项目 ID + in: path + name: id + required: true + type: integer + - description: 部署 ID + in: path + name: deployment_id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 激活后的项目 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/pages.View' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 项目或部署不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 激活 Pages 部署 + tags: + - openflare-pages + /api/v1/d/pages/{id}/deployments/{deployment_id}/delete: + post: + description: 删除指定项目的部署记录,需要管理员权限 + parameters: + - description: 项目 ID + in: path + name: id + required: true + type: integer + - description: 部署 ID + in: path + name: deployment_id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + $ref: '#/definitions/response.Any' + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 项目或部署不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除 Pages 部署 + tags: + - openflare-pages + /api/v1/d/pages/{id}/deployments/upload: + post: + consumes: + - multipart/form-data + description: 为指定项目上传静态资源压缩包(zip/tar.gz/tar.xz/tar.bz2/tar/7z),需要管理员权限 + parameters: + - description: 项目 ID + in: path + name: id + required: true + type: integer + - description: 部署包文件 + in: formData + name: package + required: true + type: file + produces: + - application/json + responses: + "200": + description: 部署记录 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/pages.DeploymentView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 项目不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 上传 Pages 部署包 + tags: + - openflare-pages + /api/v1/d/pages/{id}/deployments/upload-from-url: + post: + consumes: + - application/json + deprecated: true + description: 已弃用的一次性 URL 导入;使用 trusted_internal 策略兼容内网与自签名证书,不创建持久部署源 + parameters: + - description: 项目 ID + in: path + name: id + required: true + type: integer + - description: 下载链接 + in: body + name: request + required: true + schema: + $ref: '#/definitions/pages.UploadFromURLInput' + produces: + - application/json + responses: + "200": + description: 部署记录 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/pages.DeploymentView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 项目不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 从 URL 导入 Pages 部署包 + tags: + - openflare-pages + /api/v1/d/pages/{id}/source: + get: + description: 返回项目部署源配置与运行状态,需要管理员权限 + parameters: + - description: 项目 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 部署源 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/pages.SourceView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 项目或部署源不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取 Pages 部署源 + tags: + - openflare-pages + /api/v1/d/pages/{id}/source/check: + post: + description: 异步检查 GitHub Release 来源;Remote URL 来源不支持检查更新 + parameters: + - description: 项目 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 任务回执 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/pages.SourceActionReceipt' + type: object + "400": + description: 当前来源不支持检查 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 部署源不存在 + schema: + $ref: '#/definitions/response.Any' + "409": + description: 来源任务正在执行 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 检查 Pages 部署源 + tags: + - openflare-pages + /api/v1/d/pages/{id}/source/delete: + post: + description: 幂等删除持久部署源;已有部署历史与当前生产部署保持不变 + parameters: + - description: 项目 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 手动来源视图 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/pages.SourceView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 项目不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除 Pages 部署源 + tags: + - openflare-pages + /api/v1/d/pages/{id}/source/sync: + post: + consumes: + - application/json + description: 异步下载、校验并原子激活来源部署包;空请求体与空 JSON 对象均有效 + parameters: + - description: 项目 ID + in: path + name: id + required: true + type: integer + - description: 同步参数 + in: body + name: request + schema: + $ref: '#/definitions/pages.SourceSyncInput' + produces: + - application/json + responses: + "200": + description: 任务回执 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/pages.SourceActionReceipt' + type: object + "400": + description: 参数或来源类型无效 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 部署源不存在 + schema: + $ref: '#/definitions/response.Any' + "409": + description: 来源任务正在执行 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 同步并发布 Pages 部署源 + tags: + - openflare-pages + /api/v1/d/pages/{id}/source/update: + post: + consumes: + - application/json + description: 支持 Remote URL 与公开 GitHub Release 来源;敏感地址仅写入,不会在响应中返回 + parameters: + - description: 项目 ID + in: path + name: id + required: true + type: integer + - description: 部署源配置 + in: body + name: request + required: true + schema: + $ref: '#/definitions/pages.SourceUpdateInput' + produces: + - application/json + responses: + "200": + description: 更新结果 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/pages.SourceUpdateResult' + type: object + "400": + description: 配置无效 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 项目不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新 Pages 部署源 + tags: + - openflare-pages + /api/v1/d/pages/{id}/update: + post: + consumes: + - application/json + description: 按 ID 更新 OpenFlare Pages 项目,需要管理员权限 + parameters: + - description: 项目 ID + in: path + name: id + required: true + type: integer + - description: 项目参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/pages.Input' + produces: + - application/json + responses: + "200": + description: 更新后的项目 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/pages.View' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 项目不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新 Pages 项目 + tags: + - openflare-pages + /api/v1/d/pages/deployments/{deployment_id}/files: + get: + description: 返回指定部署包含的文件清单,需要管理员权限 + parameters: + - description: 部署 ID + in: path + name: deployment_id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 部署文件列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/pages.DeploymentFileView' + type: array + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 部署不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 列出 Pages 部署文件 + tags: + - openflare-pages + /api/v1/d/proxy-routes: + get: + description: 返回所有代理规则配置,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 代理规则列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/proxy_route.View' + type: array + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取代理规则列表 + tags: + - openflare-proxy-route + post: + consumes: + - application/json + description: 创建新的反向代理规则,需要管理员权限 + parameters: + - description: 代理规则参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/proxy_route.Input' + produces: + - application/json + responses: + "200": + description: 创建成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/proxy_route.View' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 创建代理规则 + tags: + - openflare-proxy-route + /api/v1/d/proxy-routes/{id}: + get: + description: 返回指定代理规则的完整配置,需要管理员权限 + parameters: + - description: 代理规则 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 代理规则详情 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/proxy_route.View' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或规则不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取代理规则详情 + tags: + - openflare-proxy-route + /api/v1/d/proxy-routes/{id}/delete: + post: + description: 删除指定代理规则,需要管理员权限 + parameters: + - description: 代理规则 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或规则不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除代理规则 + tags: + - openflare-proxy-route + /api/v1/d/proxy-routes/{id}/update: + post: + consumes: + - application/json + description: 更新指定代理规则的配置,需要管理员权限 + parameters: + - description: 代理规则 ID + in: path + name: id + required: true + type: integer + - description: 代理规则参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/proxy_route.Input' + produces: + - application/json + responses: + "200": + description: 更新成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/proxy_route.View' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或规则不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新代理规则 + tags: + - openflare-proxy-route + /api/v1/d/status: + get: + description: 返回版本、认证源与系统公开配置,无需登录 + produces: + - application/json + responses: + "200": + description: 公开状态 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/option.statusView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + summary: 获取 OpenFlare 公开状态 + tags: + - openflare-option + /api/v1/d/tls-certificates: + get: + description: 返回全部 TLS 证书(不含 PEM),需要管理员权限 + produces: + - application/json + responses: + "200": + description: 证书列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/model.TLSCertificate' + type: array + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 列出 TLS 证书 + tags: + - openflare-tls + post: + consumes: + - application/json + description: 从 PEM 文本创建 TLS 证书,需要管理员权限 + parameters: + - description: 证书参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/tls.CertificateInput' + produces: + - application/json + responses: + "200": + description: 创建成功的证书 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.TLSCertificate' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 创建 TLS 证书 + tags: + - openflare-tls + /api/v1/d/tls-certificates/{id}: + get: + description: 按 ID 返回 TLS 证书详情(不含 PEM),需要管理员权限 + parameters: + - description: 证书 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 证书详情 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.TLSCertificate' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取 TLS 证书详情 + tags: + - openflare-tls + /api/v1/d/tls-certificates/{id}/content: + get: + description: 按 ID 返回证书与私钥 PEM 内容,需要管理员权限 + parameters: + - description: 证书 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 证书 PEM 内容 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/tls.CertificateContent' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取 TLS 证书 PEM 内容 + tags: + - openflare-tls + /api/v1/d/tls-certificates/{id}/convert-acme: + post: + consumes: + - application/json + description: 将已上传证书转换为 ACME 自动续期模式,需要管理员权限 + parameters: + - description: 证书 ID + in: path + name: id + required: true + type: integer + - description: ACME 申请参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/tls.ApplyInput' + produces: + - application/json + responses: + "200": + description: 转换后的证书 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.TLSCertificate' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 将证书转为 ACME 管理 + tags: + - openflare-tls + /api/v1/d/tls-certificates/{id}/delete: + post: + description: 按 ID 删除 TLS 证书,需要管理员权限 + parameters: + - description: 证书 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + $ref: '#/definitions/response.Any' + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除 TLS 证书 + tags: + - openflare-tls + /api/v1/d/tls-certificates/{id}/renew: + post: + description: 手动触发 ACME 证书续期,需要管理员权限 + parameters: + - description: 证书 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 续期后的证书 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.TLSCertificate' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 续期 ACME 证书 + tags: + - openflare-tls + /api/v1/d/tls-certificates/{id}/update: + post: + consumes: + - application/json + description: 按 ID 更新 TLS 证书 PEM 信息,需要管理员权限 + parameters: + - description: 证书 ID + in: path + name: id + required: true + type: integer + - description: 证书参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/tls.CertificateInput' + produces: + - application/json + responses: + "200": + description: 更新后的证书 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.TLSCertificate' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新 TLS 证书 + tags: + - openflare-tls + /api/v1/d/tls-certificates/{id}/update-acme: + post: + consumes: + - application/json + description: 按 ID 更新 ACME 证书申请配置,需要管理员权限 + parameters: + - description: 证书 ID + in: path + name: id + required: true + type: integer + - description: ACME 申请参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/tls.ApplyInput' + produces: + - application/json + responses: + "200": + description: 更新后的证书 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.TLSCertificate' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新 ACME 证书配置 + tags: + - openflare-tls + /api/v1/d/tls-certificates/apply: + post: + consumes: + - application/json + description: 通过 ACME 申请新的 TLS 证书,需要管理员权限 + parameters: + - description: ACME 申请参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/tls.ApplyInput' + produces: + - application/json + responses: + "200": + description: 申请中的证书 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.TLSCertificate' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 申请 ACME 证书 + tags: + - openflare-tls + /api/v1/d/tls-certificates/import-file: + post: + consumes: + - multipart/form-data + description: 上传证书与私钥文件创建 TLS 证书,需要管理员权限 + parameters: + - description: 证书名称 + in: formData + name: name + type: string + - description: 备注 + in: formData + name: remark + type: string + - description: 证书文件 + in: formData + name: cert_file + required: true + type: file + - description: 私钥文件 + in: formData + name: key_file + required: true + type: file + produces: + - application/json + responses: + "200": + description: 导入成功的证书 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.TLSCertificate' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 从文件导入 TLS 证书 + tags: + - openflare-tls + /api/v1/d/uptimekuma/sync: + post: + consumes: + - application/json + description: 将 OpenFlare 节点同步到 Uptime Kuma,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 同步成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 同步 Uptime Kuma + tags: + - openflare-option + /api/v1/d/waf/ip-groups: + get: + description: 返回全部 WAF IP 组,需要管理员权限 + produces: + - application/json + responses: + "200": + description: IP 组列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/waf.IPGroupView' + type: array + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 列出 WAF IP 组 + tags: + - openflare-waf + post: + consumes: + - application/json + description: 创建新的 WAF IP 组,需要管理员权限 + parameters: + - description: IP 组参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/waf.IPGroupInput' + produces: + - application/json + responses: + "200": + description: 创建成功的 IP 组 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/waf.IPGroupView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 创建 WAF IP 组 + tags: + - openflare-waf + /api/v1/d/waf/ip-groups/{id}: + get: + description: 按 ID 返回 WAF IP 组详情,需要管理员权限 + parameters: + - description: IP 组 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: IP 组详情 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/waf.IPGroupView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取 WAF IP 组详情 + tags: + - openflare-waf + /api/v1/d/waf/ip-groups/{id}/delete: + post: + description: 按 ID 删除 WAF IP 组,需要管理员权限 + parameters: + - description: IP 组 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + $ref: '#/definitions/response.Any' + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除 WAF IP 组 + tags: + - openflare-waf + /api/v1/d/waf/ip-groups/{id}/sync: + post: + description: 手动触发 WAF IP 组外部 IP 同步,需要管理员权限 + parameters: + - description: IP 组 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 同步结果 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/waf.IPGroupSyncResult' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 同步 WAF IP 组 + tags: + - openflare-waf + /api/v1/d/waf/ip-groups/{id}/update: + post: + consumes: + - application/json + description: 按 ID 更新 WAF IP 组,需要管理员权限 + parameters: + - description: IP 组 ID + in: path + name: id + required: true + type: integer + - description: IP 组参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/waf.IPGroupInput' + produces: + - application/json + responses: + "200": + description: 更新后的 IP 组 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/waf.IPGroupView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新 WAF IP 组 + tags: + - openflare-waf + /api/v1/d/waf/ip-groups/test: + post: + consumes: + - application/json + description: 根据自动配置规则测试 IP 匹配结果(桩实现),需要管理员权限 + parameters: + - description: 自动配置参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/waf.IPGroupAutoTestInput' + produces: + - application/json + responses: + "200": + description: 测试结果 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/waf.IPGroupAutoTestResult' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 测试 WAF IP 组自动配置 + tags: + - openflare-waf + /api/v1/d/waf/rule-groups: + get: + produces: + - application/json + responses: + "200": + description: 规则列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/waf.RuleView' + type: array + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 列出 WAF 规则 + tags: + - openflare-waf + post: + consumes: + - application/json + parameters: + - description: 规则名称 + in: body + name: request + required: true + schema: + $ref: '#/definitions/waf.CreateRuleInput' + produces: + - application/json + responses: + "200": + description: 创建成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/waf.RuleView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 创建 WAF 规则 + tags: + - openflare-waf + /api/v1/d/waf/rule-groups/{id}: + get: + parameters: + - description: 规则 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 规则详情 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/waf.RuleView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取 WAF 规则详情 + tags: + - openflare-waf + /api/v1/d/waf/rule-groups/{id}/delete: + post: + parameters: + - description: 规则 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + $ref: '#/definitions/response.Any' + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除 WAF 规则 + tags: + - openflare-waf + /api/v1/d/waf/rule-groups/{id}/graph: + post: + consumes: + - application/json + parameters: + - description: 规则 ID + in: path + name: id + required: true + type: integer + - description: 规则图和修订号 + in: body + name: request + required: true + schema: + $ref: '#/definitions/waf.SaveRuleGraphInput' + produces: + - application/json + responses: + "200": + description: 保存成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/waf.RuleView' + type: object + "400": + description: 参数或规则图错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "409": + description: 修订冲突 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 保存 WAF 规则图 + tags: + - openflare-waf + /api/v1/d/waf/rule-groups/{id}/meta: + post: + consumes: + - application/json + parameters: + - description: 规则 ID + in: path + name: id + required: true + type: integer + - description: 规则元数据 + in: body + name: request + required: true + schema: + $ref: '#/definitions/waf.UpdateRuleMetaInput' + produces: + - application/json + responses: + "200": + description: 更新成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/waf.RuleView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 无权限或不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新 WAF 规则元数据 + tags: + - openflare-waf + /api/v1/d/waf/sites/{route_id}/rule-groups: + get: + description: 返回代理站点关联的 WAF 规则组绑定,需要管理员权限 + parameters: + - description: 代理路由 ID + in: path + name: route_id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 站点规则组绑定 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/waf.SiteRuleGroupsView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取站点 WAF 规则组 + tags: + - openflare-waf + post: + consumes: + - application/json + description: 替换代理站点关联的 WAF 规则组列表,需要管理员权限 + parameters: + - description: 代理路由 ID + in: path + name: route_id + required: true + type: integer + - description: 规则组 ID 列表 + in: body + name: request + required: true + schema: + $ref: '#/definitions/waf.IDsRequest' + produces: + - application/json + responses: + "200": + description: 更新后的站点规则组绑定 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/waf.SiteRuleGroupsView' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 替换站点 WAF 规则组 + tags: + - openflare-waf + /api/v1/d/zones: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/zone.ListItem' + type: array + type: object + security: + - SessionCookie: [] + summary: 获取 Zone 列表 + tags: + - openflare-zone + post: + consumes: + - application/json + parameters: + - description: Zone 参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/zone.Input' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.Zone' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.Any' + "409": + description: Conflict + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 创建 Zone + tags: + - openflare-zone + /api/v1/d/zones/{id}/delete: + post: + parameters: + - description: Zone ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.Any' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.Any' + "404": + description: Not Found + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除 Zone + tags: + - openflare-zone + /api/v1/d/zones/{id}/domains: + post: + consumes: + - application/json + parameters: + - description: Zone ID + in: path + name: id + required: true + type: integer + - description: 域名参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/zone.DomainInput' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.ZoneDomain' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.Any' + "404": + description: Not Found + schema: + $ref: '#/definitions/response.Any' + "409": + description: Conflict + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 创建 Zone 域名 + tags: + - openflare-zone + /api/v1/d/zones/{id}/domains/{domainId}/delete: + post: + parameters: + - description: Zone ID + in: path + name: id + required: true + type: integer + - description: 域名 ID + in: path + name: domainId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.Any' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.Any' + "404": + description: Not Found + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除 Zone 域名 + tags: + - openflare-zone + /api/v1/d/zones/{id}/domains/{domainId}/update: + post: + consumes: + - application/json + parameters: + - description: Zone ID + in: path + name: id + required: true + type: integer + - description: 域名 ID + in: path + name: domainId + required: true + type: integer + - description: 域名参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/zone.DomainInput' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.ZoneDomain' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.Any' + "404": + description: Not Found + schema: + $ref: '#/definitions/response.Any' + "409": + description: Conflict + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新 Zone 域名 + tags: + - openflare-zone + /api/v1/d/zones/{id}/overview: + get: + parameters: + - description: Zone ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/zone.Overview' + type: object + "404": + description: Not Found + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取 Zone 概览 + tags: + - openflare-zone + /api/v1/d/zones/{id}/stats: + get: + description: 按 Zone 下全部域名聚合访问日志:唯一访问者、请求总数、已提供数据(字节)。range 支持 24h/7d/30d。 + parameters: + - description: Zone ID + in: path + name: id + required: true + type: integer + - description: 时间范围:24h(默认)、7d、30d + in: query + name: range + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/zone.Stats' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.Any' + "404": + description: Not Found + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取 Zone 流量统计 + tags: + - openflare-zone + /api/v1/d/zones/{id}/update: + post: + consumes: + - application/json + parameters: + - description: Zone ID + in: path + name: id + required: true + type: integer + - description: Zone 参数 + in: body + name: body + required: true + schema: + $ref: '#/definitions/zone.Input' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.Zone' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.Any' + "404": + description: Not Found + schema: + $ref: '#/definitions/response.Any' + "409": + description: Conflict + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新 Zone + tags: + - openflare-zone + /api/v1/oauth/{source}/authorize: + get: + description: 根据指定认证源名称发起 OAuth 授权,支持 purpose 参数用于区分登录和账号绑定场景。认证源必须已启用。 + parameters: + - description: 认证源名称 + in: path + name: source + required: true + type: string + - description: 授权目的:login(登录)或 bind(绑定账号),默认 login + in: query + name: purpose + type: string + produces: + - application/json + responses: + "200": + description: 授权 URL + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/oauth.OAuthAuthorizeResponse' + type: object + "400": + description: 认证源不存在或未启用 + schema: + $ref: '#/definitions/response.Any' + "500": + description: Redis 异常或构造 URL 失败 + schema: + $ref: '#/definitions/response.Any' + summary: 发起指定认证源授权 + tags: + - oauth + /api/v1/oauth/callback: + post: + consumes: + - application/json + description: 接收前端传回的 state 和 code,完成 OAuth/OIDC 认证并建立会话。支持登录(login)和账号绑定(bind)两种场景。 + parameters: + - description: 回调请求参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/oauth.CallbackRequest' + produces: + - application/json + responses: + "200": + description: 登录或绑定成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/oauth.OAuthCallbackResult' + type: object + "400": + description: state 无效、参数错误或认证源错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 绑定场景未登录 + schema: + $ref: '#/definitions/response.Any' + "500": + description: OAuth 认证失败或内部错误 + schema: + $ref: '#/definitions/response.Any' + summary: OAuth 回调处理 + tags: + - oauth + /api/v1/oauth/external-accounts: + get: + description: 返回当前登录用户已绑定的所有外部 OAuth 帐号信息,需要登录 + produces: + - application/json + responses: + "200": + description: 外部帐号列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/model.ExternalAccountView' + type: array + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取外部帐号列表 + tags: + - oauth + /api/v1/oauth/external-accounts/{id}/delete: + post: + description: 解除当前登录用户与指定外部帐号的绑定关系,需要登录 + parameters: + - description: 外部帐号绑定记录 ID + format: int64 + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 解除绑定成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: ID 无效或解除失败 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 解除外部帐号绑定 + tags: + - oauth + /api/v1/oauth/login: + get: + description: 根据指定认证源生成 OAuth 授权 URL,前端跳转到该 URL 完成 OAuth 登录授权。source 参数为空时使用第一个启用的认证源。 + parameters: + - description: 认证源名称,为空使用第一个启用的认证源 + in: query + name: source + type: string + produces: + - application/json + responses: + "200": + description: 授权 URL + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/oauth.OAuthAuthorizeResponse' + type: object + "400": + description: 认证源不存在或未配置 + schema: + $ref: '#/definitions/response.Any' + "500": + description: Redis 异常 or 构造 URL 失败 + schema: + $ref: '#/definitions/response.Any' + summary: 获取登录授权地址 + tags: + - oauth + /api/v1/oauth/logout: + get: + description: 清除当前用户的登录会话,完成退出。清除 Cookie 中的 Session 数据。 + produces: + - application/json + responses: + "200": + description: 退出成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "500": + description: Session 清除失败 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 退出登录 + tags: + - oauth + /api/v1/oauth/sources: + get: + description: 返回当前系统已启用的所有 OAuth 登录源,前端展示登录按钮列表时调用 + produces: + - application/json + responses: + "200": + description: 登录源列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/oauth.AuthSourceView' + type: array + type: object + summary: 获取可用登录源 + tags: + - oauth + /api/v1/oauth/user-info: + get: + description: 返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。 + produces: + - application/json + responses: + "200": + description: 用户信息 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/oauth.BasicUserInfo' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取当前登录用户信息 + tags: + - oauth + /api/v1/relay/heartbeat: + post: + consumes: + - application/json + description: Relay 节点定期上报运行状态与 frps 观测数据,返回运行时配置 + parameters: + - description: 心跳载荷 + in: body + name: body + required: true + schema: + $ref: '#/definitions/relay.HeartbeatPayload' + produces: + - application/json + responses: + "200": + description: 心跳响应 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/relay.HeartbeatResponse' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: Agent Token 无效 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 节点类型不匹配 + schema: + $ref: '#/definitions/response.Any' + security: + - AgentTokenAuth: [] + summary: 上报 Relay 心跳 + tags: + - openflare-relay + /api/v1/relay/ws: + get: + description: 将已认证的 Relay 连接升级为 WebSocket 长连接,用于配置推送 + responses: + "401": + description: Agent Token 无效 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 节点类型不匹配 + schema: + $ref: '#/definitions/response.Any' + security: + - AgentTokenAuth: [] + summary: 升级 Relay WebSocket 连接 + tags: + - openflare-relay + /api/v1/tunnel/apply-log: + post: + consumes: + - application/json + description: Tunnel 客户端上报配置应用结果,服务端记录下发日志 + parameters: + - description: 下发结果载荷 + in: body + name: body + required: true + schema: + $ref: '#/definitions/flared.ApplyLogPayload' + produces: + - application/json + responses: + "200": + description: 下发日志记录 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.OpenFlareApplyLog' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: Tunnel Token 无效 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 节点类型不匹配 + schema: + $ref: '#/definitions/response.Any' + security: + - TunnelTokenAuth: [] + summary: 上报 Tunnel 配置下发结果 + tags: + - openflare-tunnel + /api/v1/tunnel/config/active: + get: + description: 返回 Tunnel 客户端当前应应用的完整路由配置(含中继列表与代理定义) + produces: + - application/json + responses: + "200": + description: 隧道配置 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/flared.TunnelConfigResponse' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: Tunnel Token 无效 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 节点类型不匹配 + schema: + $ref: '#/definitions/response.Any' + security: + - TunnelTokenAuth: [] + summary: 获取活跃隧道配置 + tags: + - openflare-tunnel + /api/v1/tunnel/heartbeat: + post: + consumes: + - application/json + description: Tunnel 客户端定期上报运行状态与中继连接信息,返回活跃配置元数据与隧道设置 + parameters: + - description: 心跳载荷 + in: body + name: body + required: true + schema: + $ref: '#/definitions/flared.HeartbeatPayload' + produces: + - application/json + responses: + "200": + description: 心跳响应 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/flared.HeartbeatResponse' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: Tunnel Token 无效 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 节点类型不匹配 + schema: + $ref: '#/definitions/response.Any' + security: + - TunnelTokenAuth: [] + summary: 上报 Tunnel 心跳 + tags: + - openflare-tunnel + /api/v1/tunnel/ws: + get: + description: 将已认证的 Tunnel 客户端连接升级为 WebSocket 长连接,用于配置推送 + responses: + "401": + description: Tunnel Token 无效 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 节点类型不匹配 + schema: + $ref: '#/definitions/response.Any' + security: + - TunnelTokenAuth: [] + summary: 升级 Tunnel WebSocket 连接 + tags: + - openflare-tunnel + /api/v1/upload: + post: + consumes: + - multipart/form-data + description: 支持各种类型的通用文件上传,支持自动文件类型检测、哈希计算与“秒传”去重 + parameters: + - description: 要上传的文件 + in: formData + name: file + required: true + type: file + - description: '业务分类 (例如: avatar, attachment, doc,默认为 generic)' + in: formData + name: type + type: string + - description: 额外的 JSON 格式元数据 + in: formData + name: metadata + type: string + produces: + - application/json + responses: + "200": + description: 上传成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.Upload' + type: object + "400": + description: 请求参数错误或文件受限 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "409": + description: 系统保留类型或存储只读 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 上传文件 + tags: + - upload + /api/v1/upload/{id}: + delete: + description: 将当前用户本人的文件状态置为 deleted(软删除) + parameters: + - description: 文件 ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无权操作 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 文件不存在 + schema: + $ref: '#/definitions/response.Any' + "409": + description: 系统保留类型或存储只读 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除我的文件 + tags: + - upload + put: + consumes: + - application/json + description: 更新当前用户本人的文件名或访问权限模式 (AccessMode) + parameters: + - description: 文件 ID + in: path + name: id + required: true + type: string + - description: 更新字段 + in: body + name: request + required: true + schema: + $ref: '#/definitions/handler.updateMyFileRequest' + produces: + - application/json + responses: + "200": + description: 更新成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.Upload' + type: object + "403": + description: 无权操作 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 文件不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 更新我的文件信息 + tags: + - upload + /api/v1/upload/my: + get: + description: 分页获取当前登录用户上传的文件,支持文件名关键词、业务类型、扩展名过滤 + parameters: + - description: 页码(默认 1) + in: query + name: page + type: integer + - description: 每页数量(默认 20,最大 100) + in: query + name: page_size + type: integer + - description: 文件名关键词(模糊匹配) + in: query + name: keyword + type: string + - description: 业务分类过滤 + in: query + name: type + type: string + - description: 扩展名过滤 + in: query + name: extension + type: string + produces: + - application/json + responses: + "200": + description: 查询成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/handler.listMyFilesResponse' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取我的文件列表 + tags: + - upload + /api/v1/user-info: + get: + description: 返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。 + produces: + - application/json + responses: + "200": + description: 用户信息 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/oauth.BasicUserInfo' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取当前登录用户信息 + tags: + - oauth + /api/v1/user/access-tokens: + get: + description: 返回当前登录用户的所有 active access tokens(脱敏后) + produces: + - application/json + responses: + "200": + description: 令牌列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/model.AccessToken' + type: array + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取当前用户的 AccessToken 列表 + tags: + - user + post: + consumes: + - application/json + description: 为当前用户新建一个 API 访问令牌,仅在此接口返回一次明文令牌值,请妥善保存。可通过 is_admin 字段赋予令牌管理员权限(仅管理员用户可设置)。 + parameters: + - description: 令牌名称 + in: body + name: request + required: true + schema: + $ref: '#/definitions/user.createTokenRequest' + produces: + - application/json + responses: + "200": + description: 新建令牌成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/user.tokenResponse' + type: object + "400": + description: 参数错误或超限 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 创建一个新的 AccessToken + tags: + - user + /api/v1/user/access-tokens/{id}: + delete: + description: 撤销并删除一个属于当前用户的 API 访问令牌 + parameters: + - description: 令牌ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除一个 AccessToken + tags: + - user + /api/v1/user/access-tokens/{id}/rotate: + post: + description: 轮换(重新生成)一个属于当前用户的 API 访问令牌的密钥,旧令牌将立即失效 + parameters: + - description: 令牌ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: 令牌轮换成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/user.tokenResponse' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 轮换一个 AccessToken + tags: + - user + /api/v1/user/change-password: + post: + consumes: + - application/json + description: 修改当前登录用户的密码。修改成功后,如果是首次明文登录的升级提示,则清除修改密码的提示状态。 + parameters: + - description: 修改密码请求参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/user.changePasswordRequest' + produces: + - application/json + responses: + "200": + description: 修改密码成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: 原密码错误或新密码不符合要求 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 请先登录 + schema: + $ref: '#/definitions/response.Any' + summary: 修改用户密码 + tags: + - user + /api/v1/user/login: + post: + consumes: + - application/json + description: 使用用户名和密码登录,登录成功后建立 Session。若管理员已关闭密码登录功能则返回错误。 + parameters: + - description: 登录请求参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/user.loginRequest' + produces: + - application/json + responses: + "200": + description: 登录成功,返回用户信息 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/oauth.BasicUserInfo' + type: object + "400": + description: 用户名或密码错误 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 服务内部错误 + schema: + $ref: '#/definitions/response.Any' + summary: 用户密码登录 + tags: + - user + /api/v1/user/logout: + get: + description: 清除用户登录 Session,完成退出 + produces: + - application/json + responses: + "200": + description: 退出成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "500": + description: Session 清除失败 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 用户退出登录 + tags: + - user + /api/v1/user/profile: + put: + consumes: + - application/json + description: 修改当前登录用户的昵称、邮箱、头像、简介、电话、性别、个人网站和所在地。 + parameters: + - description: 更新请求参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/user.updateProfileRequest' + produces: + - application/json + responses: + "200": + description: 修改成功,返回更新后的用户信息 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/oauth.BasicUserInfo' + type: object + "400": + description: 邮箱已被占用或参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + summary: 修改当前登录用户的个人资料 + tags: + - user + /api/v1/user/register: + post: + consumes: + - application/json + description: 使用用户名和密码注册新账号,注册成功后自动登录并建立 Session。密码长度不能少于 8 位。 + parameters: + - description: 注册请求参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/user.registerRequest' + produces: + - application/json + responses: + "200": + description: 注册并登录成功,返回用户信息 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/oauth.BasicUserInfo' + type: object + "400": + description: 参数错误、用户名已存在或注册已关闭 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 服务内部错误 + schema: + $ref: '#/definitions/response.Any' + summary: 用户注册 + tags: + - user + /api/v1/user/self: + get: + description: 返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。 + produces: + - application/json + responses: + "200": + description: 用户信息 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/oauth.BasicUserInfo' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取当前登录用户信息 + tags: + - oauth + /api/v1/user/send-email-code: + post: + consumes: + - application/json + description: 向指定邮箱发送验证码(用于注册场景) + parameters: + - description: 发送验证码请求参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/user.sendEmailCodeRequest' + produces: + - application/json + responses: + "200": + description: 发送成功 + schema: + $ref: '#/definitions/response.Any' + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + summary: 发送邮箱验证码 + tags: + - user + /f/{id}: + get: + description: 根据文件 ID 获取并提供已上传的临时或正式文件,若配置了缓存则优先走本地缓存,否则从 S3 等后端存储读取并流式返回 + parameters: + - description: 文件 ID + in: path + name: id + required: true + type: string + - description: 图片质量 (low, medium, high, origin),默认为 origin + in: query + name: quality + type: string + produces: + - application/octet-stream + responses: + "200": + description: 成功获取文件内容 + schema: + type: file + "400": + description: 文件 ID 格式错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 文件未找到 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 服务内部错误 + schema: + $ref: '#/definitions/response.Any' + summary: 获取已上传文件 + tags: + - upload + /robots.txt: + get: + description: 根据系统配置决定是否允许搜索引擎检索,并返回相应的 robots.txt 文件内容 + produces: + - text/plain + responses: + "200": + description: robots.txt 内容 + schema: + type: string + summary: 获取 robots.txt + tags: + - config +securityDefinitions: + SessionCookie: + in: cookie + name: session + type: apiKey +swagger: "2.0" diff --git a/go.mod b/backend/go.mod similarity index 96% rename from go.mod rename to backend/go.mod index c88617f3..d4d04a12 100644 --- a/go.mod +++ b/backend/go.mod @@ -1,4 +1,4 @@ -module github.com/Rain-kl/Wavelet +module Wavelet go 1.25.7 @@ -23,6 +23,7 @@ require ( github.com/go-jose/go-jose/v4 v4.1.4 github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 + github.com/gorilla/sessions v1.4.0 github.com/gorilla/websocket v1.5.3 github.com/hibiken/asynq v0.26.0 github.com/maypok86/otter/v2 v2.3.0 @@ -40,6 +41,7 @@ require ( github.com/swaggo/files v1.0.1 github.com/swaggo/gin-swagger v1.6.1 github.com/swaggo/swag v1.16.6 + github.com/tencent-connect/botgo v0.2.1 github.com/ulikunitz/xz v0.5.16 github.com/uptrace/opentelemetry-go-extra/otelzap v0.3.2 github.com/yuin/gopher-lua v1.1.2 @@ -57,6 +59,8 @@ require ( golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.22.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 + gopkg.in/telebot.v4 v4.0.0-beta.10 + gorm.io/driver/clickhouse v0.7.0 gorm.io/driver/postgres v1.6.2 gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.2 @@ -116,6 +120,7 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.3 // indirect + github.com/go-resty/resty/v2 v2.17.2 // indirect github.com/go-sql-driver/mysql v1.10.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/goccy/go-json v0.10.6 // indirect @@ -124,7 +129,6 @@ require ( github.com/google/btree v1.1.3 // indirect github.com/gorilla/context 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.30.0 // indirect github.com/hashicorp/go-version v1.9.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect @@ -162,6 +166,9 @@ require ( github.com/spf13/pflag v1.0.10 // indirect github.com/stangelandcl/ppmd v0.1.1 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/tidwall/gjson v1.9.3 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.2 // indirect github.com/uptrace/opentelemetry-go-extra/otelutil v0.3.2 // indirect @@ -185,7 +192,6 @@ require ( google.golang.org/grpc v1.83.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - gorm.io/driver/clickhouse v0.7.0 // indirect gorm.io/driver/mysql v1.6.0 // indirect modernc.org/libc v1.74.4 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 00000000..574eb7ec --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,1371 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= +cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= +cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= +cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= +cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= +cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= +cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= +cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= +cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= +cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= +cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY= +cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg= +cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8= +cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0= +cloud.google.com/go v0.83.0/go.mod h1:Z7MJUsANfY0pYPdw0lbnivPx4/vhy/e2FEkSkF7vAVY= +cloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSUM= +cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY= +cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ= +cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI= +cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4= +cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc= +cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA= +cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= +cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= +cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= +cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= +cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= +cloud.google.com/go/compute v0.1.0/go.mod h1:GAesmwr110a34z04OlxYkATPBEfVhkymfTBXtfbBFow= +cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJWM7YD99wM= +cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M= +cloud.google.com/go/compute v1.6.0/go.mod h1:T29tfhtVbq1wvAPo0E3+7vhgmkOYeXjhFvz/FMzPu0s= +cloud.google.com/go/compute v1.6.1/go.mod h1:g85FgpzFvNULZ+S8AYq87axRKuf2Kh7deLqV/jJ3thU= +cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= +cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= +cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= +cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= +cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= +cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= +cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/ClickHouse/ch-go v0.74.0 h1:uYs2m4wIt0ZHSM1E72rg0maCfzhR2V3xWb/vZEgpeWE= +github.com/ClickHouse/ch-go v0.74.0/go.mod h1:sZ/r+8ttZMjyrP9PuFbgoVbth1ywIu2LIQNA2vgko6M= +github.com/ClickHouse/clickhouse-go/v2 v2.48.0 h1:auzd4VkapQYhQF8F2Gog7s3x78Bi1JZmByxGbrw3C+4= +github.com/ClickHouse/clickhouse-go/v2 v2.48.0/go.mod h1:lBjUCPRG6RpRQdMbkXq+JV8rY0/O5lw+Z7jShgReFjM= +github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= +github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw= +github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= +github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.3 h1:mFFeD2k8QE+pYd1uFik2XG85+vilL5uj9P5mHQTM1Xk= +github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.3/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M= +github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= +github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= +github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= +github.com/armon/go-metrics v0.3.10/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/aws/aws-sdk-go-v2 v1.43.4 h1:b9FTvbRwy+JCsfp2Wp6wV/KbOx3Aj7nkoFb2cRX0IhE= +github.com/aws/aws-sdk-go-v2 v1.43.4/go.mod h1:70vwSy16txshwG+g55WkpgPKDIByzHI8ccBsOteo3bQ= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.16 h1:aiuaKlDweRC5qExJondpWjOgyzMHpofpwspGXUtwn4c= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.16/go.mod h1:nG/LOlmox9BDe9HvQnXWzgcK8uKbgBMZ/Hp5pVt/21I= +github.com/aws/aws-sdk-go-v2/config v1.32.35 h1:UEzXuET8E42lxBPijuACu/tEK7v5lFPlk0Q+GT5WD9E= +github.com/aws/aws-sdk-go-v2/config v1.32.35/go.mod h1:KaMtJpFa2JlL2BStjjHQVwQpzZEmw+ND/EgVrfFoo2g= +github.com/aws/aws-sdk-go-v2/credentials v1.19.34 h1:y6GkSmcv5myd1ngrYbGmiLlwQqB6TQhOuN/tbSSuWDY= +github.com/aws/aws-sdk-go-v2/credentials v1.19.34/go.mod h1:w3dTcnDVoQIewjo7JG45hduAToikiIFLC4FIO7fndvw= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.35 h1:+S7kbJoLDDQ5tE+lHrUBgMkzC8NLgsaioS2F3dVoFAE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.35/go.mod h1:Ak7xXviIARfFdNUJ9Etb0bdVDt/KAvKjMGJVLWXDzik= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35 h1:kzVuGlatQtYinwBJEEyLAbggepCoavosiaHHX9+fD+c= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35/go.mod h1:0yLx0yEI+SfqeJMPvOtIEFoZbiQYXMGszBueiutQyaI= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35 h1:WK6CjihTuLisCjSKKbildJ79sGZZgbBz3iNa7VsKIhU= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35/go.mod h1:KYleN57luLoe97R7vTnx8PMcVrr9gAcRECtOjl91DNg= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36 h1:jbGY4CXLzZElOXgGsexlC3Hi+3YM0rSmk4opFXKqg/k= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36/go.mod h1:uBu/9aKsS/UQGc72RAt3y54kjgYQxmhut8ZD2dXCDNE= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15 h1:JJLBQxwY+AFwuPAi5ivGc1ChnTdUt4cXMv7e76m2c/Y= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15/go.mod h1:lQknBIe78MVL0cQOQDlag8KGflMbMEVFx9mB6O8ENvk= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.28 h1:Q1TF1J9jVD+vFo0LzNnmNdQ9EAt52TS+MQlq9Ir+Yxo= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.28/go.mod h1:4KqXXC/p1hrotmouDFbrRoWaLy962b9PMUReCG6+uWo= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35 h1:BBEElKh4a+rKshvjrfpajTe9CbpZvrbb4Jkg2PB7RzA= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35/go.mod h1:zaZk983w//8beSruBVec/mr4CmDwgZitW/qzGhAAX0g= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.36 h1:EUIwBoN+q7UmhAejxgD27APiRjh1vwCFo53gSqdT0BM= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.36/go.mod h1:6u00gmlTGR6W0b2k9NBrld7MnOEmf1Spqx0VVt6AqyE= +github.com/aws/aws-sdk-go-v2/service/s3 v1.106.5 h1:HpN6GgZ3T8pSvRp81ZsgumNjlvRsa+9M0ZL2o6W4uLY= +github.com/aws/aws-sdk-go-v2/service/s3 v1.106.5/go.mod h1:5FTZoQxhmLEiCAtYVk6V+t0iS/B5yGZVLZ3Wq5FDJZI= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.4 h1:cOJELVNrq5Q3Udry2GLuHUM7MhwpeaQRdYaoa6GI/yI= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.4/go.mod h1:f4LxzKBtaTxD7xh3PiVg3CE1tchQemfmghaJr+NbK2c= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.4 h1:AMW7a7S8iQaHjBYZdU3PCq4GKRPijTPRAc7e6XtEThY= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.4/go.mod h1:QQNsFV1DVXoXcZt18FS8lI8rtUrlDyAuWZLQ5shunv4= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4 h1:AsbZcJAQPRmHDJG8K1N0pof/1zPWjVT8TFlTWuGLSvo= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4/go.mod h1:6imqztH0//t0mKbl6yWl7swSEl7F/w32oAmqB3vP1ag= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.4 h1:w/AryDYMjSUANSQ2uoZxJovUsMTwWJNTv3IMex30Y+4= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.4/go.mod h1:WeBiAa67azG7Su9Vf+ChGDBLiAozJCXzdjXiPBUwtbc= +github.com/aws/smithy-go v1.27.6 h1:0zjT8jgK3jbrTT7JJ3EE6JsMhX8JTrZ+f1sEndYDXrA= +github.com/aws/smithy-go v1.27.6/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU= +github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs= +github.com/bodgit/sevenzip v1.6.5 h1:7H7BxgmeX0j6UX42lH+KXQ92WgMQJ49DoocFdfHbCng= +github.com/bodgit/sevenzip v1.6.5/go.mod h1:GhuB6Lq1xCpP1sps+horjZ8lgiKPJcy2zUX3prla9wc= +github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4= +github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM= +github.com/boj/redistore v1.4.2 h1:44FVJnBTdzDV9VpaByCOaQs0ND8hzABD2xBHcAIbX9s= +github.com/boj/redistore v1.4.2/go.mod h1:jjh65GXAH+5lj29pPRnQHdRNOt/lmP0LOaK+fiG2Fu8= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0= +github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE= +github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= +github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= +github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo= +github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= +github.com/bytedance/sonic/loader v0.5.2 h1:0QtP1gevc1OZ6/H8Lb9BRZiCXd1Ftjd3OKuj1T1lBIo= +github.com/bytedance/sonic/loader v0.5.2/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= +github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI= +github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= +github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE= +github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= +github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/deepteams/webp v1.2.7 h1:Oj3iXbZ0U4siY1KHFTF5T21uysZkmor8pT4E38mNK2o= +github.com/deepteams/webp v1.2.7/go.mod h1:J8Ap+HAixxpKKRN9IpEeSKlfvhsef1v43jKTO7m3f4c= +github.com/dgraph-io/ristretto/v2 v2.4.2 h1:x0cvjmUKxt764Yxdk2nr94we1AvPPAMh1rh5TQ+Jo80= +github.com/dgraph-io/ristretto/v2 v2.4.2/go.mod h1:0KsrXtXvnv0EqnzyowllbVJB8yBonswa2lTCK2gGo9E= +github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38= +github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= +github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= +github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= +github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM= +github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= +github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= +github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI= +github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= +github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= +github.com/gin-contrib/sessions v1.1.0 h1:00mhHfNEGF5sP2fwxa98aRqj1FOJdL6IkR86n2hOiBo= +github.com/gin-contrib/sessions v1.1.0/go.mod h1:TyYZDIs6qCQg2SOoYPgMT9pAkmZceVNEJMcv5qbIy60= +github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko= +github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/glebarez/go-sqlite v1.23.0 h1:FyhIq4jqmgphQAUlY79zPldYGwISEZikaDfhiGWkkaI= +github.com/glebarez/go-sqlite v1.23.0/go.mod h1:IIYrOH3L0rHY3jb4IXOHoWdklNajSGUN2eJcvK8WrnI= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/go-acme/lego/v4 v4.35.2 h1:uVQg+KC/yj9R2g7Q9W5wDqhvQvxV5SMu5eqFVoN5xZU= +github.com/go-acme/lego/v4 v4.35.2/go.mod h1:pX2jN5n8OphMGY1IaMjYm5DAEzguBaKRt8AvJAgJXpc= +github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= +github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= +github.com/go-faster/errors v0.8.0 h1:9T9eJrM+72dFk7n4DfhuaDDe6cyuFCSW2oNUkN77Yqc= +github.com/go-faster/errors v0.8.0/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s= +github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y= +github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY= +github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0= +github.com/go-openapi/spec v0.22.9 h1:/vKIFDcGKp0ktZWGbym/tJEWbk6/XOEmAVU0kqKMH+w= +github.com/go-openapi/spec v0.22.9/go.mod h1:b/mNUYIOQOyIiUzUzXEE8xzyZqf93KvM9hQGP91yfl0= +github.com/go-openapi/swag v0.28.0 h1:xkgbOSKj6DZziNpyqRRAOt3GJGtgjgsd2RoyT30VWuw= +github.com/go-openapi/swag/conv v0.28.0 h1:GtqqbyFe7vR5Y7ehxG9W6/OvrSFdf1OLeTGp40TqxH8= +github.com/go-openapi/swag/conv v0.28.0/go.mod h1:mbUE+mzctnhxi864m0Q07SpN8OowD9JhxmxuYvZZD/k= +github.com/go-openapi/swag/jsonutils v0.28.0 h1:YIch6FwO7RXzeAnbO8Tu7dWBZeUEH+4nA0HXltVTnv4= +github.com/go-openapi/swag/jsonutils v0.28.0/go.mod h1:CYM3WlTUcagR2ZoHdz54di/cbBqt82tuxuXgAjxw+mg= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0 h1:qV+VVUAx5Oro8WjVWpZeql7YReTKhT4smR4zhcOQZr0= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY= +github.com/go-openapi/swag/loading v0.28.0 h1:td8QZdZC9MIYGGSnSPKShKiK22I2tU5UQvuUhIBPRLU= +github.com/go-openapi/swag/loading v0.28.0/go.mod h1:rXB0QiQX5mMveXEA7ouM4KiiM9jVJe4K6BVbwhD1M4k= +github.com/go-openapi/swag/pools v0.28.0 h1:HPMZWSAfce3rdVTFcjFiCIBtDg9h4x2QlRrHipwhxeU= +github.com/go-openapi/swag/pools v0.28.0/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE= +github.com/go-openapi/swag/stringutils v0.28.0 h1:ixsc9iYgDPubHL/8nSkbnryEHpD2VRlBMLKpQyPXcDU= +github.com/go-openapi/swag/stringutils v0.28.0/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM= +github.com/go-openapi/swag/typeutils v0.28.0 h1:nRBKSBXjDgf01VDPB3fWeD9nQuhCOVeIYAkUx2tbkyY= +github.com/go-openapi/swag/typeutils v0.28.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= +github.com/go-openapi/swag/yamlutils v0.28.0 h1:TV3JXH6DS46KUroDtMLAYHGkdWf5VDq3wVWFirmzROY= +github.com/go-openapi/swag/yamlutils v0.28.0/go.mod h1:x0q/yndZHEgk9Rx3DyDqzFUmHy55KTvIZldvF2dTJXs= +github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0= +github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo= +github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= +github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= +github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8= +github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= +github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w= +github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q= +github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk= +github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= +github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= +github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.9.5/go.mod h1:U/jl18uSupI5rdI2jmuCswEA2htH9eXfferR3KfscvA= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/gomodule/redigo v1.9.3 h1:dNPSXeXv6HCq2jdyWfjgmhBdqnR6PRO3m/G05nvpPC8= +github.com/gomodule/redigo v1.9.3/go.mod h1:KsU3hiK/Ay8U42qpaJk+kuNa3C+spxapWpM+ywhcgtw= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0= +github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM= +github.com/googleapis/gax-go/v2 v2.2.0/go.mod h1:as02EH8zWkzwUoLbBaFeQ+arQaj/OthfcblKl4IGNaM= +github.com/googleapis/gax-go/v2 v2.3.0/go.mod h1:b8LNqSzNabLiUpXKkY7HAR5jr6bIT99EXz9pXxye9YM= +github.com/googleapis/gax-go/v2 v2.4.0/go.mod h1:XOTVJ59hdnfJLIP/dh8n5CGryZR2LxK9wbMD5+iXC6c= +github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= +github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= +github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= +github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY= +github.com/hashicorp/consul/api v1.12.0/go.mod h1:6pVBMo0ebnYdt2S3H87XhekM/HHrUoTD2XXb/VrZVy0= +github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= +github.com/hashicorp/go-hclog v1.2.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= +github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= +github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= +github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= +github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= +github.com/hashicorp/mdns v1.0.4/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc= +github.com/hashicorp/memberlist v0.3.0/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= +github.com/hashicorp/serf v0.9.6/go.mod h1:TXZNMjZQijwlDvp+r0b63xZ45H7JmCmgg4gpTwn9UV4= +github.com/hashicorp/serf v0.9.7/go.mod h1:TXZNMjZQijwlDvp+r0b63xZ45H7JmCmgg4gpTwn9UV4= +github.com/hibiken/asynq v0.26.0 h1:1Zxr92MlDnb1Zt/QR5g2vSCqUS03i95lUfqx5X7/wrw= +github.com/hibiken/asynq v0.26.0/go.mod h1:Qk4e57bTnWDoyJ67VkchuV6VzSM9IQW2nPvAGuDyw58= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 h1:9Nu54bhS/H/Kgo2/7xNSUuC5G28VR8ljfrLKU2G4IjU= +github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12/go.mod h1:TBzl5BIHNXfS9+C35ZyJaklL7mLDbgUkcgXzSLa8Tk0= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= +github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= +github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0= +github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE= +github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= +github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/maypok86/otter/v2 v2.3.0 h1:8H8AVVFUSzJwIegKwv1uF5aGitTY+AIrtktg7OcLs8w= +github.com/maypok86/otter/v2 v2.3.0/go.mod h1:XgIdlpmL6jYz882/CAx1E4C1ukfgDKSaw4mWq59+7l8= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= +github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= +github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= +github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE= +github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8= +github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/paulmach/orb v0.13.0 h1:r7n7mQGGF+cj/CbcivEj9J3HGK+XR+yXnvzRdq9saIw= +github.com/paulmach/orb v0.13.0/go.mod h1:6scRWINywA2Jf05dcjOfLfxrUIMECvTSG2MVbRLxu/k= +github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pelletier/go-toml/v2 v2.0.5/go.mod h1:OMHamSCAODeSsVrwwvcJOaoN0LIUIaFVNZzmWyNfXas= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/peterbourgon/diskv/v3 v3.0.1 h1:x06SQA46+PKIUftmEujdwSEpIx8kR+M9eLYsUxeYveU= +github.com/peterbourgon/diskv/v3 v3.0.1/go.mod h1:kJ5Ny7vLdARGU3WUuy6uzO6T0nb/2gWcT1JiBvRmb5o= +github.com/pierrec/lz4/v4 v4.1.28 h1:pPEPwRJ4kybBTfGt28q7lQsRJQHhC08axprdLD5Ppio= +github.com/pierrec/lz4/v4 v4.1.28/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= +github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= +github.com/pressly/goose/v3 v3.27.3 h1:pIglVHjw99r4e/hDHHwbl9vfOsDMqUokfkXo6+n/RxA= +github.com/pressly/goose/v3 v3.27.3/go.mod h1:Dag+xpV6o20HR2LFY1j0q6MDwc3f7vPUFDA77R+0yGY= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= +github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= +github.com/prometheus/client_golang v1.11.1/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= +github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= +github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= +github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= +github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= +github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.61.0 h1:ui88A53s8MSVYLC56en0KQ17HARk+9986Dn0SBfKNvA= +github.com/quic-go/quic-go v0.61.0/go.mod h1:9So2anK4Tp22URSQq00k+Vo2PNkle96ycDPDHL4s9vs= +github.com/redis/go-redis/extra/rediscmd/v9 v9.22.0 h1:MQPzEEnpD0BMPufBLABnMYLJVwM7xi7vZ+srO8Nr0s8= +github.com/redis/go-redis/extra/rediscmd/v9 v9.22.0/go.mod h1:eve0JFcLRwFVj3RA85rrrV5+UJ+K9LDyU7kf2UdSueM= +github.com/redis/go-redis/extra/redisotel/v9 v9.22.0 h1:t5ul1Gl0o1rYQj5f5bK12G9xcg1niq2ON4yZFjvy1kA= +github.com/redis/go-redis/extra/redisotel/v9 v9.22.0/go.mod h1:hcS9L2RBBjYXkrfSOF26ZGejgo+yOC+28ZD2fkk3sGs= +github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0= +github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/sagikazarmark/crypt v0.6.0/go.mod h1:U8+INwJo3nBv1m6A/8OBXAq7Jnpspk5AxSgDyEQcea8= +github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= +github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/sethvargo/go-retry v0.4.0 h1:9qy1OoIAxBL+gBYnkTnTnWle5wlfsXQlwRzIbbpdqPw= +github.com/sethvargo/go-retry v0.4.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.8.2/go.mod h1:CtAatgMJh6bJEIs48Ay/FOnkljP3WeGUG0MC1RfAqwo= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.13.0/go.mod h1:Icm2xNL3/8uyh/wFuB1jI7TiTNKp8632Nwegu+zgdYw= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stangelandcl/ppmd v0.1.1 h1:c25QazhlWUn5nmR1QOzafKhQxBicAr7GGCKER2aJ8H8= +github.com/stangelandcl/ppmd v0.1.1/go.mod h1:Rrv7M+/2P5jYr/GMLhBl7Ug3uJ1bUiVzr5LbbaV6xgY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/studio-b12/gowebdav v0.13.0 h1:OcwSg6IQHOFNdYHn3bPOHwSE8looG8N56Y5xTT1asqQ= +github.com/studio-b12/gowebdav v0.13.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE= +github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= +github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= +github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY= +github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw= +github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= +github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= +github.com/tencent-connect/botgo v0.2.1 h1:+BrTt9Zh+awL28GWC4g5Na3nQaGRWb0N5IctS8WqBCk= +github.com/tencent-connect/botgo v0.2.1/go.mod h1:oO1sG9ybhXNickvt+CVym5khwQ+uKhTR+IhTqEfOVsI= +github.com/tidwall/gjson v1.9.3 h1:hqzS9wAHMO+KVBBkLxYdkEeeFHuqr95GfClRLKlgK0E= +github.com/tidwall/gjson v1.9.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.2 h1:zkEASHHyEClGeURfgNT9PJZVfAbs9oEX9QXggwWNJbc= +github.com/ugorji/go/codec v1.3.2/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/ulikunitz/xz v0.5.16 h1:ld6NyySjx5lowVKwJvMRLnW5nxKX/xnpSiFYZ/Lxur0= +github.com/ulikunitz/xz v0.5.16/go.mod h1:H9Rt/W6/Qj27PGauhQc6nfCDy7vHpzsOThBSaYDoEhw= +github.com/uptrace/opentelemetry-go-extra/otelutil v0.3.2 h1:3/aHKUq7qaFMWxyQV0W2ryNgg8x8rVeKVA20KJUkfS0= +github.com/uptrace/opentelemetry-go-extra/otelutil v0.3.2/go.mod h1:Zit4b8AQXaXvA68+nzmbyDzqiyFRISyw1JiD5JqUBjw= +github.com/uptrace/opentelemetry-go-extra/otelzap v0.3.2 h1:cj/Z6FKTTYBnstI0Lni9PA+k2foounKIPUmj1LBwNiQ= +github.com/uptrace/opentelemetry-go-extra/otelzap v0.3.2/go.mod h1:LDaXk90gKEC2nC7JH3Lpnhfu+2V7o/TsqomJJmqA39o= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA= +github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.etcd.io/etcd/api/v3 v3.5.4/go.mod h1:5GB2vv4A4AOn3yk7MftYGHkUfGtDHnEraIjym4dYz5A= +go.etcd.io/etcd/client/pkg/v3 v3.5.4/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= +go.etcd.io/etcd/client/v2 v2.305.4/go.mod h1:Ud+VUwIi9/uQHOMA+4ekToJ12lTxlv0zB/+DHwTGEbU= +go.etcd.io/etcd/client/v3 v3.5.4/go.mod h1:ZaRkVgBZC+L+dLCjTcF1hRXpgZXQPOvnA/Ak/gq3kiY= +go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8= +go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= +go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.70.0 h1:R+uYJnPiZLeJhFicamvZhLr0aVOrDIaxBcqgGus9nSU= +go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.70.0/go.mod h1:Zwk515MbVWCK2WOgeYBNIf8CyZGbAgkoJ6VKSGkd6aQ= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04= +go.opentelemetry.io/contrib/propagators/b3 v1.45.0 h1:audI5r8RmWVSORhzA5Y57yGvEA1358PvGk0u0sMOTDA= +go.opentelemetry.io/contrib/propagators/b3 v1.45.0/go.mod h1:SiENIek0FnzLni3/jSCiumyCA2mwP8uGaE1686SOJug= +go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= +go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 h1:QRefszxJmfPdjXUUm3j6iDzY03mTPXMjqErFqQ67vUg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0/go.mod h1:Tiz03lTBVBrm7eWZBOidzEaYaJa8tjwGUGv6d8mlTyk= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0 h1:fG5MCxGz8+2VtrN/WgqSpJFctVz24gpxj8CxkKmc8Ww= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0/go.mod h1:BmAYTn+3ysbRe+IU2msxmf5Rx3g6DHvex+tWI3LdhYI= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0 h1:lsA/S1bxgdbyFGkTj+3meEdJ6ADVU7QoFstV6MXgE68= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0/go.mod h1:L7u+MirGoB1bjeLH66+xDykF4RC8C3RN7lIFpBiewUo= +go.opentelemetry.io/otel/log v0.6.0 h1:nH66tr+dmEgW5y+F9LanGJUBYPrRgP4g2EkmPE3LeK8= +go.opentelemetry.io/otel/log v0.6.0/go.mod h1:KdySypjQHhP069JX0z/t26VHwa8vSwzgaKmXtIB3fJM= +go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= +go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= +go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= +go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= +go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= +go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= +go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= +go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= +go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= +go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= +go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw= +go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0= +golang.org/x/arch v0.29.0 h1:8sSET5wB0+exBm0FGmOtdHMqjlRdV2DRD3/IV6OZgho= +golang.org/x/arch v0.29.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= +golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I= +golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= +golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= +golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= +golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= +golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220513210516-0976fa681c29/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220502124256-b6088ccd6cba/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= +golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= +google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= +google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= +google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= +google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= +google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU= +google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94= +google.golang.org/api v0.47.0/go.mod h1:Wbvgpq1HddcWVtzsVLyfLp8lDg6AA241LmgIL59tHXo= +google.golang.org/api v0.48.0/go.mod h1:71Pr1vy+TAZRPkPs/xlCf5SsU8WjuAWv1Pfjbtukyy4= +google.golang.org/api v0.50.0/go.mod h1:4bNT5pAuq5ji4SRZm+5QIkjny9JAyVD/3gaSihNefaw= +google.golang.org/api v0.51.0/go.mod h1:t4HdrdoNgyN5cbEfm7Lum0lcLDLiise1F8qDKX00sOU= +google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6z3k= +google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= +google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= +google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI= +google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU= +google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I= +google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo= +google.golang.org/api v0.67.0/go.mod h1:ShHKP8E60yPsKNw/w8w+VYaj9H6buA5UqDp8dhbQZ6g= +google.golang.org/api v0.70.0/go.mod h1:Bs4ZM2HGifEvXwd50TtW70ovgJffJYw2oRCOFU/SkfA= +google.golang.org/api v0.71.0/go.mod h1:4PyU6e6JogV1f9eA4voyrTY2batOLdgZ5qZ5HOCc4j8= +google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRRyDs= +google.golang.org/api v0.75.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA= +google.golang.org/api v0.78.0/go.mod h1:1Sg78yoMLOhlQTeF+ARBoytAcH1NNyyl390YMy6rKmw= +google.golang.org/api v0.81.0/go.mod h1:FA6Mb/bZxj706H2j+j2d6mHEEaHBmbbWnkfvmorOCko= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= +google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= +google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= +google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A= +google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= +google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= +google.golang.org/genproto v0.0.0-20210608205507-b6d2f5bf0d7d/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= +google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24= +google.golang.org/genproto v0.0.0-20210713002101-d411969a0d9a/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k= +google.golang.org/genproto v0.0.0-20210716133855-ce7ef5c701ea/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k= +google.golang.org/genproto v0.0.0-20210728212813-7823e685a01f/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48= +google.golang.org/genproto v0.0.0-20210805201207-89edb61ffb67/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48= +google.golang.org/genproto v0.0.0-20210813162853-db860fec028c/go.mod h1:cFeNkxwySK631ADgubI+/XFU/xp8FD5KIVV4rj8UC5w= +google.golang.org/genproto v0.0.0-20210821163610-241b8fcbd6c8/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211008145708-270636b82663/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211028162531-8db9c33dc351/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211221195035-429b39de9b1c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20220207164111-0872dc986b00/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= +google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= +google.golang.org/genproto v0.0.0-20220304144024-325a89244dc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= +google.golang.org/genproto v0.0.0-20220310185008-1973136f34c6/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= +google.golang.org/genproto v0.0.0-20220324131243-acbaeb5b85eb/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E= +google.golang.org/genproto v0.0.0-20220407144326-9054f6ed7bac/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= +google.golang.org/genproto v0.0.0-20220413183235-5e96e2839df9/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= +google.golang.org/genproto v0.0.0-20220414192740-2d67ff6cf2b4/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= +google.golang.org/genproto v0.0.0-20220421151946-72621c1f0bd3/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= +google.golang.org/genproto v0.0.0-20220429170224-98d788798c3e/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= +google.golang.org/genproto v0.0.0-20220505152158-f39f71e6c8f3/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= +google.golang.org/genproto v0.0.0-20220519153652-3a47de7e79bd/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= +google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d h1:FarXi840EJWSHYTN3ERkADbPWjl307+FGrA22KAVjjc= +google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d/go.mod h1:K/+WGbmBY7aNW1HDw1fJnKYo10i0DkAX6pows00dLig= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= +google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= +google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= +google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= +google.golang.org/grpc v1.37.1/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= +google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= +google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= +google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= +google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= +google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= +google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= +google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ= +google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= +google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= +google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ= +google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/telebot.v4 v4.0.0-beta.10 h1:ygPTJJlLHeDiYd1A4E/5kufMRl6b8mft7YQxkFSkj7Q= +gopkg.in/telebot.v4 v4.0.0-beta.10/go.mod h1:jhcQjM/176jZm/s9Up/MzV5VFGPjyI8oiJhWvCMxayI= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/clickhouse v0.7.0 h1:BCrqvgONayvZRgtuA6hdya+eAW5P2QVagV3OlEp1vtA= +gorm.io/driver/clickhouse v0.7.0/go.mod h1:TmNo0wcVTsD4BBObiRnCahUgHJHjBIwuRejHwYt3JRs= +gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= +gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/driver/postgres v1.6.2 h1:BvXQ/cNUg63q5TFNg672DmDcowZSFrNLkkA3Xe6GXq4= +gorm.io/driver/postgres v1.6.2/go.mod h1:0c4fQA44XhOklXDkgtuKqysHCycTa5i9e3EIpDGCwXk= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= +gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc= +gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM= +gorm.io/plugin/opentelemetry v0.1.16 h1:Kypj2YYAliJqkIczDZDde6P6sFMhKSlG5IpngMFQGpc= +gorm.io/plugin/opentelemetry v0.1.16/go.mod h1:P3RmTeZXT+9n0F1ccUqR5uuTvEXDxF8k2UpO7mTIB2Y= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= +modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= +modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0= +modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= +sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= diff --git a/main.go b/backend/main.go similarity index 92% rename from main.go rename to backend/main.go index deddacd5..5a4076db 100644 --- a/main.go +++ b/backend/main.go @@ -4,7 +4,7 @@ // Package main 是 OpenFlare 平台的程序入口 package main -import "github.com/Rain-kl/Wavelet/internal/cmd" +import "Wavelet/cmd" // @title OpenFlare API // @version 1.0.0 diff --git a/internal/infra/persistence/batchwriter/config.go b/backend/pkg/batchwriter/config.go similarity index 83% rename from internal/infra/persistence/batchwriter/config.go rename to backend/pkg/batchwriter/config.go index cf19d708..f2363d16 100644 --- a/internal/infra/persistence/batchwriter/config.go +++ b/backend/pkg/batchwriter/config.go @@ -4,7 +4,7 @@ package batchwriter import ( - "errors" + "fmt" "time" ) @@ -51,19 +51,19 @@ func DefaultConfig() Config { func (c Config) validate() error { if c.QueueSize <= 0 { - return errors.New("batchwriter: queue size must be positive") + return fmt.Errorf("batchwriter: queue size must be positive") } if c.MaxBatchSize <= 0 { - return errors.New("batchwriter: max batch size must be positive") + return fmt.Errorf("batchwriter: max batch size must be positive") } if c.MinBatchSize < 0 { - return errors.New("batchwriter: min batch size must be non-negative") + return fmt.Errorf("batchwriter: min batch size must be non-negative") } if c.FlushInterval <= 0 { - return errors.New("batchwriter: flush interval must be positive") + return fmt.Errorf("batchwriter: flush interval must be positive") } if c.MaxFlushWait < 0 { - return errors.New("batchwriter: max flush wait must be non-negative") + return fmt.Errorf("batchwriter: max flush wait must be non-negative") } return nil } diff --git a/internal/infra/persistence/batchwriter/errs.go b/backend/pkg/batchwriter/errs.go similarity index 100% rename from internal/infra/persistence/batchwriter/errs.go rename to backend/pkg/batchwriter/errs.go diff --git a/internal/infra/persistence/batchwriter/writer.go b/backend/pkg/batchwriter/writer.go similarity index 95% rename from internal/infra/persistence/batchwriter/writer.go rename to backend/pkg/batchwriter/writer.go index 59554498..46aed750 100644 --- a/internal/infra/persistence/batchwriter/writer.go +++ b/backend/pkg/batchwriter/writer.go @@ -7,12 +7,11 @@ package batchwriter import ( + "Wavelet/pkg/util" "context" "sync" "sync/atomic" "time" - - "github.com/Rain-kl/Wavelet/internal/model/analytics" ) // FlushFunc persists a batch of queued items. It is invoked from the worker goroutine. @@ -24,8 +23,14 @@ type FlushFunc[T any] func(ctx context.Context, items []T) error type FlushErrorHandler[T any] func(ctx context.Context, items []T, err error) // Stats is a point-in-time snapshot of Writer queue and failure counters. -// It is an alias of analyticsmodel.BatchWriterStats (moved to keep model pure data). -type Stats = analytics.BatchWriterStats +type Stats struct { + Name string + Depth int + Cap int + Drops int64 + FlushErrors int64 + Running bool +} // Writer buffers items and flushes them by size or interval. type Writer[T any] struct { @@ -92,7 +97,7 @@ func (w *Writer[T]) Start(parent context.Context) { w.ch = make(chan T, w.cfg.QueueSize) w.workerCtx = context.WithoutCancel(parent) - go w.run() + util.Go(w.run) }) } @@ -238,7 +243,7 @@ func (w *Writer[T]) run() { } } -func (w *Writer[T]) shouldFlushOnInterval(batchLen int, batchStartedAt time.Time, now time.Time) bool { +func (w *Writer[T]) shouldFlushOnInterval(batchLen int, batchStartedAt, now time.Time) bool { if batchLen == 0 { return false } diff --git a/backend/pkg/batchwriter/writer_test.go b/backend/pkg/batchwriter/writer_test.go new file mode 100644 index 00000000..8a2d42f0 --- /dev/null +++ b/backend/pkg/batchwriter/writer_test.go @@ -0,0 +1,317 @@ +// 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: 1, + FlushInterval: time.Hour, + } + + entered := make(chan struct{}) + w, err := New(cfg, func(_ context.Context, _ []testEvent) error { + select { + case entered <- struct{}{}: + default: + } + <-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()) + }() + + // 1. 推入 1 个 item 触发 flush 并阻塞在 blockCh + w.ch <- testEvent{ID: 1} + <-entered + + // 2. 此时 worker 阻塞,填满 channel + w.ch <- testEvent{ID: 2} + w.ch <- testEvent{ID: 3} + + assert.False(t, w.TryEnqueue(testEvent{ID: 4})) + 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) +} diff --git a/internal/buildinfo/buildinfo.go b/backend/pkg/buildinfo/buildinfo.go similarity index 100% rename from internal/buildinfo/buildinfo.go rename to backend/pkg/buildinfo/buildinfo.go diff --git a/pkg/cache/disk/cache.go b/backend/pkg/cache/disk/cache.go similarity index 94% rename from pkg/cache/disk/cache.go rename to backend/pkg/cache/disk/cache.go index 33758175..83b32117 100644 --- a/pkg/cache/disk/cache.go +++ b/backend/pkg/cache/disk/cache.go @@ -5,6 +5,7 @@ package disk import ( + "Wavelet/pkg/util" "container/list" "encoding/binary" "errors" @@ -26,7 +27,7 @@ const ( headerSize = 8 // 8 bytes metadata prefix for expiration UnixNano timestamp defaultMaxSizeMB = 100 defaultTTLMinutes = 60 - cacheDirPerm = 0750 + cacheDirPerm = 0o750 // DefaultExpiration applies the cache-wide default TTL. DefaultExpiration time.Duration = 0 @@ -88,6 +89,25 @@ func New(basePath string) *Cache { return c } +var ( + defaultCache *Cache + defaultCacheOnce sync.Once + + // defaultCleanupInterval 全局磁盘缓存默认的过期清理巡检周期 + defaultCleanupInterval = 10 * time.Minute +) + +// Default returns the default global disk cache instance. +func Default() *Cache { + defaultCacheOnce.Do(func() { + defaultCache = New("uploads/diskcache") + util.Go(func() { + defaultCache.StartCleanupWorker(defaultCleanupInterval) + }) + }) + return defaultCache +} + // Set stores a key-value pair in the cache. // Use DefaultExpiration for the configured default TTL, NoExpiration for no // TTL, or a positive duration for a business-specific TTL. @@ -282,7 +302,7 @@ func (c *Cache) Status() Status { } // UpdatePolicy dynamically updates policies. -func (c *Cache) UpdatePolicy(maxSizeMB int64, ttlMinutes int64, lruEnabled bool) { +func (c *Cache) UpdatePolicy(maxSizeMB, ttlMinutes int64, lruEnabled bool) { c.mu.Lock() defer c.mu.Unlock() diff --git a/backend/pkg/cache/disk/cache_corruption_test.go b/backend/pkg/cache/disk/cache_corruption_test.go new file mode 100644 index 00000000..379c005a --- /dev/null +++ b/backend/pkg/cache/disk/cache_corruption_test.go @@ -0,0 +1,63 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package disk + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// 这些用例锁住「LRU 链表节点被污染时不得 panic」的行为:一旦 items 与 evictList +// 的不变量被破坏(例如后续改动误写节点),缓存必须降级为未命中/跳过, +// 而不是在读、写、删除与淘汰路径上崩掉整个进程。 + +// corruptEntry 写入一个键后把其链表节点值换成非法类型,返回缓存。 +func corruptEntry(t *testing.T, key string) *Cache { + t.Helper() + + c := New(t.TempDir()) + require.NoError(t, c.Set(key, []byte("payload"), time.Minute)) + + elem, ok := c.items[key] + require.True(t, ok, "entry must be tracked after Set") + elem.Value = "not-a-cacheItem" + return c +} + +func TestGetToleratesCorruptEvictEntry(t *testing.T) { + c := corruptEntry(t, "k") + + got, err := c.Get("k") + require.ErrorIs(t, err, ErrCacheMiss) + require.Nil(t, got) +} + +func TestSetOverCorruptEvictEntryReportsError(t *testing.T) { + c := corruptEntry(t, "k") + + err := c.Set("k", []byte("second"), time.Minute) + require.Error(t, err, "Set must report the corrupted tracker entry instead of panicking") + require.Contains(t, err.Error(), "invalid type") +} + +func TestDeleteToleratesCorruptEvictEntry(t *testing.T) { + c := corruptEntry(t, "k") + + require.NotPanics(t, func() { _ = c.Delete("k") }) + require.NotContains(t, c.items, "k") +} + +func TestEvictToleratesCorruptEvictEntry(t *testing.T) { + c := corruptEntry(t, "k") + // 让任意写入都触发淘汰扫描:扫到被污染的节点必须跳过而非 panic。 + c.UpdatePolicy(0, 0, true) + + require.NotPanics(t, func() { + for i := range 4 { + _ = c.Set(string(rune('a'+i)), []byte("x"), time.Minute) + } + }) +} diff --git a/pkg/cache/disk/cache_test.go b/backend/pkg/cache/disk/cache_test.go similarity index 79% rename from pkg/cache/disk/cache_test.go rename to backend/pkg/cache/disk/cache_test.go index 107d3c44..03b363d2 100644 --- a/pkg/cache/disk/cache_test.go +++ b/backend/pkg/cache/disk/cache_test.go @@ -5,15 +5,12 @@ package disk import ( "bytes" - "os" "testing" "time" ) func TestDiskCacheBasic(t *testing.T) { - testDir := "uploads/test_diskcache_basic" - defer func() { _ = os.RemoveAll(testDir) }() - _ = os.RemoveAll(testDir) + testDir := t.TempDir() c := New(testDir) defer func() { _ = c.Clear() }() @@ -55,9 +52,7 @@ func TestDiskCacheBasic(t *testing.T) { } func TestDiskCacheTTL(t *testing.T) { - testDir := "uploads/test_diskcache_ttl" - defer func() { _ = os.RemoveAll(testDir) }() - _ = os.RemoveAll(testDir) + testDir := t.TempDir() c := New(testDir) defer func() { _ = c.Clear() }() @@ -91,9 +86,7 @@ func TestDiskCacheTTL(t *testing.T) { } func TestDiskCacheExpirationPolicies(t *testing.T) { - testDir := "uploads/test_diskcache_expiration_policies" - defer func() { _ = os.RemoveAll(testDir) }() - _ = os.RemoveAll(testDir) + testDir := t.TempDir() c := New(testDir) defer func() { _ = c.Clear() }() @@ -102,14 +95,14 @@ func TestDiskCacheExpirationPolicies(t *testing.T) { if err := c.Set("default", []byte("default"), DefaultExpiration); err != nil { t.Fatalf("Set(default, DefaultExpiration) returned error: %v", err) } - if err := c.Set("custom", []byte("custom"), 100*time.Millisecond); err != nil { - t.Fatalf("Set(custom, 100ms) returned error: %v", err) + if err := c.Set("custom", []byte("custom"), 150*time.Millisecond); err != nil { + t.Fatalf("Set(custom, 150ms) returned error: %v", err) } if err := c.Set("permanent", []byte("permanent"), NoExpiration); err != nil { t.Fatalf("Set(permanent, NoExpiration) returned error: %v", err) } - time.Sleep(75 * time.Millisecond) + time.Sleep(80 * time.Millisecond) if _, err := c.Get("default"); err != ErrCacheMiss { t.Errorf("Get(default) error = %v, want ErrCacheMiss", err) @@ -121,7 +114,7 @@ func TestDiskCacheExpirationPolicies(t *testing.T) { t.Errorf("Get(permanent) returned error: %v", err) } - time.Sleep(50 * time.Millisecond) + time.Sleep(100 * time.Millisecond) if _, err := c.Get("custom"); err != ErrCacheMiss { t.Errorf("Get(custom) error = %v, want ErrCacheMiss", err) @@ -132,9 +125,7 @@ func TestDiskCacheExpirationPolicies(t *testing.T) { } func TestDiskCacheNoExpirationSurvivesReload(t *testing.T) { - testDir := "uploads/test_diskcache_no_expiration_reload" - defer func() { _ = os.RemoveAll(testDir) }() - _ = os.RemoveAll(testDir) + testDir := t.TempDir() c := New(testDir) if err := c.Set("permanent", []byte("value"), NoExpiration); err != nil { @@ -154,9 +145,7 @@ func TestDiskCacheNoExpirationSurvivesReload(t *testing.T) { } func TestDiskCacheLRUEviction(t *testing.T) { - testDir := "uploads/test_diskcache_lru" - defer func() { _ = os.RemoveAll(testDir) }() - _ = os.RemoveAll(testDir) + testDir := t.TempDir() c := New(testDir) defer func() { _ = c.Clear() }() @@ -186,10 +175,8 @@ func TestDiskCacheLRUEviction(t *testing.T) { t.Errorf("k2 should exist: %v", err) } - // Write item 3: 8 + 2 = 10 bytes -> total size would be 30, exceeding 20. - // This should evict the oldest item. Since k1 was accessed, but then k2 was accessed, - // wait, let's access k1 again to make it the most recently used, so k2 becomes oldest! - _, _ = c.Get("k1") // k1 is now MRU, k2 is LRU + // Access k1 again to make it MRU, k2 becomes LRU + _, _ = c.Get("k1") err = c.Set("k3", []byte("v3"), DefaultExpiration) if err != nil { diff --git a/pkg/cache/ram/cache.go b/backend/pkg/cache/ram/cache.go similarity index 100% rename from pkg/cache/ram/cache.go rename to backend/pkg/cache/ram/cache.go diff --git a/pkg/cache/ram/cache_test.go b/backend/pkg/cache/ram/cache_test.go similarity index 100% rename from pkg/cache/ram/cache_test.go rename to backend/pkg/cache/ram/cache_test.go diff --git a/pkg/cache/ram/manager.go b/backend/pkg/cache/ram/manager.go similarity index 91% rename from pkg/cache/ram/manager.go rename to backend/pkg/cache/ram/manager.go index 92245ece..d961187e 100644 --- a/pkg/cache/ram/manager.go +++ b/backend/pkg/cache/ram/manager.go @@ -4,9 +4,9 @@ package ram import ( + "Wavelet/pkg/util" "context" "errors" - "maps" "sync" "time" ) @@ -32,7 +32,7 @@ type CacheItem struct { // Loader is an interface that the cache client must implement to handle database retrieval. type Loader interface { LoadAll(ctx context.Context, configType string) ([]CacheItem, error) - LoadOne(ctx context.Context, configType string, key string) (CacheItem, error) + LoadOne(ctx context.Context, configType, key string) (CacheItem, error) } type cacheEntry struct { @@ -60,7 +60,7 @@ func getWriteLock(configType string) *sync.Mutex { // Get retrieves a cache item from the local cache store, checking for expiration. // Reads are completely lock-free because maps stored in Otter are immutable. -func Get(configType string, key string) (CacheItem, bool) { +func Get(configType, key string) (CacheItem, bool) { m, ok := managerCache.GetIfPresent(configType) if !ok { return CacheItem{}, false @@ -74,14 +74,14 @@ func Get(configType string, key string) (CacheItem, bool) { // Check expiration if entry.item.TTL != -1 && !entry.expireAt.IsZero() && time.Now().After(entry.expireAt) { // Asynchronously remove the expired item from the map and write back - go deleteKeyIfExpired(configType, key, entry.expireAt) + util.Go(func() { deleteKeyIfExpired(configType, key, entry.expireAt) }) return CacheItem{}, false } return entry.item, true } -func deleteKeyIfExpired(configType string, key string, expireAt time.Time) { +func deleteKeyIfExpired(configType, key string, expireAt time.Time) { lock := getWriteLock(configType) lock.Lock() defer lock.Unlock() @@ -120,7 +120,9 @@ func Set(item CacheItem) { currentMap, ok := managerCache.GetIfPresent(item.Type) newMap := make(map[string]cacheEntry) if ok { - maps.Copy(newMap, currentMap) + for k, v := range currentMap { + newMap[k] = v + } } var expireAt time.Time @@ -137,7 +139,7 @@ func Set(item CacheItem) { // Delete removes a single item from the local cache store. // Writes are protected by a fine-grained lock per configType. -func Delete(configType string, key string) { +func Delete(configType, key string) { lock := getWriteLock(configType) lock.Lock() defer lock.Unlock() @@ -195,7 +197,7 @@ func GetTypeItems(configType string) []CacheItem { } // Refresh reloads configuration cache from database via the Loader. -func Refresh(ctx context.Context, configType string, key string, loader Loader) error { +func Refresh(ctx context.Context, configType, key string, loader Loader) error { if configType == "" { return errors.New("type is required") } diff --git a/backend/pkg/ginutil/auth.go b/backend/pkg/ginutil/auth.go new file mode 100644 index 00000000..99a0638d --- /dev/null +++ b/backend/pkg/ginutil/auth.go @@ -0,0 +1,24 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package ginutil + +import ( + "Wavelet/pkg/response" + + "github.com/gin-gonic/gin" +) + +// errAuthUnavailable is reported when a route's authentication guard cannot be +// resolved, so the request is rejected instead of reaching the handler. +const errAuthUnavailable = "authServiceUnavailable" + +// AuthUnavailable returns a middleware that denies the request. Plugins use it +// as the fallback when contracts.AuthService cannot be resolved or its middleware +// has an unexpected shape: the alternative is a pass-through closure that serves +// the request as if it were authenticated. +func AuthUnavailable() gin.HandlerFunc { + return func(c *gin.Context) { + response.AbortUnauthorized(c, errAuthUnavailable) + } +} diff --git a/backend/pkg/ginutil/context.go b/backend/pkg/ginutil/context.go new file mode 100644 index 00000000..3ea9ec0d --- /dev/null +++ b/backend/pkg/ginutil/context.go @@ -0,0 +1,23 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package ginutil provides helper utilities for Gin web framework contexts. +package ginutil + +import "github.com/gin-gonic/gin" + +// GetFromContext retrieves a typed value from Gin context. +func GetFromContext[T any](c *gin.Context, key string) (T, bool) { + value, exists := c.Get(key) + if !exists { + var zero T + return zero, false + } + typed, ok := value.(T) + return typed, ok +} + +// SetToContext sets a typed value into Gin context. +func SetToContext[T any](c *gin.Context, key string, value T) { + c.Set(key, value) +} diff --git a/pkg/httppool/httppool.go b/backend/pkg/httppool/httppool.go similarity index 100% rename from pkg/httppool/httppool.go rename to backend/pkg/httppool/httppool.go diff --git a/pkg/httppool/httppool_test.go b/backend/pkg/httppool/httppool_test.go similarity index 96% rename from pkg/httppool/httppool_test.go rename to backend/pkg/httppool/httppool_test.go index a753bda7..edaec191 100644 --- a/pkg/httppool/httppool_test.go +++ b/backend/pkg/httppool/httppool_test.go @@ -52,7 +52,7 @@ func TestNewTransportUsesConfiguredDirectDialer(t *testing.T) { dialer := &net.Dialer{} transport := NewTransport(TransportOptions{ Proxy: nil, - DialContext: func(ctx context.Context, network string, address string) (net.Conn, error) { + DialContext: func(ctx context.Context, network, address string) (net.Conn, error) { dialedAddress = address return dialer.DialContext(ctx, network, server.Listener.Addr().String()) }, diff --git a/backend/pkg/idgen/snowflake.go b/backend/pkg/idgen/snowflake.go new file mode 100644 index 00000000..3081178b --- /dev/null +++ b/backend/pkg/idgen/snowflake.go @@ -0,0 +1,65 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package idgen 提供分布式 ID 生成器 +package idgen + +import ( + "errors" + "fmt" + "log" + "sync" + + "github.com/bwmarrin/snowflake" +) + +// 2025-12-01 00:00:00 UTC 的毫秒时间戳 +const epoch int64 = 1764547200000 + +const maxNegativeIDRetries = 3 + +var ( + mu sync.RWMutex + node *snowflake.Node +) + +// Init initializes the snowflake ID generator with the given node ID. +func Init(nodeID int64) error { + snowflake.Epoch = epoch + + n, err := snowflake.NewNode(nodeID) + if err != nil { + return fmt.Errorf("idgen: init node %d failed: %w", nodeID, err) + } + + mu.Lock() + node = n + mu.Unlock() + + log.Printf("[Snowflake] initialized with node ID: %d, epoch: 2025-12-01\n", nodeID) + return nil +} + +// ErrNotInitialized indicates NextUint64ID was called before Init. +var ErrNotInitialized = errors.New("idgen: Init must be called before generating IDs") + +// NextUint64ID 生成下一个分布式唯一 ID。 +// 理论上不应出现负值;若出现则最多重试 maxNegativeIDRetries 次,仍失败则 panic。 +func NextUint64ID() uint64 { + mu.RLock() + n := node + mu.RUnlock() + + if n == nil { + panic(ErrNotInitialized) + } + + for attempt := 1; attempt <= maxNegativeIDRetries; attempt++ { + id := n.Generate().Int64() + if id >= 0 { + return uint64(id) + } + log.Printf("[Snowflake] generated negative ID: %d (attempt %d/%d)", id, attempt, maxNegativeIDRetries) + } + panic(fmt.Sprintf("[Snowflake] generated negative ID after %d attempts", maxNegativeIDRetries)) +} diff --git a/backend/pkg/idgen/snowflake_test.go b/backend/pkg/idgen/snowflake_test.go new file mode 100644 index 00000000..82ef3407 --- /dev/null +++ b/backend/pkg/idgen/snowflake_test.go @@ -0,0 +1,30 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package idgen + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNextUint64ID(t *testing.T) { + require.NoError(t, Init(1)) + id := NextUint64ID() + assert.NotZero(t, id) +} + +func TestNextUint64ID_PanicsWhenNotInitialized(t *testing.T) { + mu.Lock() + node = nil + mu.Unlock() + + assert.PanicsWithError(t, ErrNotInitialized.Error(), func() { + NextUint64ID() + }) + + // Restore initialization for other tests + require.NoError(t, Init(1)) +} diff --git a/pkg/logger/errs.go b/backend/pkg/logger/errs.go similarity index 100% rename from pkg/logger/errs.go rename to backend/pkg/logger/errs.go diff --git a/pkg/logger/logger.go b/backend/pkg/logger/logger.go similarity index 89% rename from pkg/logger/logger.go rename to backend/pkg/logger/logger.go index ba9bedb3..dfaff3f0 100644 --- a/pkg/logger/logger.go +++ b/backend/pkg/logger/logger.go @@ -1,4 +1,3 @@ -// Copyright 2025 linux.do // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 @@ -78,25 +77,25 @@ func Init(cfg Config) { } // DebugF 输出 Debug 级别日志 -func DebugF(ctx context.Context, format string, args ...any) { +func DebugF(ctx context.Context, format string, args ...interface{}) { msg := fmt.Sprintf(format, args...) logger.Ctx(ctx).Debug(msg, getTraceIDFields(ctx)...) } // InfoF 输出 Info 级别日志 -func InfoF(ctx context.Context, format string, args ...any) { +func InfoF(ctx context.Context, format string, args ...interface{}) { msg := fmt.Sprintf(format, args...) logger.Ctx(ctx).Info(msg, getTraceIDFields(ctx)...) } // WarnF 输出 Warn 级别日志 -func WarnF(ctx context.Context, format string, args ...any) { +func WarnF(ctx context.Context, format string, args ...interface{}) { msg := fmt.Sprintf(format, args...) logger.Ctx(ctx).Warn(msg, getTraceIDFields(ctx)...) } // ErrorF 输出 Error 级别日志 -func ErrorF(ctx context.Context, format string, args ...any) { +func ErrorF(ctx context.Context, format string, args ...interface{}) { msg := fmt.Sprintf(format, args...) logger.Ctx(ctx).Error(msg, getTraceIDFields(ctx)...) } diff --git a/pkg/logger/ringbuffer.go b/backend/pkg/logger/ringbuffer.go similarity index 95% rename from pkg/logger/ringbuffer.go rename to backend/pkg/logger/ringbuffer.go index 89fe211c..5053b880 100644 --- a/pkg/logger/ringbuffer.go +++ b/backend/pkg/logger/ringbuffer.go @@ -1,4 +1,3 @@ -// Copyright 2025 linux.do // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 @@ -47,7 +46,7 @@ func (r *LogRingBuffer) Write(p []byte) (int, error) { data := string(p) start := 0 - for i := range len(data) { + for i := 0; i < len(data); i++ { if data[i] == '\n' { line := data[start:i] start = i + 1 @@ -100,7 +99,7 @@ func (r *LogRingBuffer) appendLine(line string) { // cursor=0 表示查询最新日志,cursor>0 表示查询 index < cursor 的更早日志 // limit 为返回条数上限 // 返回日志条目(按 index 升序)和是否有更早的日志 -func (r *LogRingBuffer) Query(cursor int, limit int) ([]LogEntry, bool) { +func (r *LogRingBuffer) Query(cursor, limit int) ([]LogEntry, bool) { r.mu.RLock() defer r.mu.RUnlock() @@ -114,7 +113,7 @@ func (r *LogRingBuffer) Query(cursor int, limit int) ([]LogEntry, bool) { // 将 ring buffer 中的有效条目按顺序收集 ordered := make([]LogEntry, 0, r.count) - for i := range r.count { + for i := 0; i < r.count; i++ { pos := (oldestPos + i) % r.cap ordered = append(ordered, r.entries[pos]) } @@ -141,7 +140,10 @@ func (r *LogRingBuffer) Query(cursor int, limit int) ([]LogEntry, bool) { } // 返回 cut 之前的最后 limit 条 - start := max(cut-limit, 0) + start := cut - limit + if start < 0 { + start = 0 + } hasMore := start > 0 return ordered[start:cut], hasMore diff --git a/pkg/logger/ringbuffer_test.go b/backend/pkg/logger/ringbuffer_test.go similarity index 91% rename from pkg/logger/ringbuffer_test.go rename to backend/pkg/logger/ringbuffer_test.go index 249a74ed..85aa04ef 100644 --- a/pkg/logger/ringbuffer_test.go +++ b/backend/pkg/logger/ringbuffer_test.go @@ -1,4 +1,3 @@ -// Copyright 2025 linux.do // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 @@ -8,7 +7,6 @@ import ( "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestLogRingBuffer_WriteAndQuery(t *testing.T) { @@ -19,7 +17,7 @@ func TestLogRingBuffer_WriteAndQuery(t *testing.T) { entries, hasMore := rb.Query(0, 10) assert.False(t, hasMore) - assert.Len(t, entries, 3) + assert.Equal(t, 3, len(entries)) assert.Equal(t, "line1", entries[0].Data) assert.Equal(t, "line2", entries[1].Data) assert.Equal(t, "line3", entries[2].Data) @@ -35,7 +33,7 @@ func TestLogRingBuffer_CapacityOverflow(t *testing.T) { entries, hasMore := rb.Query(0, 10) assert.False(t, hasMore) - assert.Len(t, entries, 3) + assert.Equal(t, 3, len(entries)) assert.Equal(t, "c", entries[0].Data) assert.Equal(t, "d", entries[1].Data) assert.Equal(t, "e", entries[2].Data) @@ -49,7 +47,7 @@ func TestLogRingBuffer_QueryLatest(t *testing.T) { // Query latest 2 entries, hasMore := rb.Query(0, 2) assert.True(t, hasMore) - assert.Len(t, entries, 2) + assert.Equal(t, 2, len(entries)) assert.Equal(t, "d", entries[0].Data) assert.Equal(t, "e", entries[1].Data) } @@ -61,12 +59,12 @@ func TestLogRingBuffer_QueryByCursor(t *testing.T) { // First get all to find indices all, _ := rb.Query(0, 10) - assert.Len(t, all, 5) + assert.Equal(t, 5, len(all)) // Query entries before index 3 entries, hasMore := rb.Query(3, 10) assert.False(t, hasMore) - assert.Len(t, entries, 3) + assert.Equal(t, 3, len(entries)) assert.Equal(t, "a", entries[0].Data) assert.Equal(t, "b", entries[1].Data) assert.Equal(t, "c", entries[2].Data) @@ -80,7 +78,7 @@ func TestLogRingBuffer_QueryByCursorWithLimit(t *testing.T) { // Query 2 entries before index 4 entries, hasMore := rb.Query(4, 2) assert.True(t, hasMore) - assert.Len(t, entries, 2) + assert.Equal(t, 2, len(entries)) assert.Equal(t, "c", entries[0].Data) assert.Equal(t, "d", entries[1].Data) } @@ -99,7 +97,7 @@ func TestLogRingBuffer_QueryNonExistentCursor(t *testing.T) { entries, hasMore := rb.Query(999, 10) assert.False(t, hasMore) - assert.Len(t, entries, 2) + assert.Equal(t, 2, len(entries)) assert.Equal(t, "a", entries[0].Data) assert.Equal(t, "b", entries[1].Data) } @@ -139,7 +137,7 @@ func TestLogRingBuffer_WriteNoNewline(t *testing.T) { _, _ = rb.Write([]byte("partial")) entries, _ := rb.Query(0, 10) - assert.Len(t, entries, 1) + assert.Equal(t, 1, len(entries)) assert.Equal(t, "partial", entries[0].Data) } @@ -148,7 +146,7 @@ func TestLogRingBuffer_WriteEmpty(t *testing.T) { n, err := rb.Write([]byte("")) assert.Equal(t, 0, n) - require.NoError(t, err) + assert.NoError(t, err) entries, _ := rb.Query(0, 10) assert.Nil(t, entries) @@ -161,7 +159,7 @@ func TestLogRingBuffer_QueryAfterOverflow(t *testing.T) { entries, hasMore := rb.Query(0, 10) assert.False(t, hasMore) - assert.Len(t, entries, 3) + assert.Equal(t, 3, len(entries)) assert.Equal(t, "5", entries[0].Data) assert.Equal(t, "6", entries[1].Data) assert.Equal(t, "7", entries[2].Data) @@ -179,14 +177,14 @@ func TestLogRingBuffer_NextCursor(t *testing.T) { // Query latest 2, should return next_cursor pointing to first returned entry entries, _ := rb.Query(0, 2) - assert.Len(t, entries, 2) + assert.Equal(t, 2, len(entries)) // entries[0].Index = 3 ("d"), entries[1].Index = 4 ("e") assert.Equal(t, 3, entries[0].Index) // Now use that index as cursor to get older entries older, hasMore := rb.Query(entries[0].Index, 10) assert.False(t, hasMore) - assert.Len(t, older, 3) + assert.Equal(t, 3, len(older)) assert.Equal(t, "a", older[0].Data) assert.Equal(t, "b", older[1].Data) assert.Equal(t, "c", older[2].Data) diff --git a/pkg/logger/utils.go b/backend/pkg/logger/utils.go similarity index 97% rename from pkg/logger/utils.go rename to backend/pkg/logger/utils.go index d1a48bdf..37e12c92 100644 --- a/pkg/logger/utils.go +++ b/backend/pkg/logger/utils.go @@ -1,4 +1,3 @@ -// Copyright 2025 linux.do // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 @@ -18,7 +17,7 @@ import ( ) // logDirPerm 日志目录权限 -const logDirPerm = 0750 +const logDirPerm = 0o750 func getLogWriterForConfig(cfg Config) (zapcore.WriteSyncer, error) { if cfg.Output == "file" { diff --git a/pkg/mail/errs.go b/backend/pkg/mail/errs.go similarity index 100% rename from pkg/mail/errs.go rename to backend/pkg/mail/errs.go diff --git a/pkg/mail/mail.go b/backend/pkg/mail/mail.go similarity index 89% rename from pkg/mail/mail.go rename to backend/pkg/mail/mail.go index 1321d1cf..b64ae5fe 100644 --- a/pkg/mail/mail.go +++ b/backend/pkg/mail/mail.go @@ -1,4 +1,3 @@ -// Copyright 2025 linux.do // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 @@ -39,12 +38,12 @@ func sanitizeHeaderValue(v string) string { } // SendMail sends an HTML email using the provided config and message details -func SendMail(ctx context.Context, cfg Config, to string, subject, body string) error { +func SendMail(ctx context.Context, cfg Config, to, subject, body string) error { return SendMailHTML(ctx, cfg, to, subject, body) } // SendMailHTML sends an HTML format email -func SendMailHTML(ctx context.Context, cfg Config, to string, subject, body string) error { +func SendMailHTML(ctx context.Context, cfg Config, to, subject, body string) error { addr := net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)) // Header & MIME settings for HTML email @@ -55,21 +54,21 @@ func SendMailHTML(ctx context.Context, cfg Config, to string, subject, body stri header["MIME-Version"] = "1.0" header["Content-Type"] = "text/html; charset=UTF-8" - var message strings.Builder + message := "" for k, v := range header { - fmt.Fprintf(&message, "%s: %s\r\n", k, v) + message += fmt.Sprintf("%s: %s\r\n", k, v) } - message.WriteString("\r\n" + body) + message += "\r\n" + body auth := smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host) // If using SSL port 465, we connection via TLS dial if cfg.Port == smtpSSLPort { - return sendMailViaSSL(ctx, addr, auth, cfg, to, message.String()) + return sendMailViaSSL(ctx, addr, auth, cfg, to, message) } // For standard port (587 / 25), use smtp.SendMail directly (handles STARTTLS automatically if server supports it) - err := smtp.SendMail(addr, auth, cfg.Username, []string{to}, []byte(message.String())) + err := smtp.SendMail(addr, auth, cfg.Username, []string{to}, []byte(message)) if err != nil { return fmt.Errorf(errSendMailFailed, err) } @@ -125,9 +124,9 @@ func sendMailViaSSL(ctx context.Context, addr string, auth smtp.Auth, cfg Config } // SendMailWithLog sends a test email and records a detailed SMTP connection log -func SendMailWithLog(ctx context.Context, cfg Config, to string, subject, body string) (string, error) { +func SendMailWithLog(ctx context.Context, cfg Config, to, subject, body string) (string, error) { var logBuf bytes.Buffer - logLine := func(dir string, format string, args ...any) { + logLine := func(dir, format string, args ...interface{}) { fmt.Fprintf(&logBuf, "[%s] %s\n", dir, fmt.Sprintf(format, args...)) } @@ -227,14 +226,14 @@ func SendMailWithLog(ctx context.Context, cfg Config, to string, subject, body s header["MIME-Version"] = "1.0" header["Content-Type"] = "text/html; charset=UTF-8" - var message strings.Builder + message := "" for k, v := range header { - fmt.Fprintf(&message, "%s: %s\r\n", k, v) + message += fmt.Sprintf("%s: %s\r\n", k, v) } - message.WriteString("\r\n" + body) + message += "\r\n" + body logLine("System", "Sending message body...") - if _, err = w.Write([]byte(message.String())); err != nil { + if _, err = w.Write([]byte(message)); err != nil { _ = w.Close() logLine("Error", "Writing message body failed: %v", err) return logBuf.String(), err diff --git a/pkg/mail/mail_test.go b/backend/pkg/mail/mail_test.go similarity index 98% rename from pkg/mail/mail_test.go rename to backend/pkg/mail/mail_test.go index 9b69740e..08e16330 100644 --- a/pkg/mail/mail_test.go +++ b/backend/pkg/mail/mail_test.go @@ -1,4 +1,3 @@ -// Copyright 2025 linux.do // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/shared/response/abort.go b/backend/pkg/response/abort.go similarity index 100% rename from internal/shared/response/abort.go rename to backend/pkg/response/abort.go diff --git a/internal/shared/response/middleware.go b/backend/pkg/response/middleware.go similarity index 100% rename from internal/shared/response/middleware.go rename to backend/pkg/response/middleware.go diff --git a/internal/shared/response/middleware_test.go b/backend/pkg/response/middleware_test.go similarity index 98% rename from internal/shared/response/middleware_test.go rename to backend/pkg/response/middleware_test.go index d593f7cc..0a354973 100644 --- a/internal/shared/response/middleware_test.go +++ b/backend/pkg/response/middleware_test.go @@ -34,7 +34,7 @@ func TestAbortWithError(t *testing.T) { require.Len(t, c.Errors, 1) var apiErr *APIError - require.ErrorAs(t, c.Errors.Last().Err, &apiErr) + require.True(t, errors.As(c.Errors.Last().Err, &apiErr)) assert.Equal(t, http.StatusBadRequest, apiErr.Code) assert.Equal(t, "invalid input", apiErr.Msg) assert.True(t, c.IsAborted()) diff --git a/internal/shared/response/response.go b/backend/pkg/response/response.go similarity index 92% rename from internal/shared/response/response.go rename to backend/pkg/response/response.go index 185972b1..b783b999 100644 --- a/internal/shared/response/response.go +++ b/backend/pkg/response/response.go @@ -1,4 +1,3 @@ -// Copyright 2025 linux.do // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 @@ -16,8 +15,8 @@ type Response[T any] struct { // Any 用于 Swagger 文档的响应类型(非泛型) // swag 不支持泛型,使用此类型替代 Response[T] type Any struct { - ErrorMsg string `json:"error_msg" example:""` - Data any `json:"data"` + ErrorMsg string `json:"error_msg" example:""` + Data interface{} `json:"data"` } // APIError 统一的 API 业务错误类型,可被全局错误处理中间件捕获 diff --git a/backend/pkg/testhelper/cleanup.go b/backend/pkg/testhelper/cleanup.go new file mode 100644 index 00000000..0b0ecada --- /dev/null +++ b/backend/pkg/testhelper/cleanup.go @@ -0,0 +1,17 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package testhelper + +// RegisterCleanup registers an extra cleanup hook invoked by SetupTestEnvironment. +func RegisterCleanup(fn func()) { + extraCleanups = append(extraCleanups, fn) +} + +var extraCleanups []func() + +func runExtraCleanups() { + for _, fn := range extraCleanups { + fn() + } +} diff --git a/backend/pkg/testhelper/gin.go b/backend/pkg/testhelper/gin.go new file mode 100644 index 00000000..29b8c19f --- /dev/null +++ b/backend/pkg/testhelper/gin.go @@ -0,0 +1,21 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package testhelper + +import ( + "Wavelet/pkg/response" + + "github.com/gin-gonic/gin" +) + +// NewTestGinEngine 创建带 ErrorHandlerMiddleware 的 Gin 引擎,与生产环境错误响应行为一致。 +func NewTestGinEngine(middlewares ...gin.HandlerFunc) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(response.ErrorHandlerMiddleware()) + for _, middleware := range middlewares { + r.Use(middleware) + } + return r +} diff --git a/backend/pkg/testhelper/test_helper.go b/backend/pkg/testhelper/test_helper.go new file mode 100644 index 00000000..ce06fb9d --- /dev/null +++ b/backend/pkg/testhelper/test_helper.go @@ -0,0 +1,333 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package testhelper 提供测试辅助工具 +package testhelper + +import ( + "context" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/glebarez/sqlite" + "github.com/redis/go-redis/v9" + "github.com/redis/go-redis/v9/maintnotifications" + "gorm.io/gorm" + + cachepkg "Wavelet/plugins/infra/cache" + db "Wavelet/plugins/infra/database" +) + +// SystemConfig 测试用系统配置表 +type SystemConfig struct { + Key string `gorm:"primaryKey;size:64;not null"` + Value string `gorm:"type:text;not null"` + Type string `gorm:"size:32;not null"` + Visibility string `gorm:"size:32;not null;default:'hidden'"` + Description string `gorm:"size:255"` +} + +// TableName 返回测试配置表表名 +func (SystemConfig) TableName() string { + return "w_system_configs" +} + +type userHelper struct { + ID uint64 `gorm:"primaryKey;autoIncrement"` + Username string `gorm:"size:64;uniqueIndex;not null"` + Nickname string `gorm:"size:64;not null;default:''"` + Password string `gorm:"size:255;not null;default:''"` + Email string `gorm:"size:128;index;default:''"` + AvatarURL string `gorm:"size:255;default:''"` + IsAdmin bool `gorm:"default:false;not null"` + IsActive bool `gorm:"default:true;not null"` + NeedChangePassword bool `gorm:"default:false;not null"` + Bio string `gorm:"size:500;default:''"` + Phone string `gorm:"size:32;default:''"` + Gender string `gorm:"size:16;default:''"` + Website string `gorm:"size:255;default:''"` + Location string `gorm:"size:255;default:''"` + LastLoginAt time.Time + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` +} + +func (userHelper) TableName() string { return "w_users" } + +type accessTokenHelper struct { + ID uint64 `gorm:"primaryKey;autoIncrement"` + UserID uint64 `gorm:"not null;index"` + Name string `gorm:"size:64;not null"` + TokenHash string `gorm:"size:64;uniqueIndex;not null"` + MaskedToken string `gorm:"size:32;not null"` + IsAdmin bool `gorm:"default:false;not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` +} + +func (accessTokenHelper) TableName() string { return "w_access_tokens" } + +type authSourceHelper struct { + ID uint64 `gorm:"primaryKey;autoIncrement"` + Type string `gorm:"size:32;not null"` + Name string `gorm:"size:64;not null"` + Enabled bool `gorm:"default:false;not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` +} + +func (authSourceHelper) TableName() string { return "w_auth_sources" } + +type externalAccountHelper struct { + ID uint64 `gorm:"primaryKey;autoIncrement"` + UserID uint64 `gorm:"not null;index"` + AuthSourceType string `gorm:"size:32;not null;index"` + ExternalID string `gorm:"size:128;not null;index"` + Username string `gorm:"size:128;default:''"` + Email string `gorm:"size:128;default:''"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` +} + +func (externalAccountHelper) TableName() string { return "w_external_accounts" } + +type uploadHelper struct { + ID uint64 `gorm:"primaryKey;autoIncrement"` + UserID uint64 `gorm:"not null;index"` + FileName string `gorm:"size:255;not null"` + FilePath string `gorm:"size:500;not null"` + FileSize int64 `gorm:"not null"` + MimeType string `gorm:"size:128;not null"` + Extension string `gorm:"size:32;not null"` + Hash string `gorm:"size:64;index;not null;default:''"` + Type string `gorm:"size:50;not null;index"` + Status string `gorm:"size:20;not null;default:'pending'"` + AccessMode int `gorm:"not null;default:0"` + Metadata string `gorm:"type:text"` + CreatedAt time.Time `gorm:"autoCreateTime;index"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` +} + +func (uploadHelper) TableName() string { return "w_uploads" } + +type uploadStatHelper struct { + ID uint64 `gorm:"primaryKey;autoIncrement"` + Dimension string `gorm:"size:32;not null;uniqueIndex:idx_stat_dimension_key"` + StatKey string `gorm:"size:100;not null;uniqueIndex:idx_stat_dimension_key"` + FileCount int64 `gorm:"not null;default:0"` + FileSize int64 `gorm:"not null;default:0"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` +} + +func (uploadStatHelper) TableName() string { return "w_upload_stats" } + +type messageChannelHelper struct { + ID uint64 `gorm:"primaryKey;autoIncrement"` + Type string `gorm:"size:32;not null"` + Name string `gorm:"size:64;not null"` + OwnerScope string `gorm:"size:32;not null;default:'system'"` + OwnerID *uint64 + Credentials string `gorm:"type:text;not null"` + Extra string `gorm:"type:text"` + Enabled bool `gorm:"default:false;not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` +} + +func (messageChannelHelper) TableName() string { return "w_message_channels" } + +type messageBindingHelper struct { + ID uint64 `gorm:"primaryKey;autoIncrement"` + ChannelID uint64 `gorm:"not null;index"` + PlatformUserID string `gorm:"size:128;not null;index"` + UserID uint64 `gorm:"not null;index"` + CreatedAt time.Time `gorm:"autoCreateTime"` +} + +func (messageBindingHelper) TableName() string { return "w_message_bindings" } + +type messagePairingHelper struct { + ID uint64 `gorm:"primaryKey;autoIncrement"` + Code string `gorm:"size:32;uniqueIndex;not null"` + ChannelID uint64 `gorm:"not null;index"` + PlatformUserID string `gorm:"size:128;not null;index"` + UserID uint64 `gorm:"not null;index"` + ExpiresAt time.Time `gorm:"not null;index"` + CreatedAt time.Time `gorm:"autoCreateTime"` +} + +func (messagePairingHelper) TableName() string { return "w_message_pairing_codes" } + +type taskExecutionHelper struct { + ID uint64 `gorm:"primaryKey;autoIncrement"` + TaskID string `gorm:"size:128;uniqueIndex;not null"` + TaskType string `gorm:"size:64;index;not null"` + TaskName string `gorm:"size:128"` + Status string `gorm:"size:32;index;not null"` + Retryable bool `gorm:"not null;default:false"` + MaxRetry int `gorm:"not null;default:0"` + RetryCount int `gorm:"not null;default:0"` + Log string `gorm:"type:text"` + ErrorMessage string `gorm:"type:text"` + Result string `gorm:"type:text"` + StartedAt *time.Time `gorm:"index"` + FinishedAt *time.Time + Duration int64 + Payload string `gorm:"type:text"` + TriggeredBy string `gorm:"size:32;not null;default:system"` + CreatedAt time.Time `gorm:"autoCreateTime;index"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` +} + +func (taskExecutionHelper) TableName() string { + return "w_task_executions" +} + +type scheduleHelper struct { + ID uint64 `gorm:"primaryKey;autoIncrement"` + TaskType string `gorm:"size:64;uniqueIndex;not null"` + TaskName string `gorm:"size:128;not null"` + CronExpr string `gorm:"size:64;not null"` + Payload string `gorm:"type:text"` + Enabled bool `gorm:"default:true;not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` +} + +func (scheduleHelper) TableName() string { return "w_schedules" } + +const ( + configTypeSystem = "system" + configTypeBusiness = "business" + configValueTrue = "true" + configValueFalse = "false" +) + +// SetupTestEnvironment initializes an in-memory SQLite DB, seeds default configurations, +// starts miniredis, and overrides the global db/Redis clients. It returns a cleanup function. +func SetupTestEnvironment(t *testing.T) (*gorm.DB, *miniredis.Miniredis, func()) { + sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ + DisableForeignKeyConstraintWhenMigrating: true, + }) + if err != nil { + t.Fatalf("failed to open in-memory SQLite db: %v", err) + } + + if sqlDB, err := sqliteDB.DB(); err == nil { + sqlDB.SetMaxOpenConns(1) + } + + // AutoMigrate all tables via internal test helpers to completely decouple testhelper from domain plugins + err = sqliteDB.AutoMigrate( + &userHelper{}, + &accessTokenHelper{}, + &authSourceHelper{}, + &externalAccountHelper{}, + &SystemConfig{}, + &uploadHelper{}, + &uploadStatHelper{}, + &taskExecutionHelper{}, + &scheduleHelper{}, + &messageChannelHelper{}, + &messageBindingHelper{}, + &messagePairingHelper{}, + ) + if err != nil { + t.Fatalf("failed to auto migrate tables: %v", err) + } + + mr, err := miniredis.Run() + if err != nil { + t.Fatalf("failed to start miniredis: %v", err) + } + + redisClient := redis.NewClient(&redis.Options{ + Addr: mr.Addr(), + MaintNotificationsConfig: &maintnotifications.Config{ + Mode: maintnotifications.ModeDisabled, + }, + }) + + db.SetDB(sqliteDB) + cachepkg.Redis = redisClient + + seedDefaultConfigs(t, sqliteDB) + + cleanup := func() { + runExtraCleanups() + _ = redisClient.Close() + mr.Close() + db.SetDB(nil) + cachepkg.Redis = nil + } + + return sqliteDB, mr, cleanup +} + +func getSeedConfigsPart1() []SystemConfig { + return []SystemConfig{ + {Key: "upload_allowed_extensions", Value: `["jpg", "jpeg", "png", "gif", "webp", "txt", "pdf", "zip"]`, Type: configTypeSystem, Description: "允许上传的文件扩展名列表(JSON 字符串数组)"}, + {Key: "site_name", Value: "Wavelet", Type: configTypeSystem, Description: "站点名称"}, + {Key: "site_description", Value: "Lightweight and Modular Web Application Platform", Type: configTypeSystem, Description: "站点描述"}, + {Key: "password_login_enabled", Value: configValueTrue, Type: configTypeSystem, Description: "是否开启账号密码登录(true/false)"}, + {Key: "registration_enabled", Value: configValueTrue, Type: configTypeSystem, Description: "是否允许新用户注册(全局总开关,true/false)"}, + {Key: "password_register_enabled", Value: configValueTrue, Type: configTypeSystem, Description: "是否允许账号密码注册(true/false)"}, + {Key: "oidc_login_enabled", Value: configValueFalse, Type: configTypeSystem, Description: "是否开启 OIDC 登录(true/false)"}, + {Key: "server_address", Value: "http://localhost:8000", Type: configTypeSystem, Description: "服务端访问地址(用于生成绝对路径链接,多个地址用英文逗号分隔)"}, + {Key: "smtp_host", Value: "", Type: configTypeSystem, Description: "SMTP 服务器主机名或 IP"}, + {Key: "smtp_port", Value: "587", Type: configTypeSystem, Description: "SMTP 服务器端口(标准 STARTTLS 为 587,SMTPS 为 465)"}, + {Key: "smtp_username", Value: "", Type: configTypeSystem, Description: "SMTP 账户(如 sender@example.com)"}, + {Key: "smtp_password", Value: "", Type: configTypeSystem, Description: "SMTP 访问凭证(授权码/密码)"}, + {Key: "email_login_verification_enabled", Value: configValueFalse, Type: configTypeSystem, Description: "是否开启邮箱登录验证(true/false)"}, + {Key: "email_register_verification_enabled", Value: configValueFalse, Type: configTypeSystem, Description: "是否开启邮箱注册验证(true/false)"}, + {Key: "menu_display_config", Value: "{}", Type: configTypeSystem, Description: "目录显示配置(JSON 字符串,格式为 {url: enabled})"}, + {Key: "search_engine_indexing_enabled", Value: configValueFalse, Type: configTypeSystem, Description: "是否允许搜索引擎检索"}, + {Key: "file_access_whitelist", Value: `["avatar"]`, Type: configTypeSystem, Description: "免登录访问的文件业务类型白名单"}, + {Key: "disk_cache_max_size_mb", Value: "100", Type: configTypeSystem, Description: "磁盘缓存最大空间大小 (MB)"}, + {Key: "disk_cache_ttl_minutes", Value: "60", Type: configTypeSystem, Description: "磁盘缓存默认有效期 (分钟)"}, + {Key: "disk_cache_lru_enabled", Value: configValueTrue, Type: configTypeSystem, Description: "是否启用 LRU 淘汰机制"}, + {Key: "login_session_ttl_hours", Value: "0", Type: configTypeSystem, Description: "登录会话过期时间 (小时,0表示浏览器关闭后自动退出,-1表示永不过期)"}, + {Key: "update_upstream_repository", Value: "Rain-kl/Wavelet", Type: configTypeSystem, Description: "GitHub Actions Release 上游仓库"}, + {Key: "storage_config", Value: `{"driver":"local","local":{"root":"."},"s3":{"region":"us-east-1"},"r2":{"region":"auto"},"minio":{"region":"us-east-1","path_style":true},"oss":{},"webdav":{}}`, Type: configTypeSystem, Description: "文件存储驱动及连接配置(JSON)"}, + {Key: "log_database", Value: "sqlite", Type: configTypeSystem, Description: "当前日志主库"}, + {Key: "log_db_migration", Value: "", Type: configTypeSystem, Description: "日志库迁移冻结标记"}, + {Key: "log_retention_days_postgres", Value: "30", Type: configTypeBusiness, Description: "PostgreSQL 用户访问日志保留天数"}, + {Key: "log_retention_days_sqlite", Value: "30", Type: configTypeBusiness, Description: "SQLite 用户访问日志保留天数"}, + {Key: "log_retention_days_clickhouse", Value: "30", Type: configTypeBusiness, Description: "ClickHouse 用户访问日志保留天数"}, + } +} + +func seedDefaultConfigs(t *testing.T, tx *gorm.DB) { + defaultConfigs := getSeedConfigsPart1() + + if err := tx.Create(&defaultConfigs).Error; err != nil { + t.Fatalf("failed to seed default system configs: %v", err) + } + + publicKeys := map[string]struct{}{ + "upload_allowed_extensions": {}, + "site_name": {}, + "password_login_enabled": {}, + "registration_enabled": {}, + "password_register_enabled": {}, + "oidc_login_enabled": {}, + } + + keys := make([]string, 0, len(publicKeys)) + for key := range publicKeys { + keys = append(keys, key) + } + if err := tx.Model(&SystemConfig{}). + Where("key IN ?", keys). + Update("visibility", "visible").Error; err != nil { + t.Fatalf("failed to seed public system config visibility: %v", err) + } + + for _, config := range defaultConfigs { + if _, ok := publicKeys[config.Key]; ok { + config.Visibility = "visible" + } + _ = cachepkg.HSetJSON(context.Background(), "system_configs", config.Key, &config) + } +} diff --git a/pkg/trace/propagator.go b/backend/pkg/trace/propagator.go similarity index 92% rename from pkg/trace/propagator.go rename to backend/pkg/trace/propagator.go index 56283b91..5e83adf3 100644 --- a/pkg/trace/propagator.go +++ b/backend/pkg/trace/propagator.go @@ -1,4 +1,3 @@ -// Copyright 2025 linux.do // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 diff --git a/pkg/trace/sampler.go b/backend/pkg/trace/sampler.go similarity index 95% rename from pkg/trace/sampler.go rename to backend/pkg/trace/sampler.go index 388b400b..4a2e08fc 100644 --- a/pkg/trace/sampler.go +++ b/backend/pkg/trace/sampler.go @@ -1,4 +1,3 @@ -// Copyright 2025 linux.do // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 diff --git a/pkg/trace/trace.go b/backend/pkg/trace/trace.go similarity index 85% rename from pkg/trace/trace.go rename to backend/pkg/trace/trace.go index 9586e49c..0e5bf0b1 100644 --- a/pkg/trace/trace.go +++ b/backend/pkg/trace/trace.go @@ -1,4 +1,3 @@ -// Copyright 2025 linux.do // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 @@ -13,8 +12,10 @@ import ( ) // Tracer 全局 OpenTelemetry Tracer 实例 -var Tracer trace.Tracer -var shutdownFuncs []func(context.Context) error +var ( + Tracer trace.Tracer + shutdownFuncs []func(context.Context) error +) func init() { // 初始化 Propagator @@ -22,7 +23,7 @@ func init() { otel.SetTextMapPropagator(prop) // 初始化 Tracer 实例为 No-op 默认以避免未初始化前或测试环境崩溃 - Tracer = otel.GetTracerProvider().Tracer("github.com/Rain-kl/OpenFlare") + Tracer = otel.GetTracerProvider().Tracer("github.com/Rain-kl/Wavelet") } // Config 链路追踪配置 @@ -44,7 +45,7 @@ func Init(cfg Config) { // 更新 Tracer tracerName := cfg.TracerName if tracerName == "" { - tracerName = "github.com/Rain-kl/OpenFlare" + tracerName = "github.com/Rain-kl/Wavelet" } Tracer = tracerProvider.Tracer(tracerName) } diff --git a/pkg/trace/trace_provider.go b/backend/pkg/trace/trace_provider.go similarity index 98% rename from pkg/trace/trace_provider.go rename to backend/pkg/trace/trace_provider.go index 8051f2b8..3415dd49 100644 --- a/pkg/trace/trace_provider.go +++ b/backend/pkg/trace/trace_provider.go @@ -1,4 +1,3 @@ -// Copyright 2025 linux.do // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 diff --git a/backend/pkg/util/client_errs.go b/backend/pkg/util/client_errs.go new file mode 100644 index 00000000..ed878b14 --- /dev/null +++ b/backend/pkg/util/client_errs.go @@ -0,0 +1,10 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package util + +const ( + errCreateHTTPRequestFailed = "创建HTTP请求失败: %w" + errHTTPRequestFailed = "请求%s接口失败: %w" + errInvalidCustomValue = "invalid value: %v" +) diff --git a/pkg/util/crypto.go b/backend/pkg/util/crypto.go similarity index 94% rename from pkg/util/crypto.go rename to backend/pkg/util/crypto.go index ce31763b..4f2ad19a 100644 --- a/pkg/util/crypto.go +++ b/backend/pkg/util/crypto.go @@ -1,4 +1,3 @@ -// Copyright 2025 linux.do // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 @@ -34,7 +33,7 @@ const ( // signKey: 64 字符 hex 编码的密钥(对应 32 字节,用于 AES-256) // plaintext: 要加密的明文字符串 // return: base64 编码的密文 -func Encrypt(signKey string, plaintext string) (string, error) { +func Encrypt(signKey, plaintext string) (string, error) { return encryptBytes(signKey, []byte(plaintext)) } @@ -42,7 +41,7 @@ func Encrypt(signKey string, plaintext string) (string, error) { // signKey: 64 字符 hex 编码的密钥(对应 32 字节,用于 AES-256) // ciphertext: base64 编码的密文 // return: 解密后的明文字符串 -func Decrypt(signKey string, ciphertext string) (string, error) { +func Decrypt(signKey, ciphertext string) (string, error) { plaintext, err := decryptBytes(signKey, ciphertext) if err != nil { return "", err @@ -87,7 +86,7 @@ func encryptBytes(signKey string, plaintext []byte) (string, error) { } // decryptBytes 解密函数,处理字节数据 -func decryptBytes(signKey string, ciphertext string) ([]byte, error) { +func decryptBytes(signKey, ciphertext string) ([]byte, error) { // 将 hex 编码的密钥转换为字节 key, err := hex.DecodeString(signKey) if err != nil { diff --git a/backend/pkg/util/custom_types.go b/backend/pkg/util/custom_types.go new file mode 100644 index 00000000..44bb9ae0 --- /dev/null +++ b/backend/pkg/util/custom_types.go @@ -0,0 +1,28 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package util provides framework-agnostic helper types and HTTP utilities. +package util + +import ( + "database/sql/driver" + "encoding/json" + "fmt" +) + +// StringArray custom type for handling JSON arrays +type StringArray []string + +// Scan 实现 sql.Scanner 接口,从数据库读取 JSON 数组 +func (sa *StringArray) Scan(value interface{}) error { + bytesValue, ok := value.([]byte) + if !ok { + return fmt.Errorf(errInvalidCustomValue, value) + } + return json.Unmarshal(bytesValue, sa) +} + +// Value 实现 driver.Valuer 接口,将 JSON 数组序列化为数据库存储值 +func (sa StringArray) Value() (driver.Value, error) { + return json.Marshal(sa) +} diff --git a/pkg/util/format.go b/backend/pkg/util/format.go similarity index 100% rename from pkg/util/format.go rename to backend/pkg/util/format.go diff --git a/pkg/util/format_test.go b/backend/pkg/util/format_test.go similarity index 100% rename from pkg/util/format_test.go rename to backend/pkg/util/format_test.go diff --git a/pkg/util/goroutine.go b/backend/pkg/util/goroutine.go similarity index 100% rename from pkg/util/goroutine.go rename to backend/pkg/util/goroutine.go diff --git a/backend/pkg/util/goroutine_test.go b/backend/pkg/util/goroutine_test.go new file mode 100644 index 00000000..decfc504 --- /dev/null +++ b/backend/pkg/util/goroutine_test.go @@ -0,0 +1,38 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package util + +import ( + "sync" + "testing" +) + +func TestGoRecoversPanic(t *testing.T) { + var wg sync.WaitGroup + wg.Add(1) + + // Go should swallow the panic without crashing the test process + Go(func() { + defer wg.Done() + panic("boom") + }) + + wg.Wait() +} + +func TestGoRunsNormally(t *testing.T) { + var wg sync.WaitGroup + wg.Add(1) + ran := false + + Go(func() { + defer wg.Done() + ran = true + }) + + wg.Wait() + if !ran { + t.Fatal("expected fn to run") + } +} diff --git a/backend/pkg/util/http_clients.go b/backend/pkg/util/http_clients.go new file mode 100644 index 00000000..e8eed7a8 --- /dev/null +++ b/backend/pkg/util/http_clients.go @@ -0,0 +1,66 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package util + +import ( + "Wavelet/pkg/httppool" + "context" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +// IsLocalhost 检查 URL 是否为 localhost +func IsLocalhost(urlStr string) bool { + u, err := url.Parse(urlStr) + if err != nil { + return false + } + hostname := u.Hostname() + return hostname == "localhost" || hostname == "127.0.0.1" || hostname == "::1" +} + +// HTTP 客户端配置常量 +const ( + httpClientTimeout = 10 // HTTP 客户端超时时间(秒) + httpMaxIdleConns = 100 + httpMaxIdleConnsPerHost = 20 + httpIdleConnTimeout = 60 // 空闲连接超时(秒) +) + +// 配置HTTP客户端 使用 otelhttp 自动注入 trace span +var httpClient = &http.Client{ + Timeout: httpClientTimeout * time.Second, + Transport: httppool.DefaultTransport(), +} + +// SetHTTPClient 替换全局 HTTP 客户端实例 +func SetHTTPClient(c *http.Client) { + httpClient = c +} + +// Request 发送 HTTP 请求,支持自定义 Headers 和 Cookies +func Request(ctx context.Context, method, url string, body io.Reader, headers, cookies map[string]string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, method, url, body) + if err != nil { + return nil, fmt.Errorf(errCreateHTTPRequestFailed, err) + } + + for key, value := range cookies { + req.AddCookie(&http.Cookie{Name: key, Value: value}) //nolint:gosec // client-side cookies do not require server attributes (Secure/HttpOnly) + } + + for key, value := range headers { + req.Header.Set(key, value) + } + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf(errHTTPRequestFailed, url, err) + } + + return resp, nil +} diff --git a/pkg/util/like.go b/backend/pkg/util/like.go similarity index 100% rename from pkg/util/like.go rename to backend/pkg/util/like.go diff --git a/pkg/util/like_test.go b/backend/pkg/util/like_test.go similarity index 100% rename from pkg/util/like_test.go rename to backend/pkg/util/like_test.go diff --git a/pkg/util/network.go b/backend/pkg/util/network.go similarity index 100% rename from pkg/util/network.go rename to backend/pkg/util/network.go diff --git a/pkg/util/network_test.go b/backend/pkg/util/network_test.go similarity index 100% rename from pkg/util/network_test.go rename to backend/pkg/util/network_test.go diff --git a/pkg/util/password.go b/backend/pkg/util/password.go similarity index 94% rename from pkg/util/password.go rename to backend/pkg/util/password.go index 84060e37..55f5e97a 100644 --- a/pkg/util/password.go +++ b/backend/pkg/util/password.go @@ -18,8 +18,10 @@ func HashPassword(password string) (string, error) { return string(hash), nil } -var dummyPasswordHashOnce sync.Once -var dummyPasswordHash string +var ( + dummyPasswordHashOnce sync.Once + dummyPasswordHash string +) func dummyHash() string { dummyPasswordHashOnce.Do(func() { diff --git a/pkg/util/password_test.go b/backend/pkg/util/password_test.go similarity index 100% rename from pkg/util/password_test.go rename to backend/pkg/util/password_test.go diff --git a/pkg/util/slice.go b/backend/pkg/util/slice.go similarity index 100% rename from pkg/util/slice.go rename to backend/pkg/util/slice.go diff --git a/pkg/util/string.go b/backend/pkg/util/string.go similarity index 100% rename from pkg/util/string.go rename to backend/pkg/util/string.go diff --git a/pkg/util/strings.go b/backend/pkg/util/strings.go similarity index 96% rename from pkg/util/strings.go rename to backend/pkg/util/strings.go index 3bfad09d..fa9095bb 100644 --- a/pkg/util/strings.go +++ b/backend/pkg/util/strings.go @@ -1,4 +1,3 @@ -// Copyright 2025 linux.do // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 diff --git a/pkg/util/uuid.go b/backend/pkg/util/uuid.go similarity index 96% rename from pkg/util/uuid.go rename to backend/pkg/util/uuid.go index 1c14b4cb..00fac4b5 100644 --- a/pkg/util/uuid.go +++ b/backend/pkg/util/uuid.go @@ -1,4 +1,3 @@ -// Copyright 2025 linux.do // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 diff --git a/pkg/util/value.go b/backend/pkg/util/value.go similarity index 100% rename from pkg/util/value.go rename to backend/pkg/util/value.go diff --git a/pkg/util/version.go b/backend/pkg/util/version.go similarity index 100% rename from pkg/util/version.go rename to backend/pkg/util/version.go diff --git a/pkg/util/version_compare.go b/backend/pkg/util/version_compare.go similarity index 100% rename from pkg/util/version_compare.go rename to backend/pkg/util/version_compare.go diff --git a/backend/plugins/domain/admin/config.go b/backend/plugins/domain/admin/config.go new file mode 100644 index 00000000..12763bcb --- /dev/null +++ b/backend/plugins/domain/admin/config.go @@ -0,0 +1,12 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package admin + +import "Wavelet/plugins/domain/admin/model" + +// DatabaseConfig aliases model.DatabaseConfig. +type DatabaseConfig = model.DatabaseConfig + +// ClickHouseConfig aliases model.ClickHouseConfig. +type ClickHouseConfig = model.ClickHouseConfig diff --git a/backend/plugins/domain/admin/errs/errs.go b/backend/plugins/domain/admin/errs/errs.go new file mode 100644 index 00000000..d3530f7f --- /dev/null +++ b/backend/plugins/domain/admin/errs/errs.go @@ -0,0 +1,246 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package errs defines error constants, sentinels, and error helpers for the admin domain. +package errs + +import ( + "errors" + "strings" +) + +// 管理后台公共错误常量 +const ( + AdminRequired = "未经授权访问" + TokenAdminRequired = "该访问令牌没有管理员权限,无法访问管理端点" //nolint:gosec // false positive: this is an error message, not hardcoded credentials + InvalidAuthSourceID = "认证源 ID 无效" + ErrInvalidAuthSourceID = "无效的认证源 ID" + InvalidCursorParam = "无效的 cursor 参数" + InvalidTaskExecutionID = "无效的任务执行记录 ID" + InvalidParams = "无效的参数" + InternalServerError = "内部服务器错误" + InvalidScheduleID = "无效的定时任务ID" +) + +// 依赖服务未就绪错误常量 +const ( + DatabaseNotInitialized = "数据库未初始化" + ErrDatabaseServiceNotAvailable = "database service not available" + ErrDatabaseNotInitialized = "database not initialized" + ErrCacheServiceNotInitialized = "cache service is not initialized" + UserServiceUnavailable = "用户服务未就绪" + AuthServiceUnavailable = "认证服务未就绪" + TaskServiceUnavailable = "task service not available" + LogStoreUnavailable = "日志存储服务未初始化" +) + +// 系统配置错误消息常量 +const ( + SystemConfigNotFound = "系统配置不存在" + ConfigKeyRequired = "配置键不能为空" + ConfigValueRequired = "配置值不能为空" + ConfigKeyExists = "配置键已存在" + ProtectedConfigKeyMessage = "该配置项由系统任务管理,禁止手动修改" + StorageDriverSwitchRequiresMigration = "存在存量文件,请通过存储迁移任务切换存储引擎" + ErrConfigIntParseFailed = "配置 %s 的值 '%s' 无法转换为整数: %w" + ErrConfigDecimalParseFailed = "配置 %s 的值 '%s' 无法转换为decimal: %w" + ErrConfigBoolParseFailed = "配置 %s 的值 '%s' 无法转换为布尔值: %w" + ErrParseMenuDisplayConfigFailed = "解析目录显示配置失败: %w" + ErrCheckExistingUploadsFailed = "检查存量文件失败: %w" + ErrParseCurrentStorageConfigFailed = "解析当前存储配置失败: %w" + ErrParseTargetStorageConfigFailed = "解析目标存储配置失败: %w" + ErrSerializeStorageConfigFailed = "序列化存储配置失败: %w" + ErrAutoResolveMigrationTaskFailed = "自动更新迁移任务状态失败: %v" + StorageMigrationTaskType = "storage:migrate" + StorageDriverResolvedResult = "存储配置直接更新,故障迁移任务自动标记为已解决" +) + +// 存储配置校验错误前缀,用于区分参数校验失败与内部错误。 +var storageValidationErrPrefixes = []string{ + "解析", "验证", "初始化测试", "存储连通性", "序列化", "检查存量文件", +} + +// 模板管理相关错误消息常量 +const ( + TemplateNotFound = "模板不存在" + TemplateKeyRequired = "模板标识符不能为空" + TemplateNameRequired = "模板名称不能为空" + TemplateContentRequired = "模板内容不能为空" + TemplateKeyExists = "模板标识符已存在" + SystemTemplateCannotDelete = "系统预置模板不可删除" + SystemTemplateCannotModifyKey = "系统预置模板不可修改标识符" +) + +// 任务调度相关错误消息常量 +const ( + InvalidTaskType = "无效的任务类型" + InvalidTimeRange = "无效的时间范围" + TaskDispatchFailed = "任务下发失败" + UserIDRequired = "用户ID必填" + TaskNotFound = "任务执行记录不存在" + TaskNotRetryable = "该任务不支持重试" + TaskNotFailed = "只有失败的任务才能重试" + TaskMaxRetryExceeded = "已达到最大重试次数" + TaskRetryFailed = "任务重试失败" + ScheduleSaveFailed = "保存定时任务失败" + ScheduleDeleteFailed = "删除定时任务失败" + InvalidCronExpression = "无效的 Cron 表达式" + ScheduleNotFound = "定时任务不存在" + // 任务契约实现返回的远端错误文案,用于状态码归类。 + RemoteTaskNotFoundMsg = "不存在" + RemoteTaskNotFailedMsg = "只有失败的任务" + RemoteTaskNotRetryableMsg = "不支持重试" + RemoteTaskMaxRetryMsg = "已达到最大重试" +) + +// 数据库管理相关错误消息常量 +const ( + InvalidSQLStatement = "SQL 语句不能为空" + ErrOpenDatabaseFileFailed = "无法打开数据库文件" + ErrReadDatabaseFileInfoFailed = "无法读取数据库文件信息" + ErrPgDumpUnavailable = "pg_dump 不可用,请确保服务器已安装 PostgreSQL 客户端工具" +) + +// 访问日志相关错误消息常量 +const ( + ErrQueryUserFailed = "查询用户信息失败: %w" + ErrQueryAccessTrendFailed = "查询访问趋势失败: " +) + +// 日志库切换相关错误消息常量 +const ( + ErrReadLogDatabaseFailed = "读取日志主库失败: %w" + ErrLogDatabaseEmpty = "日志主库配置为空" + ErrSameLogTarget = "目标日志库与当前日志库相同,无需迁移" + ErrClickHouseNotEnabled = "ClickHouse 未启用,无法迁移到 ClickHouse" + ErrPostgresNotEnabled = "PostgreSQL 未启用(当前主库为 SQLite),无法迁移到 PostgreSQL" + ErrSQLiteNotAllowedAsLogDB = "当前主库为 PostgreSQL,日志库不能设置为 SQLite" +) + +// 应用更新相关错误消息常量 +const ( + ErrInvalidRepository = "上游仓库地址无效" + ErrReleaseRequestFailed = "获取上游版本失败" + ErrReleaseResponseInvalid = "上游版本响应无效" + ErrNoCompatibleRelease = "未找到兼容的 Release" + ErrNoCompatibleAsset = "未找到当前系统对应的 Release 资产" + ErrDevelopmentBuild = "开发版本无法执行自动升级" + ErrAlreadyUpToDate = "当前已是最新版本" + ErrUpgradeAlreadyRunning = "已有升级任务正在执行" + ErrAutomaticUpgradeBlocked = "当前平台暂不支持自动替换二进制" + ErrReleaseAssetSizeInvalid = "release 资产大小无效: %d" + ErrCreateUpgradeRequestFailed = "创建升级下载请求失败: %w" + ErrDownloadUpgradeAssetFailed = "下载升级资产失败: %w" + ErrUpgradeAssetHTTPFailed = "下载升级资产失败: HTTP %d" + ErrCreateUpgradeArchiveFailed = "创建升级归档失败: %w" + ErrWriteUpgradeArchiveFailed = "写入升级归档失败: %w" + ErrCloseUpgradeArchiveFailed = "关闭升级归档失败: %w" + ErrUpgradeArchiveSizeMismatch = "升级归档大小不匹配: got %d, want %d" + ErrArchiveContainsIllegalPath = "归档包含非法路径: %s" + ErrArchivePathOutOfDestination = "归档路径越界: %s" + ErrExtractedBinaryTooLarge = "解压后的程序文件超过大小限制" + ErrLocateExecutableFailed = "定位当前程序失败: %w" + ErrResolveExecutablePathFailed = "解析当前程序路径失败: %w" + ErrCreateUpgradeDirFailed = "创建升级目录失败: %w" + ErrExtractUpgradeAssetFailed = "解压升级资产失败: %w" +) + +// 用户管理(管理员视角)错误消息常量 +const ( + UserNotFound = "用户不存在" + CannotDisable = "不能禁用管理员账号" + CannotDelete = "不能删除管理员账号" + CannotDeleteSelf = "不能删除当前登录账号" + UsernameRequired = "用户名不能为空" + EmailRequired = "邮箱不能为空" + PasswordTooShort = "密码长度不能少于 8 位" //nolint:gosec // error message, not hardcoded credentials + UsernameExists = "用户名已存在" + EmailExists = "邮箱已被使用" + CannotRevokeSelfAdmin = "不能取消自身的管理员权限" + UpdateUserFailed = "更新用户状态失败" + DeleteUserFailed = "删除用户失败" + UpdateUserInfoFailed = "更新用户信息失败" + ListAdminUsersFailed = "获取用户列表失败" +) + +// 认证源管理相关错误消息常量 +const ( + ListAuthSourcesFailed = "获取认证源列表失败" + CreateAuthSourceFailed = "创建认证源失败: " + ToggleAuthSourceFailed = "切换认证源状态失败: " + DeleteAuthSourceFailed = "删除认证源失败: " +) + +// 层边界哨兵错误:Service/Repository 层返回、Handler 层据以选择信封状态码。 +var ( + // ErrDatabaseUninitialized 表示数据库服务尚未注入。 + ErrDatabaseUninitialized = errors.New(DatabaseNotInitialized) + // ErrSystemConfigNotFound 表示系统配置键不存在。 + ErrSystemConfigNotFound = errors.New(SystemConfigNotFound) + // ErrConfigKeyExists 表示系统配置键已存在。 + ErrConfigKeyExists = errors.New(ConfigKeyExists) + // ErrProtectedConfigKey 表示配置键由系统任务托管,禁止手动修改。 + ErrProtectedConfigKey = errors.New(ProtectedConfigKeyMessage) + // ErrTemplateNotFound 表示模板标识符不存在。 + ErrTemplateNotFound = errors.New(TemplateNotFound) + // ErrTemplateKeyExists 表示模板标识符已被占用。 + ErrTemplateKeyExists = errors.New(TemplateKeyExists) + // ErrSystemTemplateCannotDelete 表示系统预置模板不可删除。 + ErrSystemTemplateCannotDelete = errors.New(SystemTemplateCannotDelete) + // ErrUserNotFound 表示目标用户不存在。 + ErrUserNotFound = errors.New(UserNotFound) + // ErrUserServiceUnavailable 表示用户契约服务尚未注入。 + ErrUserServiceUnavailable = errors.New(UserServiceUnavailable) + // ErrAuthServiceUnavailable 表示认证契约服务尚未注入。 + ErrAuthServiceUnavailable = errors.New(AuthServiceUnavailable) + // ErrTaskServiceUnavailable 表示任务契约服务尚未注入。 + ErrTaskServiceUnavailable = errors.New(TaskServiceUnavailable) + // ErrLogStoreUnavailable 表示日志分析契约服务尚未注入。 + ErrLogStoreUnavailable = errors.New(LogStoreUnavailable) + // ErrScheduleNotFound 表示定时任务不存在。 + ErrScheduleNotFound = errors.New(ScheduleNotFound) + // ErrInvalidCronExpression 表示 Cron 表达式无法解析。 + ErrInvalidCronExpression = errors.New(InvalidCronExpression) + // ErrInvalidTaskType 表示任务类型未在任务注册表中声明。 + ErrInvalidTaskType = errors.New(InvalidTaskType) +) + +// InvalidInputError marks a failure caused by caller supplied content (an unusable SQL +// statement, a rejected task payload, ...). It carries no HTTP semantics; the handler +// layer decides how such errors surface to the client. +type InvalidInputError struct { + Msg string +} + +func (e *InvalidInputError) Error() string { return e.Msg } + +// NewInvalidInputError builds an invalid input failure preserving the original message. +func NewInvalidInputError(msg string) error { + return &InvalidInputError{Msg: msg} +} + +// AsInvalidInput reports whether err was caused by rejected caller input. +func AsInvalidInput(err error) (string, bool) { + var target *InvalidInputError + if errors.As(err, &target) { + return target.Msg, true + } + return "", false +} + +// IsStorageConfigValidationError 判定错误是否属于存储配置参数校验失败。 +func IsStorageConfigValidationError(err error) bool { + if err == nil { + return false + } + msg := err.Error() + if msg == StorageDriverSwitchRequiresMigration { + return true + } + for _, prefix := range storageValidationErrPrefixes { + if strings.HasPrefix(msg, prefix) { + return true + } + } + return false +} diff --git a/backend/plugins/domain/admin/handler/auth_source.go b/backend/plugins/domain/admin/handler/auth_source.go new file mode 100644 index 00000000..27eadc46 --- /dev/null +++ b/backend/plugins/domain/admin/handler/auth_source.go @@ -0,0 +1,106 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package handler + +import ( + "Wavelet/core/contracts" + "Wavelet/pkg/response" + "Wavelet/plugins/domain/admin/errs" + "Wavelet/plugins/domain/admin/service" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" +) + +// ListAuthSources lists all configured authentication sources. +func ListAuthSources(c *gin.Context) { + views, err := service.ListAuthSources(c.Request.Context()) + if err != nil { + response.AbortInternal(c, err.Error()) + return + } + + c.JSON(http.StatusOK, response.OK(views)) +} + +// CreateAuthSource creates a new authentication source. +func CreateAuthSource(c *gin.Context) { + var source contracts.AuthSourceDTO + if err := c.ShouldBindJSON(&source); err != nil { + response.AbortBadRequest(c, errs.InvalidParams) + return + } + + created, err := service.CreateAuthSource(c.Request.Context(), source) + if err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + + c.JSON(http.StatusOK, response.OK(created)) +} + +// UpdateAuthSource updates an authentication source. +func UpdateAuthSource(c *gin.Context) { + id, ok := parseAuthSourceID(c) + if !ok { + return + } + + var req contracts.AuthSourceDTO + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortBadRequest(c, errs.InvalidParams) + return + } + + updated, err := service.UpdateAuthSource(c.Request.Context(), id, req) + if err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + + c.JSON(http.StatusOK, response.OK(updated)) +} + +// ToggleAuthSource toggles the active state of an auth source. +func ToggleAuthSource(c *gin.Context) { + id, ok := parseAuthSourceID(c) + if !ok { + return + } + + toggled, err := service.ToggleAuthSource(c.Request.Context(), id) + if err != nil { + response.AbortInternal(c, err.Error()) + return + } + + c.JSON(http.StatusOK, response.OK(gin.H{"is_active": toggled.IsActive})) +} + +// DeleteAuthSource deletes an authentication source. +func DeleteAuthSource(c *gin.Context) { + id, ok := parseAuthSourceID(c) + if !ok { + return + } + + if err := service.DeleteAuthSource(c.Request.Context(), id); err != nil { + response.AbortInternal(c, err.Error()) + return + } + + c.JSON(http.StatusOK, response.OKNil()) +} + +// parseAuthSourceID reads the numeric auth source path parameter. +func parseAuthSourceID(c *gin.Context) (uint64, bool) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.AbortBadRequest(c, errs.ErrInvalidAuthSourceID) + return 0, false + } + return id, true +} diff --git a/backend/plugins/domain/admin/handler/cache.go b/backend/plugins/domain/admin/handler/cache.go new file mode 100644 index 00000000..7b2d8374 --- /dev/null +++ b/backend/plugins/domain/admin/handler/cache.go @@ -0,0 +1,76 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package handler + +import ( + "Wavelet/pkg/response" + "Wavelet/plugins/domain/admin/model" + "Wavelet/plugins/domain/admin/service" + "net/http" + + "github.com/gin-gonic/gin" +) + +// GetCacheStatus 获取磁盘缓存状态与当前统计数据 +// @Summary 获取缓存状态 +// @Description 获取当前系统磁盘缓存的使用情况(已占用字节、Key 数量等)与策略配置 +// @Tags admin +// @Produce json +// @Security SessionCookie +// @Success 200 {object} response.Any{data=disk.Status} "获取成功" +// @Failure 401 {object} response.Any "未登录" +// @Failure 403 {object} response.Any "无管理员权限" +// @Failure 500 {object} response.Any "内部错误" +// @Router /api/v1/admin/cache/status [get] +func GetCacheStatus(c *gin.Context) { + c.JSON(http.StatusOK, response.OK(service.DiskCacheStatus())) +} + +// UpdateCacheConfig 更新磁盘缓存策略配置 +// @Summary 更新缓存配置 +// @Description 更改磁盘缓存最大容量限制、文件生存时间(TTL)以及是否启用 LRU 淘汰淘汰算法,并进行热更新 +// @Tags admin +// @Accept json +// @Produce json +// @Param request body model.UpdateCacheConfigRequest true "缓存配置请求体" +// @Security SessionCookie +// @Success 200 {object} response.Any "更新成功" +// @Failure 400 {object} response.Any "参数错误" +// @Failure 401 {object} response.Any "未登录" +// @Failure 403 {object} response.Any "无管理员权限" +// @Failure 500 {object} response.Any "服务内部错误" +// @Router /api/v1/admin/cache/config [post] +func UpdateCacheConfig(c *gin.Context) { + var req model.UpdateCacheConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + + if err := service.UpdateDiskCachePolicy(c.Request.Context(), req); err != nil { + response.AbortInternal(c, err.Error()) + return + } + + c.JSON(http.StatusOK, response.OKNil()) +} + +// ClearCache 一键清空所有磁盘缓存数据 +// @Summary 清空缓存 +// @Description 清除系统磁盘缓存目录中的所有临时文件,并重置缓存容量和 Key 追踪数据 +// @Tags admin +// @Produce json +// @Security SessionCookie +// @Success 200 {object} response.Any "清理成功" +// @Failure 401 {object} response.Any "未登录" +// @Failure 403 {object} response.Any "无管理员权限" +// @Failure 500 {object} response.Any "服务内部错误" +// @Router /api/v1/admin/cache/clear [post] +func ClearCache(c *gin.Context) { + if err := service.ClearDiskCache(); err != nil { + response.AbortInternal(c, err.Error()) + return + } + c.JSON(http.StatusOK, response.OKNil()) +} diff --git a/backend/plugins/domain/admin/handler/config.go b/backend/plugins/domain/admin/handler/config.go new file mode 100644 index 00000000..a431c874 --- /dev/null +++ b/backend/plugins/domain/admin/handler/config.go @@ -0,0 +1,187 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package handler + +import ( + "Wavelet/pkg/response" + "Wavelet/plugins/domain/admin/errs" + "Wavelet/plugins/domain/admin/model" + "Wavelet/plugins/domain/admin/service" + "errors" + "net/http" + + "github.com/gin-gonic/gin" +) + +// GetPublicConfig 获取公共配置 +// @Summary 获取公共配置 +// @Description 返回系统配置表中 visibility 为 1 的配置键值集合 +// @Tags config +// @Accept json +// @Produce json +// @Success 200 {object} response.Any +// @Router /api/v1/config/public [get] +func GetPublicConfig(c *gin.Context) { + resp, err := service.PublicSystemConfigs(c.Request.Context()) + if err != nil { + response.AbortInternal(c, err.Error()) + return + } + + c.JSON(http.StatusOK, response.OK(resp)) +} + +// GetRobotsTXT 动态生成 robots.txt +// @Summary 获取 robots.txt +// @Description 根据系统配置决定是否允许搜索引擎检索,并返回相应的 robots.txt 文件内容 +// @Tags config +// @Produce text/plain +// @Success 200 {string} string "robots.txt 内容" +// @Router /robots.txt [get] +func GetRobotsTXT(c *gin.Context) { + c.Data(http.StatusOK, "text/plain; charset=utf-8", []byte(service.RobotsTxtBody(c.Request.Context()))) +} + +// CreateSystemConfig 创建系统配置 +// @Summary 创建系统配置 +// @Description 创建一条新的系统配置项,配置键不可重复,同时将新配置同步到 Redis,需要管理员权限 +// @Tags admin +// @Accept json +// @Produce json +// @Security SessionCookie +// @Param request body model.CreateSystemConfigRequest true "创建请求参数" +// @Success 200 {object} response.Any{data=string} "创建成功" +// @Failure 400 {object} response.Any "参数错误或配置键已存在" +// @Failure 401 {object} response.Any "未登录" +// @Failure 403 {object} response.Any "无管理员权限" +// @Failure 500 {object} response.Any "内部错误" +// @Router /api/v1/admin/system-configs [post] +func CreateSystemConfig(c *gin.Context) { + var req model.CreateSystemConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + + if err := service.CreateAdminSystemConfig(c.Request.Context(), req); err != nil { + if errors.Is(err, errs.ErrProtectedConfigKey) || errors.Is(err, errs.ErrConfigKeyExists) { + response.AbortBadRequest(c, err.Error()) + return + } + response.AbortInternal(c, err.Error()) + return + } + + c.JSON(http.StatusOK, response.OKNil()) +} + +// ListSystemConfigs 获取系统配置列表 +// @Summary 获取系统配置列表 +// @Description 返回所有系统配置列表,支持按配置类型(system/business)过滤,需要管理员权限 +// @Tags admin +// @Produce json +// @Security SessionCookie +// @Param type query string false "配置类型(system/business)" +// @Success 200 {object} response.Any{data=[]model.SystemConfig} "系统配置列表" +// @Failure 401 {object} response.Any "未登录" +// @Failure 403 {object} response.Any "无管理员权限" +// @Failure 500 {object} response.Any "内部错误" +// @Router /api/v1/admin/system-configs [get] +func ListSystemConfigs(c *gin.Context) { + configs, err := service.ListAdminSystemConfigs(c.Request.Context(), c.Query("type")) + if err != nil { + response.AbortInternal(c, err.Error()) + return + } + + c.JSON(http.StatusOK, response.OK(configs)) +} + +// GetSystemConfig 获取单个系统配置 +// @Summary 获取单个系统配置 +// @Description 根据配置键获取对应的系统配置详情,需要管理员权限 +// @Tags admin +// @Produce json +// @Security SessionCookie +// @Param key path string true "配置键" +// @Success 200 {object} response.Any{data=model.SystemConfig} "系统配置详情" +// @Failure 401 {object} response.Any "未登录" +// @Failure 403 {object} response.Any "无管理员权限" +// @Failure 404 {object} response.Any "配置不存在" +// @Failure 500 {object} response.Any "内部错误" +// @Router /api/v1/admin/system-configs/{key} [get] +func GetSystemConfig(c *gin.Context) { + config, err := service.GetAdminSystemConfig(c.Request.Context(), c.Param("key")) + if err != nil { + if errors.Is(err, errs.ErrSystemConfigNotFound) { + response.AbortNotFound(c, errs.SystemConfigNotFound) + return + } + response.AbortInternal(c, err.Error()) + return + } + + c.JSON(http.StatusOK, response.OK(config)) +} + +// UpdateSystemConfig 更新系统配置 +// @Summary 更新系统配置 +// @Description 根据配置键更新对应的配置内容,同时将更新同步到 Redis,需要管理员权限 +// @Tags admin +// @Accept json +// @Produce json +// @Security SessionCookie +// @Param key path string true "配置键" +// @Param request body model.UpdateSystemConfigRequest true "更新请求参数" +// @Success 200 {object} response.Any{data=string} "更新成功" +// @Failure 400 {object} response.Any "参数错误" +// @Failure 401 {object} response.Any "未登录" +// @Failure 403 {object} response.Any "无管理员权限" +// @Failure 404 {object} response.Any "配置不存在" +// @Failure 500 {object} response.Any "内部错误" +// @Router /api/v1/admin/system-configs/{key} [put] +func UpdateSystemConfig(c *gin.Context) { + var req model.UpdateSystemConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + + key := c.Param("key") + if err := service.UpdateAdminSystemConfig(c.Request.Context(), key, req); err != nil { + if errors.Is(err, errs.ErrSystemConfigNotFound) { + response.AbortNotFound(c, errs.SystemConfigNotFound) + return + } + if errors.Is(err, errs.ErrProtectedConfigKey) || errs.IsStorageConfigValidationError(err) { + response.AbortBadRequest(c, err.Error()) + return + } + response.AbortInternal(c, err.Error()) + return + } + + c.JSON(http.StatusOK, response.OKNil()) +} + +// TestSMTP 测试 SMTP 邮件发送 +// @Summary 测试 SMTP 邮件发送 +// @Description 使用传入的配置进行 SMTP 邮件发送测试,支持使用 ****** 占位符使用保存的数据库密码 +// @Tags admin +// @Accept json +// @Produce json +// @Security SessionCookie +// @Param request body model.TestSMTPRequest true "测试请求参数" +// @Success 200 {object} response.Any{data=model.TestSMTPResponse} "测试执行完毕" +// @Failure 400 {object} response.Any "参数错误" +// @Router /api/v1/admin/system-configs/smtp/test [post] +func TestSMTP(c *gin.Context) { + var req model.TestSMTPRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + + c.JSON(http.StatusOK, response.OK(service.TestSMTP(c.Request.Context(), req))) +} diff --git a/backend/plugins/domain/admin/handler/db.go b/backend/plugins/domain/admin/handler/db.go new file mode 100644 index 00000000..e6d986f8 --- /dev/null +++ b/backend/plugins/domain/admin/handler/db.go @@ -0,0 +1,190 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package handler + +import ( + "Wavelet/pkg/logger" + "Wavelet/pkg/response" + "Wavelet/plugins/domain/admin/errs" + "Wavelet/plugins/domain/admin/model" + "Wavelet/plugins/domain/admin/service" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +// GetDBOverview 获取数据库运行概览 +// @Summary 获取数据库运行概览 +// @Description 获取数据库类型、版本、名称、文件大小、表数量及当前连接数,需要管理员权限 +// @Tags admin +// @Produce json +// @Security SessionCookie +// @Success 200 {object} response.Any{data=model.DBOverviewResponse} "获取成功" +// @Failure 401 {object} response.Any "未登录" +// @Failure 403 {object} response.Any "无管理员权限" +// @Failure 500 {object} response.Any "内部错误" +// @Router /api/v1/admin/db-manage/overview [get] +func GetDBOverview(c *gin.Context) { + overview, err := service.DatabaseOverview(c.Request.Context()) + if err != nil { + response.AbortInternal(c, err.Error()) + return + } + + c.JSON(http.StatusOK, response.OK(overview)) +} + +// ListDBTables 获取数据库所有表名 +// @Summary 获取数据库所有表名 +// @Description 返回当前数据库的所有用户自定义表名称列表,需要管理员权限 +// @Tags admin +// @Produce json +// @Security SessionCookie +// @Success 200 {object} response.Any{data=[]string} "获取成功" +// @Failure 401 {object} response.Any "未登录" +// @Failure 403 {object} response.Any "无管理员权限" +// @Failure 500 {object} response.Any "内部错误" +// @Router /api/v1/admin/db-manage/tables [get] +func ListDBTables(c *gin.Context) { + tables, err := service.DatabaseTableNames(c.Request.Context()) + if err != nil { + response.AbortInternal(c, err.Error()) + return + } + + c.JSON(http.StatusOK, response.OK(tables)) +} + +// GetDBTableData 获取数据表 data +func GetDBTableData(c *gin.Context) { + var req model.GetTableDataRequest + if err := c.ShouldBindQuery(&req); err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + + data, err := service.DatabaseTableData(c.Request.Context(), req) + if err != nil { + if msg, ok := errs.AsInvalidInput(err); ok { + response.AbortBadRequest(c, msg) + return + } + response.AbortInternal(c, err.Error()) + return + } + + c.JSON(http.StatusOK, response.OK(data)) +} + +// ExecuteSQL 执行 SQL 查询 +// @Summary 执行 SQL 查询 +// @Description 在当前数据库中执行任意自定义 SQL,如果是查询语句将返回格式化后的列与数据集,否则返回受影响行数,需要管理员权限 +// @Tags admin +// @Accept json +// @Produce json +// @Security SessionCookie +// @Param request body model.ExecuteSQLRequest true "SQL 请求参数" +// @Success 200 {object} response.Any{data=model.ExecuteSQLResponse} "执行完毕" +// @Failure 400 {object} response.Any "SQL 语句错误" +// @Failure 401 {object} response.Any "未登录" +// @Failure 403 {object} response.Any "无管理员权限" +// @Failure 500 {object} response.Any "内部错误" +// @Router /api/v1/admin/db-manage/query [post] +func ExecuteSQL(c *gin.Context) { + var req model.ExecuteSQLRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + + trimmedSQL := strings.TrimSpace(req.SQL) + if trimmedSQL == "" { + response.AbortBadRequest(c, errs.InvalidSQLStatement) + return + } + + resp, err := service.ExecuteCustomSQL(c.Request.Context(), trimmedSQL) + if err != nil { + if errors.Is(err, errs.ErrDatabaseUninitialized) { + response.AbortInternal(c, err.Error()) + return + } + response.AbortBadRequest(c, err.Error()) + return + } + + c.JSON(http.StatusOK, response.OK(resp)) +} + +// GetDatabaseInfo 获取当前数据库类型及版本信息 +// @Summary 获取数据库信息 +// @Description 返回当前使用的数据库类型(sqlite/postgres)、名称/路径及版本字符串,需要管理员权限 +// @Tags admin +// @Produce json +// @Security SessionCookie +// @Success 200 {object} response.Any{data=model.DatabaseInfoResponse} "获取成功" +// @Failure 401 {object} response.Any "未登录" +// @Failure 403 {object} response.Any "无管理员权限" +// @Router /api/v1/admin/db-info [get] +func GetDatabaseInfo(c *gin.Context) { + c.JSON(http.StatusOK, response.OK(service.DatabaseInfo(c.Request.Context()))) +} + +// ExportDatabase 导出数据库 +// @Summary 导出数据库 +// @Description SQLite 时直接下载 .db 文件;PostgreSQL 时执行 pg_dump 并流式下载 .sql 文件,需要管理员权限 +// @Tags admin +// @Produce application/octet-stream +// @Security SessionCookie +// @Success 200 {file} binary "数据库文件" +// @Failure 401 {object} response.Any "未登录" +// @Failure 403 {object} response.Any "无管理员权限" +// @Failure 500 {object} response.Any "导出失败" +// @Router /api/v1/admin/db-export [get] +func ExportDatabase(c *gin.Context) { + if !service.GetDBConfig().Enabled { + exportSQLite(c) + } else { + exportPostgres(c) + } +} + +func exportSQLite(c *gin.Context) { + f, fi, err := service.OpenSQLiteExportFile() + if err != nil { + response.AbortInternal(c, err.Error()) + return + } + defer func() { + _ = f.Close() + }() + + c.Header("Content-Disposition", `attachment; filename="wavelet.db"`) + c.Header("Content-Type", "application/octet-stream") + c.Header("Content-Length", fmt.Sprintf("%d", fi.Size())) + c.Status(http.StatusOK) + http.ServeContent(c.Writer, c.Request, "wavelet.db", fi.ModTime(), f) +} + +func exportPostgres(c *gin.Context) { + cmd, fileName, err := service.NewPgDumpCommand(c.Request.Context()) + if err != nil { + response.AbortInternal(c, err.Error()) + return + } + + c.Header("Content-Disposition", `attachment; filename="`+fileName+`"`) + c.Header("Content-Type", "application/octet-stream") + c.Status(http.StatusOK) + + cmd.Stdout = c.Writer + cmd.Stderr = nil + + if err := cmd.Run(); err != nil { + logger.ErrorF(c.Request.Context(), "[db-export] pg_dump failed: %v", err) + } +} diff --git a/backend/plugins/domain/admin/handler/logs.go b/backend/plugins/domain/admin/handler/logs.go new file mode 100644 index 00000000..aac63a14 --- /dev/null +++ b/backend/plugins/domain/admin/handler/logs.go @@ -0,0 +1,205 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package handler + +import ( + "Wavelet/pkg/logger" + "Wavelet/pkg/response" + "Wavelet/pkg/util" + "Wavelet/plugins/domain/admin/errs" + "Wavelet/plugins/domain/admin/model" + "Wavelet/plugins/domain/admin/service" + "encoding/json" + "errors" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +const ( + defaultLimit = 200 + maxLimit = 500 + maxPageSize = 100 +) + +// wsMessage WebSocket 消息格式 +type wsMessage struct { + Type string `json:"type"` // "log" | "error" + Data json.RawMessage `json:"data"` +} + +// GetLogs 获取历史日志 +// @Summary 获取系统日志 +// @Description 分页获取系统历史日志,cursor=0 获取最新日志,cursor>0 获取更早日志 +// @Tags admin +// @Produce json +// @Security SessionCookie +// @Param cursor query int false "日志游标,0=获取最新" default(0) +// @Param limit query int false "每页条数" default(200) +// @Success 200 {object} response.Any{data=model.LogsResponse} "日志列表" +// @Failure 401 {object} response.Any "未登录" +// @Failure 403 {object} response.Any "无管理员权限" +// @Router /api/v1/admin/logs [get] +func GetLogs(c *gin.Context) { + cursorStr := c.DefaultQuery("cursor", "0") + limitStr := c.DefaultQuery("limit", "200") + + var cursor, limit int + if err := parsePositiveInt(cursorStr, &cursor); err != nil { + response.AbortWithError(c, http.StatusBadRequest, errs.InvalidCursorParam) + return + } + if err := parsePositiveInt(limitStr, &limit); err != nil || limit <= 0 { + limit = defaultLimit + } + if limit > maxLimit { + limit = maxLimit + } + + c.JSON(http.StatusOK, response.OK(service.RecentSystemLogs(cursor, limit))) +} + +// HandleLogWebSocket WebSocket 端点,实时推送系统日志 +// @Summary 系统日志实时推送 +// @Description 通过 WebSocket 实时推送系统日志,需要管理员权限 +// @Tags admin +// @Router /api/v1/admin/logs/ws [get] +func HandleLogWebSocket(c *gin.Context) { + upgrader := getUpgrader() + + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + return + } + defer func() { _ = conn.Close() }() + + ch := logger.GlobalRingBuffer.Subscribe() + defer logger.GlobalRingBuffer.Unsubscribe(ch) + + done := make(chan struct{}) + util.Go(func() { + defer close(done) + for { + _, _, err := conn.ReadMessage() + if err != nil { + return + } + } + }) + + for { + select { + case <-done: + return + case entry, ok := <-ch: + if !ok { + return + } + data, _ := json.Marshal(entry) + msg := wsMessage{Type: "log", Data: data} + payload, _ := json.Marshal(msg) + if err := conn.WriteMessage(1, payload); err != nil { + return + } + } + } +} + +// GetAccessLogs 获取 ClickHouse 异步采集的访问日志 +// @Summary 获取用户访问日志 +// @Description 分页并按照用户、接口路径、时间范围等维度检索用户访问日志列表(需要管理员权限) +// @Tags admin +// @Produce json +// @Security SessionCookie +// @Param page query int false "页码" default(1) +// @Param page_size query int false "每页条数" default(20) +// @Param username query string false "用户名模糊搜索" +// @Param path query string false "接口路径模糊搜索" +// @Param start_time query string false "起始时间(RFC3339 或 YYYY-MM-DD HH:MM:SS)" +// @Param end_time query string false "结束时间(RFC3339 或 YYYY-MM-DD HH:MM:SS)" +// @Success 200 {object} response.Any{data=model.AccessLogsResponse} "访问日志列表" +// @Failure 400 {object} response.Any "参数错误" +// @Failure 401 {object} response.Any "未登录" +// @Failure 403 {object} response.Any "无管理员权限" +// @Failure 500 {object} response.Any "内部错误" +// @Router /api/v1/admin/logs/access [get] +func GetAccessLogs(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + if page < 1 { + page = 1 + } + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + if pageSize < 1 { + pageSize = 20 + } + if pageSize > maxPageSize { + pageSize = maxPageSize + } + + resp, err := service.AccessLogs(c.Request.Context(), model.AccessLogQuery{ + Username: c.Query("username"), + Path: c.Query("path"), + StartTime: c.Query("start_time"), + EndTime: c.Query("end_time"), + Page: page, + PageSize: pageSize, + }) + if err != nil { + response.AbortWithError(c, http.StatusInternalServerError, err.Error()) + return + } + + c.JSON(http.StatusOK, response.OK(resp)) +} + +// GetLogsAnalytics 获取 ClickHouse 访问日志图表聚合指标 +// @Summary 获取访问日志分析数据 +// @Description 聚合统计最近 7 天的每日访问趋势、浏览器分布以及前 10 名最活跃用户排行(需要管理员权限) +// @Tags admin +// @Produce json +// @Security SessionCookie +// @Success 200 {object} response.Any{data=model.LogsAnalyticsResponse} "分析统计数据" +// @Failure 500 {object} response.Any "内部错误" +// @Failure 401 {object} response.Any "未登录" +// @Failure 403 {object} response.Any "无管理员权限" +// @Router /api/v1/admin/logs/analytics [get] +func GetLogsAnalytics(c *gin.Context) { + resp, err := service.AccessLogAnalytics(c.Request.Context()) + if err != nil { + response.AbortWithError(c, http.StatusInternalServerError, err.Error()) + return + } + + c.JSON(http.StatusOK, response.OK(resp)) +} + +func getUpgrader() *websocket.Upgrader { + return &websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return service.IsAllowedLogOrigin(r.Context(), r.Header.Get("Origin"), r.Host) + }, + } +} + +// errNegativeParam 表示查询参数解析出了负数。 +var errNegativeParam = errors.New("parameter must not be negative") + +// parsePositiveInt 解析非负整数查询参数;返回错误时 result 保持调用前的值。 +func parsePositiveInt(s string, result *int) error { + if s == "" { + *result = 0 + return nil + } + n, err := strconv.Atoi(s) + if err != nil { + return err + } + if n < 0 { + return errNegativeParam + } + *result = n + return nil +} diff --git a/backend/plugins/domain/admin/handler/logs_test.go b/backend/plugins/domain/admin/handler/logs_test.go new file mode 100644 index 00000000..9f97ea78 --- /dev/null +++ b/backend/plugins/domain/admin/handler/logs_test.go @@ -0,0 +1,41 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package handler + +import ( + "testing" +) + +func TestParsePositiveInt(t *testing.T) { + const untouched = 77 + + tests := []struct { + name string + input string + want int + wantErr bool + }{ + {name: "empty means zero", input: "", want: 0}, + {name: "zero accepted", input: "0", want: 0}, + {name: "positive accepted", input: "42", want: 42}, + {name: "negative rejected", input: "-5", want: untouched, wantErr: true}, + {name: "oversized rejected", input: "99999999999999999999", want: untouched, wantErr: true}, + {name: "non numeric rejected", input: "abc", want: untouched, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := untouched + err := parsePositiveInt(tt.input, &got) + if (err != nil) != tt.wantErr { + t.Fatalf("parsePositiveInt(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + } + if got != tt.want { + t.Errorf("parsePositiveInt(%q) left result %d, want %d", tt.input, got, tt.want) + } + }) + } +} diff --git a/backend/plugins/domain/admin/handler/middleware.go b/backend/plugins/domain/admin/handler/middleware.go new file mode 100644 index 00000000..a3bd770d --- /dev/null +++ b/backend/plugins/domain/admin/handler/middleware.go @@ -0,0 +1,46 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package handler + +import ( + "Wavelet/core/contracts" + "Wavelet/pkg/ginutil" + "Wavelet/pkg/logger" + "Wavelet/pkg/response" + "Wavelet/pkg/trace" + "Wavelet/plugins/domain/admin/errs" + + "github.com/gin-gonic/gin" +) + +// LoginAdminRequired 返回管理员权限校验中间件 +func LoginAdminRequired() gin.HandlerFunc { + return func(c *gin.Context) { + ctx, span := trace.Start(c.Request.Context(), "LoginAdminRequired") + defer span.End() + + user, _ := ginutil.GetFromContext[*contracts.UserDTO](c, contracts.AuthUserObjKey) + if user == nil { + response.AbortNotFound(c, errs.AdminRequired) + return + } + + // 如果是通过 Access Token 鉴权,需要检查令牌本身是否具有管理员权限 + if tokenAuth, _ := ginutil.GetFromContext[bool](c, contracts.AuthTokenAuthKey); tokenAuth { + tokenAdmin, _ := ginutil.GetFromContext[bool](c, contracts.AuthTokenAdminKey) + if !tokenAdmin { + response.AbortNotFound(c, errs.TokenAdminRequired) + return + } + } + + if !user.IsAdmin { + response.AbortNotFound(c, errs.AdminRequired) + return + } + + logger.InfoF(ctx, "[LoginAdminRequired] %d %s", user.ID, user.Username) + c.Next() + } +} diff --git a/backend/plugins/domain/admin/handler/router.go b/backend/plugins/domain/admin/handler/router.go new file mode 100644 index 00000000..00c6bb19 --- /dev/null +++ b/backend/plugins/domain/admin/handler/router.go @@ -0,0 +1,122 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package handler provides HTTP routing and handlers for the admin domain. +package handler + +import ( + "Wavelet/core/extpoints" +) + +// RegisterRoutes mounts all admin console endpoints under the provided admin router group. +func RegisterRoutes(adminRouter extpoints.RouterExtension) { + // Status & Diagnostics + adminRouter.GET("/status", GetSystemStatus) + adminRouter.GET("/status/log-database", GetLogDatabaseStatus) + adminRouter.GET("/db-info", GetDatabaseInfo) + adminRouter.GET("/db-export", ExportDatabase) + + // DB Management + dbGroup := adminRouter.Group("/db-manage") + { + dbGroup.GET("/overview", GetDBOverview) + dbGroup.GET("/tables", ListDBTables) + dbGroup.GET("/table-data", GetDBTableData) + dbGroup.POST("/query", ExecuteSQL) + } + + // Cache Management + cacheGroup := adminRouter.Group("/cache") + { + cacheGroup.GET("/status", GetCacheStatus) + cacheGroup.POST("/config", UpdateCacheConfig) + cacheGroup.POST("/clear", ClearCache) + } + + // Updater + updateGroup := adminRouter.Group("/update") + { + updateGroup.GET("", GetUpdateStatus) + updateGroup.POST("/apply", ApplyUpdate) + } + + // Logs + logsGroup := adminRouter.Group("/logs") + { + logsGroup.GET("", GetLogs) + logsGroup.GET("/access", GetAccessLogs) + logsGroup.GET("/analytics", GetLogsAnalytics) + logsGroup.GET("/ws", HandleLogWebSocket) + } + + // Users + usersGroup := adminRouter.Group("/users") + { + usersGroup.GET("", ListUsers) + usersGroup.POST("", CreateUser) + usersGroup.GET("/:id", GetUser) + usersGroup.PUT("/:id/status", UpdateUserStatus) + usersGroup.PUT("/:id", UpdateUser) + usersGroup.DELETE("/:id", DeleteUser) + } + + // Auth Sources + authSourcesGroup := adminRouter.Group("/auth-sources") + { + authSourcesGroup.GET("", ListAuthSources) + authSourcesGroup.POST("", CreateAuthSource) + authSourcesGroup.PUT("/:id", UpdateAuthSource) + authSourcesGroup.PUT("/:id/toggle", ToggleAuthSource) + authSourcesGroup.DELETE("/:id", DeleteAuthSource) + } + + // System Configs + configGroup := adminRouter.Group("/system-configs") + { + configGroup.GET("", ListSystemConfigs) + configGroup.POST("", CreateSystemConfig) + configGroup.POST("/smtp/test", TestSMTP) + + keyGroup := configGroup.Group("/:key") + { + keyGroup.GET("", GetSystemConfig) + keyGroup.PUT("", UpdateSystemConfig) + } + } + + // Templates + templateGroup := adminRouter.Group("/templates") + { + templateGroup.GET("", ListTemplates) + templateGroup.POST("", CreateTemplate) + + keyGroup := templateGroup.Group("/:key") + { + keyGroup.GET("", GetTemplate) + keyGroup.PUT("", UpdateTemplate) + keyGroup.DELETE("", DeleteTemplate) + } + } + + // Tasks + taskGroup := adminRouter.Group("/tasks") + { + taskGroup.GET("/types", ListTaskTypes) + taskGroup.POST("/dispatch", DispatchTask) + + executions := taskGroup.Group("/executions") + { + executions.GET("", ListTaskExecutions) + executions.GET("/:id", GetTaskExecution) + executions.POST("/:id/retry", RetryTask) + } + + schedules := taskGroup.Group("/schedules") + { + schedules.GET("", ListSchedules) + schedules.POST("", CreateSchedule) + schedules.PUT("/:id", UpdateSchedule) + schedules.DELETE("/:id", DeleteSchedule) + } + } +} diff --git a/backend/plugins/domain/admin/handler/status.go b/backend/plugins/domain/admin/handler/status.go new file mode 100644 index 00000000..0f635cce --- /dev/null +++ b/backend/plugins/domain/admin/handler/status.go @@ -0,0 +1,41 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package handler + +import ( + "Wavelet/pkg/response" + "Wavelet/plugins/domain/admin/service" + "net/http" + + "github.com/gin-gonic/gin" +) + +// GetSystemStatus 获取系统状态信息 +// @Summary 获取系统状态信息 +// @Description 获取后端服务运行状态、Goroutine、内存指标等详细统计数据,需要管理员权限 +// @Tags admin +// @Produce json +// @Security SessionCookie +// @Success 200 {object} response.Any{data=model.SystemStatusResponse} "获取成功" +// @Failure 401 {object} response.Any "未登录" +// @Failure 403 {object} response.Any "无管理员权限" +// @Router /api/v1/admin/status [get] +func GetSystemStatus(c *gin.Context) { + c.JSON(http.StatusOK, response.OK(service.CollectSystemStatus())) +} + +// GetLogDatabaseStatus 返回当前日志库状态。 +// @Summary 获取日志数据库状态 +// @Description 返回当前日志主库、迁移状态、各库保留天数与合法迁移目标,需要管理员权限 +// @Tags admin +// @Produce json +// @Security SessionCookie +// @Success 200 {object} response.Any{data=model.LogDatabaseStatus} "获取成功" +// @Failure 401 {object} response.Any "未登录" +// @Failure 403 {object} response.Any "无管理员权限" +// @Failure 500 {object} response.Any "内部错误" +// @Router /api/v1/admin/status/log-database [get] +func GetLogDatabaseStatus(c *gin.Context) { + c.JSON(http.StatusOK, response.OK(service.LogDatabaseStatus(c.Request.Context()))) +} diff --git a/backend/plugins/domain/admin/handler/tasks.go b/backend/plugins/domain/admin/handler/tasks.go new file mode 100644 index 00000000..438cae3f --- /dev/null +++ b/backend/plugins/domain/admin/handler/tasks.go @@ -0,0 +1,318 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package handler + +import ( + "Wavelet/pkg/response" + "Wavelet/plugins/domain/admin/errs" + "Wavelet/plugins/domain/admin/model" + "Wavelet/plugins/domain/admin/service" + "errors" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" +) + +// abortTaskLogicError maps a task service failure onto the unified response envelope. +func abortTaskLogicError(c *gin.Context, err error) bool { + if err == nil { + return false + } + if errors.Is(err, errs.ErrTaskServiceUnavailable) || errors.Is(err, errs.ErrScheduleNotFound) { + response.AbortInternal(c, err.Error()) + return true + } + msg := err.Error() + if errors.Is(err, errs.ErrInvalidCronExpression) || errors.Is(err, errs.ErrInvalidTaskType) { + response.AbortBadRequest(c, msg) + return true + } + if text, ok := errs.AsInvalidInput(err); ok { + response.AbortBadRequest(c, text) + return true + } + response.AbortInternal(c, msg) + return true +} + +// ListTaskTypes 获取支持的任务类型列表 +// @Summary 获取支持的任务类型 +// @Description 返回系统支持的所有可调度任务类型列表,包括任务名称、描述、是否支持时间范围等元数据,需要管理员权限 +// @Tags admin +// @Produce json +// @Security SessionCookie +// @Success 200 {object} response.Any{data=[]contracts.TaskMetaDTO} "任务类型列表" +// @Failure 401 {object} response.Any "未登录" +// @Failure 403 {object} response.Any "无管理员权限" +// @Router /api/v1/admin/tasks/types [get] +func ListTaskTypes(c *gin.Context) { + c.JSON(http.StatusOK, response.OK(service.ListTaskTypes())) +} + +// DispatchTask 下发任务 +// @Summary 下发异步任务 +// @Description 手动触发指定类型的异步任务,支持指定时间范围和用户,需要管理员权限 +// @Tags admin +// @Accept json +// @Produce json +// @Security SessionCookie +// @Param request body model.DispatchTaskRequest true "任务请求参数" +// @Success 200 {object} response.Any{data=string} "任务已入队" +// @Failure 400 {object} response.Any "任务类型不存在或参数错误" +// @Failure 401 {object} response.Any "未登录" +// @Failure 403 {object} response.Any "无管理员权限" +// @Failure 500 {object} response.Any "任务入队失败" +// @Router /api/v1/admin/tasks/dispatch [post] +func DispatchTask(c *gin.Context) { + var req model.DispatchTaskRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + + taskID, err := service.DispatchTask(c.Request.Context(), req) + if err != nil { + switch { + case errors.Is(err, errs.ErrTaskServiceUnavailable): + response.AbortInternal(c, err.Error()) + case errors.Is(err, errs.ErrInvalidTaskType): + response.AbortBadRequest(c, errs.InvalidTaskType) + default: + if text, ok := errs.AsInvalidInput(err); ok { + response.AbortBadRequest(c, text) + return + } + response.AbortInternal(c, err.Error()) + } + return + } + + c.JSON(http.StatusOK, response.OK(taskID)) +} + +// ListTaskExecutions 查询任务执行记录列表 +// @Summary 查询任务执行记录 +// @Description 分页查询任务执行记录,支持按状态和任务类型筛选,需要管理员权限 +// @Tags admin +// @Produce json +// @Security SessionCookie +// @Param status query string false "状态筛选 (pending/running/succeeded/failed)" +// @Param task_type query string false "任务类型筛选" +// @Param page query int false "页码" default(1) +// @Param page_size query int false "每页条数" default(20) +// @Success 200 {object} response.Any{data=object} "任务执行记录列表" +// @Failure 401 {object} response.Any "未登录" +// @Failure 403 {object} response.Any "无管理员权限" +// @Router /api/v1/admin/tasks/executions [get] +func ListTaskExecutions(c *gin.Context) { + var req model.ListTaskExecutionsRequest + if err := c.ShouldBindQuery(&req); err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + + executions, total, err := service.ListTaskExecutions(c.Request.Context(), req) + if err != nil { + response.AbortInternal(c, err.Error()) + return + } + + c.JSON(http.StatusOK, response.OK(gin.H{ + "items": executions, + "total": total, + "page": req.Page, + "page_size": req.PageSize, + })) +} + +// GetTaskExecution 查询单条任务执行详情 +// @Summary 查询任务执行详情 +// @Description 根据 ID 查询任务执行记录详情,包含完整执行日志,需要管理员权限 +// @Tags admin +// @Produce json +// @Security SessionCookie +// @Param id path int true "任务执行记录 ID" +// @Success 200 {object} response.Any{data=model.TaskExecution} "任务执行详情" +// @Failure 400 {object} response.Any "参数错误" +// @Failure 401 {object} response.Any "未登录" +// @Failure 403 {object} response.Any "无管理员权限" +// @Failure 404 {object} response.Any "记录不存在" +// @Router /api/v1/admin/tasks/executions/{id} [get] +func GetTaskExecution(c *gin.Context) { + id, err := parseUintParam(c, errs.InvalidTaskExecutionID) + if err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + + execution, err := service.TaskExecution(c.Request.Context(), id) + if err != nil { + response.AbortNotFound(c, errs.TaskNotFound) + return + } + + c.JSON(http.StatusOK, response.OK(execution)) +} + +// RetryTask 重试失败的任务 +// @Summary 重试失败任务 +// @Description 重新下发一条失败的任务,创建新的执行记录,需要管理员权限 +// @Tags admin +// @Produce json +// @Security SessionCookie +// @Param id path int true "任务执行记录 ID" +// @Success 200 {object} response.Any{data=string} "新任务的 TaskID" +// @Failure 400 {object} response.Any "任务不支持重试或参数错误" +// @Failure 401 {object} response.Any "未登录" +// @Failure 403 {object} response.Any "无管理员权限" +// @Failure 404 {object} response.Any "记录不存在" +// @Failure 500 {object} response.Any "重试失败" +// @Router /api/v1/admin/tasks/executions/{id}/retry [post] +func RetryTask(c *gin.Context) { + id, err := parseUintParam(c, errs.InvalidTaskExecutionID) + if err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + + newTaskID, err := service.RetryTask(c.Request.Context(), id) + if err != nil { + switch { + case errors.Is(err, errs.ErrTaskServiceUnavailable): + response.AbortInternal(c, err.Error()) + case service.IsRetryMissingError(err): + response.AbortNotFound(c, err.Error()) + case service.IsRetryConflictError(err): + response.AbortBadRequest(c, err.Error()) + default: + response.AbortInternal(c, err.Error()) + } + return + } + + c.JSON(http.StatusOK, response.OK(newTaskID)) +} + +// ListSchedules 获取定时任务列表 +// @Summary 获取定时任务列表 +// @Description 返回系统所有的定时任务配置列表,包括名称、关联的异步任务类型、Cron 表达式和启用状态,需要管理员权限 +// @Tags admin +// @Produce json +// @Security SessionCookie +// @Success 200 {object} response.Any{data=[]model.Schedule} "定时任务列表" +// @Failure 401 {object} response.Any "未登录" +// @Failure 403 {object} response.Any "无管理员权限" +// @Router /api/v1/admin/tasks/schedules [get] +func ListSchedules(c *gin.Context) { + schedules, err := service.ListSchedules(c.Request.Context()) + if err != nil { + response.AbortInternal(c, err.Error()) + return + } + c.JSON(http.StatusOK, response.OK(schedules)) +} + +// CreateSchedule 创建定时任务 +// @Summary 创建定时任务 +// @Description 新增一个动态定时任务配置,关联已有的异步任务,配置 Cron 表达式和执行参数,并触发调度器热加载,需要管理员权限 +// @Tags admin +// @Accept json +// @Produce json +// @Security SessionCookie +// @Param request body model.CreateScheduleRequest true "创建定时任务请求参数" +// @Success 200 {object} response.Any{data=model.Schedule} "创建成功的定时任务信息" +// @Failure 400 {object} response.Any "Cron 表达式无效、异步任务类型不存在或参数错误" +// @Failure 401 {object} response.Any "未登录" +// @Failure 403 {object} response.Any "无管理员权限" +// @Failure 500 {object} response.Any "保存定时任务失败" +// @Router /api/v1/admin/tasks/schedules [post] +func CreateSchedule(c *gin.Context) { + var req model.CreateScheduleRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + + schedule, err := service.CreateSchedule(c.Request.Context(), req) + if abortTaskLogicError(c, err) { + return + } + + c.JSON(http.StatusOK, response.OK(schedule)) +} + +// UpdateSchedule 修改定时任务 +// @Summary 修改定时任务 +// @Description 修改一个定时任务的配置(名称、Cron 表达式、异步任务参数和是否启用等),并触发调度器热加载,需要管理员权限 +// @Tags admin +// @Accept json +// @Produce json +// @Security SessionCookie +// @Param id path int true "定时任务 ID" +// @Param request body model.UpdateScheduleRequest true "修改定时任务请求参数" +// @Success 200 {object} response.Any{data=model.Schedule} "修改后的定时任务信息" +// @Failure 400 {object} response.Any "Cron 表达式无效、参数错误" +// @Failure 401 {object} response.Any "未登录" +// @Failure 403 {object} response.Any "无管理员权限" +// @Failure 404 {object} response.Any "定时任务不存在" +// @Failure 500 {object} response.Any "修改定时任务失败" +// @Router /api/v1/admin/tasks/schedules/{id} [put] +func UpdateSchedule(c *gin.Context) { + id, err := parseUintParam(c, errs.InvalidScheduleID) + if err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + + var req model.UpdateScheduleRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + + schedule, err := service.UpdateSchedule(c.Request.Context(), id, req) + if abortTaskLogicError(c, err) { + return + } + + c.JSON(http.StatusOK, response.OK(schedule)) +} + +// DeleteSchedule 删除定时任务 +// @Summary 删除定时任务 +// @Description 删除指定的定时任务配置,并触发调度器热加载,需要管理员权限 +// @Tags admin +// @Produce json +// @Security SessionCookie +// @Param id path int true "定时任务 ID" +// @Success 200 {object} response.Any{data=string} "删除结果" +// @Failure 400 {object} response.Any "参数错误" +// @Failure 401 {object} response.Any "未登录" +// @Failure 403 {object} response.Any "无管理员权限" +// @Failure 500 {object} response.Any "删除定时任务失败" +// @Router /api/v1/admin/tasks/schedules/{id} [delete] +func DeleteSchedule(c *gin.Context) { + id, err := parseUintParam(c, errs.InvalidScheduleID) + if err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + + if err := service.DeleteSchedule(c.Request.Context(), id); err != nil { + response.AbortInternal(c, err.Error()) + return + } + + c.JSON(http.StatusOK, response.OKNil()) +} + +// parseUintParam reads a positive numeric path parameter. +func parseUintParam(c *gin.Context, invalidMsg string) (uint64, error) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + return 0, errors.New(invalidMsg) + } + return id, nil +} diff --git a/backend/plugins/domain/admin/handler/tasks_test.go b/backend/plugins/domain/admin/handler/tasks_test.go new file mode 100644 index 00000000..b513ece6 --- /dev/null +++ b/backend/plugins/domain/admin/handler/tasks_test.go @@ -0,0 +1,97 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package handler_test + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "Wavelet/core/extpoints" + "Wavelet/plugins/domain/admin/handler" + "Wavelet/plugins/domain/admin/service" + "Wavelet/plugins/drivers/driver_asynq_worker" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type listTaskTypesResponse struct { + ErrorMsg string `json:"error_msg"` + Data []contracts.TaskMetaDTO `json:"data"` +} + +func TestListTaskTypesHandler(t *testing.T) { + gin.SetMode(gin.TestMode) + + ctx := core.NewContext(t.Context()) + worker := driver_asynq_worker.New() + require.NoError(t, worker.Apply(ctx)) + + ctx.Task().Register("logs:db_switch", func(_ any) error { return nil }, + extpoints.WithTaskType("logs_db_switch"), + extpoints.WithTaskName("切换日志数据库"), + extpoints.WithTaskDescription("复制迁移用户访问日志并在成功后切换日志主库"), + extpoints.WithTaskCategory("system"), + extpoints.WithTaskRetry(3), + extpoints.WithTaskQueue("default"), + extpoints.WithTaskRetryable(true), + extpoints.WithTaskParams(contracts.TaskParamDTO{ + Name: "target", + Label: "目标日志库", + Type: "string", + Required: true, + Placeholder: "postgres|sqlite|clickhouse", + Description: "迁移目标", + }), + ) + + taskSvc, err := core.Inject[contracts.TaskService](ctx) + require.NoError(t, err) + service.SetTaskService(taskSvc) + + r := gin.New() + r.GET("/api/v1/admin/tasks/types", handler.ListTaskTypes) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/tasks/types", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp listTaskTypesResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Empty(t, resp.ErrorMsg) + require.NotEmpty(t, resp.Data) + + var found bool + for _, task := range resp.Data { + if task.Type == "logs_db_switch" { + found = true + assert.Equal(t, "logs:db_switch", task.AsynqTask) + assert.Equal(t, "切换日志数据库", task.Name) + assert.Equal(t, "复制迁移用户访问日志并在成功后切换日志主库", task.Description) + assert.Equal(t, "system", task.Category) + assert.Equal(t, 3, task.MaxRetry) + assert.Equal(t, "default", task.Queue) + assert.True(t, task.Retryable) + require.Len(t, task.Params, 1) + assert.Equal(t, "target", task.Params[0].Name) + assert.Equal(t, "目标日志库", task.Params[0].Label) + assert.Equal(t, "string", task.Params[0].Type) + assert.True(t, task.Params[0].Required) + assert.Equal(t, "postgres|sqlite|clickhouse", task.Params[0].Placeholder) + } + } + assert.True(t, found, "expected logs_db_switch in task types") + + // Ensure NO task in the list has an empty Type or Name, which breaks SelectItem key/value in frontend + for _, task := range resp.Data { + assert.NotEmpty(t, task.Type, "task.type must never be empty") + assert.NotEmpty(t, task.Name, "task.name must never be empty") + } +} diff --git a/backend/plugins/domain/admin/handler/templates.go b/backend/plugins/domain/admin/handler/templates.go new file mode 100644 index 00000000..3b8a72b7 --- /dev/null +++ b/backend/plugins/domain/admin/handler/templates.go @@ -0,0 +1,159 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package handler + +import ( + "Wavelet/pkg/response" + "Wavelet/plugins/domain/admin/errs" + "Wavelet/plugins/domain/admin/model" + "Wavelet/plugins/domain/admin/service" + "errors" + "net/http" + + "github.com/gin-gonic/gin" +) + +// abortTemplateLogicError maps the template service outcome onto the response envelope. +func abortTemplateLogicError(c *gin.Context, err error) bool { + if err == nil { + return false + } + if errors.Is(err, errs.ErrTemplateNotFound) { + response.AbortNotFound(c, errs.TemplateNotFound) + return true + } + msg := err.Error() + switch msg { + case errs.TemplateKeyExists, errs.SystemTemplateCannotDelete: + response.AbortBadRequest(c, msg) + return true + } + response.AbortInternal(c, msg) + return true +} + +// CreateTemplate 创建模板 +// @Summary 创建模板 +// @Description 创建一条新的自定义通知模板,模板标识符(Key)不可重复,需要管理员权限 +// @Tags admin +// @Accept json +// @Produce json +// @Security SessionCookie +// @Param request body model.CreateTemplateRequest true "创建请求参数" +// @Success 200 {object} response.Any{data=string} "创建成功" +// @Failure 400 {object} response.Any "参数错误或模板标识符已存在" +// @Failure 401 {object} response.Any "未登录" +// @Failure 403 {object} response.Any "无管理员权限" +// @Failure 500 {object} response.Any "内部错误" +// @Router /api/v1/admin/templates [post] +func CreateTemplate(c *gin.Context) { + var req model.CreateTemplateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + + tmpl, err := service.CreateTemplate(c.Request.Context(), req) + if abortTemplateLogicError(c, err) { + return + } + + c.JSON(http.StatusOK, response.OK(tmpl)) +} + +// ListTemplates 获取模板列表 +// @Summary 获取模板列表 +// @Description 返回所有通知模板列表,需要管理员权限 +// @Tags admin +// @Produce json +// @Security SessionCookie +// @Success 200 {object} response.Any{data=[]model.Template} "模板列表" +// @Failure 401 {object} response.Any "未登录" +// @Failure 403 {object} response.Any "无管理员权限" +// @Failure 500 {object} response.Any "内部错误" +// @Router /api/v1/admin/templates [get] +func ListTemplates(c *gin.Context) { + templates, err := service.ListTemplates(c.Request.Context()) + if err != nil { + response.AbortInternal(c, err.Error()) + return + } + + c.JSON(http.StatusOK, response.OK(templates)) +} + +// GetTemplate 获取单个模板 +// @Summary 获取单个模板 +// @Description 根据模板标识符获取对应的模板详情,需要管理员权限 +// @Tags admin +// @Produce json +// @Security SessionCookie +// @Param key path string true "模板标识符" +// @Success 200 {object} response.Any{data=model.Template} "模板详情" +// @Failure 401 {object} response.Any "未登录" +// @Failure 403 {object} response.Any "无管理员权限" +// @Failure 404 {object} response.Any "模板不存在" +// @Failure 500 {object} response.Any "内部错误" +// @Router /api/v1/admin/templates/{key} [get] +func GetTemplate(c *gin.Context) { + tmpl, err := service.GetTemplate(c.Request.Context(), c.Param("key")) + if abortTemplateLogicError(c, err) { + return + } + + c.JSON(http.StatusOK, response.OK(tmpl)) +} + +// UpdateTemplate 更新模板 +// @Summary 更新模板 +// @Description 根据模板标识符更新对应的模板内容,需要管理员权限 +// @Tags admin +// @Accept json +// @Produce json +// @Security SessionCookie +// @Param key path string true "模板标识符" +// @Param request body model.UpdateTemplateRequest true "更新请求参数" +// @Success 200 {object} response.Any{data=model.Template} "更新成功" +// @Failure 400 {object} response.Any "参数错误" +// @Failure 401 {object} response.Any "未登录" +// @Failure 403 {object} response.Any "无管理员权限" +// @Failure 404 {object} response.Any "模板不存在" +// @Failure 500 {object} response.Any "内部错误" +// @Router /api/v1/admin/templates/{key} [put] +func UpdateTemplate(c *gin.Context) { + var req model.UpdateTemplateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + + tmpl, err := service.UpdateTemplate(c.Request.Context(), c.Param("key"), req) + if abortTemplateLogicError(c, err) { + return + } + + c.JSON(http.StatusOK, response.OK(tmpl)) +} + +// DeleteTemplate 删除模板 +// @Summary 删除模板 +// @Description 根据模板标识符删除对应模板,系统预置模板不可删除,需要管理员权限 +// @Tags admin +// @Produce json +// @Security SessionCookie +// @Param key path string true "模板标识符" +// @Success 200 {object} response.Any{data=string} "删除成功" +// @Failure 400 {object} response.Any "不可删除系统模板" +// @Failure 401 {object} response.Any "未登录" +// @Failure 403 {object} response.Any "无管理员权限" +// @Failure 404 {object} response.Any "模板不存在" +// @Failure 500 {object} response.Any "内部错误" +// @Router /api/v1/admin/templates/{key} [delete] +func DeleteTemplate(c *gin.Context) { + if err := service.DeleteTemplate(c.Request.Context(), c.Param("key")); abortTemplateLogicError(c, err) { + return + } + + c.JSON(http.StatusOK, response.OKNil()) +} diff --git a/backend/plugins/domain/admin/handler/updater.go b/backend/plugins/domain/admin/handler/updater.go new file mode 100644 index 00000000..115c7b4c --- /dev/null +++ b/backend/plugins/domain/admin/handler/updater.go @@ -0,0 +1,69 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package handler + +import ( + "Wavelet/pkg/logger" + "Wavelet/pkg/response" + "Wavelet/pkg/util" + "Wavelet/plugins/domain/admin/service" + "context" + "net/http" + "time" + + "github.com/gin-gonic/gin" +) + +// GetUpdateStatus 获取应用更新状态 +// @Summary 获取应用更新状态 +// @Description 从系统配置指定的 GitHub 上游仓库查询最新兼容 Release,并与当前服务版本比较 +// @Tags admin +// @Produce json +// @Security SessionCookie +// @Success 200 {object} response.Any{data=model.UpdaterStatus} "更新状态" +// @Failure 401 {object} response.Any "未登录" +// @Failure 403 {object} response.Any "无管理员权限" +// @Failure 500 {object} response.Any "查询失败" +// @Router /api/v1/admin/update [get] +func GetUpdateStatus(c *gin.Context) { + status, err := service.GetUpdateStatus(c.Request.Context()) + if err != nil { + logger.ErrorF(c.Request.Context(), "[Updater] check release failed: %v", err) + response.AbortInternal(c, err.Error()) + return + } + c.JSON(http.StatusOK, response.OK(status)) +} + +// ApplyUpdate 下载并应用应用更新 +// @Summary 下载并应用应用更新 +// @Description 下载当前平台对应的 GitHub Actions Release 资产,替换当前二进制并重启进程 +// @Tags admin +// @Produce json +// @Security SessionCookie +// @Success 200 {object} response.Any "升级已准备并即将重启" +// @Failure 400 {object} response.Any "当前版本不可升级" +// @Failure 401 {object} response.Any "未登录" +// @Failure 403 {object} response.Any "无管理员权限" +// @Failure 500 {object} response.Any "升级准备失败" +// @Router /api/v1/admin/update/apply [post] +func ApplyUpdate(c *gin.Context) { + executable, stagedBinary, err := service.DefaultUpdaterManager.PrepareUpgrade(c.Request.Context()) + if err != nil { + logger.ErrorF(c.Request.Context(), "[Updater] prepare upgrade failed: %v", err) + response.AbortBadRequest(c, err.Error()) + return + } + + logger.InfoF(c.Request.Context(), "[Updater] upgrade prepared; restarting with %s", stagedBinary) + c.JSON(http.StatusOK, response.OKNil()) + + util.Go(func() { + time.Sleep(time.Second) + if err := service.ReplaceAndRestart(executable, stagedBinary); err != nil { + service.DefaultUpdaterManager.FinishUpgrade() + logger.ErrorF(context.Background(), "[Updater] replace and restart failed: %v", err) + } + }) +} diff --git a/backend/plugins/domain/admin/handler/user.go b/backend/plugins/domain/admin/handler/user.go new file mode 100644 index 00000000..7681b27e --- /dev/null +++ b/backend/plugins/domain/admin/handler/user.go @@ -0,0 +1,294 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package handler + +import ( + "Wavelet/core/contracts" + "Wavelet/pkg/ginutil" + "Wavelet/pkg/logger" + "Wavelet/pkg/response" + "Wavelet/plugins/domain/admin/errs" + "Wavelet/plugins/domain/admin/model" + "Wavelet/plugins/domain/admin/service" + "errors" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" +) + +func parseUserID(c *gin.Context) (uint64, bool) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + response.AbortBadRequest(c, errs.UserNotFound) + return 0, false + } + return id, true +} + +// abortUserLogicError maps the user service outcome onto the unified response envelope. +func abortUserLogicError(c *gin.Context, err error, notFoundMsg string, forbiddenMsgs, badRequestMsgs []string) bool { + if err == nil { + return false + } + if errors.Is(err, errs.ErrUserServiceUnavailable) { + response.AbortInternal(c, err.Error()) + return true + } + if errors.Is(err, errs.ErrUserNotFound) { + response.AbortNotFound(c, notFoundMsg) + return true + } + msg := err.Error() + for _, m := range badRequestMsgs { + if msg == m { + response.AbortBadRequest(c, msg) + return true + } + } + for _, m := range forbiddenMsgs { + if msg == m { + response.AbortForbidden(c, msg) + return true + } + } + logger.ErrorF(c.Request.Context(), "Admin user error: %v", err) + response.AbortInternal(c, errs.InternalServerError) + return true +} + +// ListUsers 获取用户列表 +// @Summary 获取用户列表 +// @Description 分页返回用户列表,支持按用户 ID 和用户名筛选,需要管理员权限 +// @Tags admin +// @Produce json +// @Security SessionCookie +// @Param request query model.ListUsersRequest true "查询参数" +// @Success 200 {object} response.Any{data=model.ListUsersResponse} "用户列表" +// @Failure 400 {object} response.Any "参数错误" +// @Failure 401 {object} response.Any "未登录" +// @Failure 403 {object} response.Any "无管理员权限" +// @Failure 500 {object} response.Any "内部错误" +// @Router /api/v1/admin/users [get] +func ListUsers(c *gin.Context) { + var req model.ListUsersRequest + if err := c.ShouldBindQuery(&req); err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + + total, dtos, err := service.AdminListUsers(c.Request.Context(), contracts.AdminListUsersFilter{ + Page: req.Page, + PageSize: req.PageSize, + UserID: req.UserID, + Username: req.Username, + Email: req.Email, + }) + if err != nil { + response.AbortInternal(c, err.Error()) + return + } + + users := make([]model.UserResponse, 0, len(dtos)) + for _, dto := range dtos { + users = append(users, service.ToUserResponse(dto)) + } + + c.JSON(http.StatusOK, response.OK(model.ListUsersResponse{ + Users: users, + Total: total, + })) +} + +// GetUser 获取用户详情 +// @Summary 获取用户详情 +// @Description 返回指定用户的完整个人资料和系统状态,需要管理员权限,不返回密码等敏感字段 +// @Tags admin +// @Produce json +// @Security SessionCookie +// @Param id path int true "用户 ID" +// @Success 200 {object} response.Any{data=model.UserResponse} "用户详情" +// @Failure 400 {object} response.Any "参数错误" +// @Failure 401 {object} response.Any "未登录" +// @Failure 403 {object} response.Any "无管理员权限" +// @Failure 404 {object} response.Any "用户不存在" +// @Failure 500 {object} response.Any "内部错误" +// @Router /api/v1/admin/users/{id} [get] +func GetUser(c *gin.Context) { + id, ok := parseUserID(c) + if !ok { + return + } + + targetUser, err := service.AdminGetUser(c.Request.Context(), id) + if abortUserLogicError(c, err, errs.UserNotFound, nil, nil) { + return + } + + c.JSON(http.StatusOK, response.OK(service.ToUserResponse(targetUser))) +} + +// UpdateUserStatus 更新用户状态(启用/禁用) +// @Summary 更新用户状态 +// @Description 启用或禁用指定用户,管理员账号无法被禁用,需要管理员权限 +// @Tags admin +// @Accept json +// @Produce json +// @Security SessionCookie +// @Param id path int true "用户 ID" +// @Param request body model.UpdateUserStatusRequest true "状态参数" +// @Success 200 {object} response.Any{data=string} "更新成功" +// @Failure 400 {object} response.Any "参数错误" +// @Failure 401 {object} response.Any "未登录" +// @Failure 403 {object} response.Any "无管理员权限或尝试禁用管理员" +// @Failure 404 {object} response.Any "用户不存在" +// @Failure 500 {object} response.Any "内部错误" +// @Router /api/v1/admin/users/{id}/status [put] +func UpdateUserStatus(c *gin.Context) { + var req model.UpdateUserStatusRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + + id, ok := parseUserID(c) + if !ok { + return + } + + if err := service.AdminUpdateUserStatus(c.Request.Context(), id, req.IsActive); err != nil { + if abortUserLogicError(c, err, errs.UserNotFound, []string{errs.CannotDisable}, nil) { + return + } + response.AbortInternal(c, errs.UpdateUserFailed) + return + } + + c.JSON(http.StatusOK, response.OKNil()) +} + +// DeleteUser 删除用户 +// @Summary 删除用户 +// @Description 删除指定非管理员用户,需要管理员权限,不能删除当前登录用户 +// @Tags admin +// @Produce json +// @Security SessionCookie +// @Param id path int true "用户 ID" +// @Success 200 {object} response.Any{data=string} "删除成功" +// @Failure 400 {object} response.Any "参数错误" +// @Failure 401 {object} response.Any "未登录" +// @Failure 403 {object} response.Any "无管理员权限、尝试删除管理员或当前用户" +// @Failure 404 {object} response.Any "用户不存在" +// @Failure 500 {object} response.Any "内部错误" +// @Router /api/v1/admin/users/{id} [delete] +func DeleteUser(c *gin.Context) { + id, ok := parseUserID(c) + if !ok { + return + } + + currUser, _ := ginutil.GetFromContext[*contracts.UserDTO](c, contracts.AuthUserObjKey) + if currUser == nil { + response.AbortUnauthorized(c, errs.AdminRequired) + return + } + + if err := service.AdminDeleteUser(c.Request.Context(), currUser.ID, id); err != nil { + if abortUserLogicError(c, err, errs.UserNotFound, []string{errs.CannotDelete, errs.CannotDeleteSelf}, nil) { + return + } + response.AbortInternal(c, errs.DeleteUserFailed) + return + } + + c.JSON(http.StatusOK, response.OKNil()) +} + +// CreateUser 创建用户 +// @Summary 创建用户 +// @Description 创建一个本地密码登录的新用户,需要管理员权限 +// @Tags admin +// @Accept json +// @Produce json +// @Security SessionCookie +// @Param request body model.CreateUserRequest true "创建用户参数" +// @Success 200 {object} response.Any{data=model.UserResponse} "创建成功" +// @Failure 400 {object} response.Any "参数错误或用户名已存在" +// @Failure 401 {object} response.Any "未登录" +// @Failure 403 {object} response.Any "无管理员权限" +// @Failure 500 {object} response.Any "内部错误" +// @Router /api/v1/admin/users [post] +func CreateUser(c *gin.Context) { + var req model.CreateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + + newUser, err := service.AdminCreateUser(c.Request.Context(), contracts.AdminCreateUserRequest{ + Username: req.Username, + Password: req.Password, + Nickname: req.Nickname, + Email: req.Email, + IsActive: req.IsActive, + IsAdmin: req.IsAdmin, + }) + if abortUserLogicError(c, err, "", nil, []string{errs.UsernameRequired, errs.EmailRequired, errs.PasswordTooShort, errs.UsernameExists, errs.EmailExists}) { + return + } + + c.JSON(http.StatusOK, response.OK(service.ToUserResponse(newUser))) +} + +// UpdateUser 更新用户信息 +// @Summary 更新用户信息 +// @Description 更新指定用户的昵称、邮箱、管理员权限,并可选重置密码,需要管理员权限 +// @Tags admin +// @Accept json +// @Produce json +// @Security SessionCookie +// @Param id path int true "用户 ID" +// @Param request body model.UpdateUserRequest true "更新参数" +// @Success 200 {object} response.Any{data=string} "更新成功" +// @Failure 400 {object} response.Any "参数错误" +// @Failure 401 {object} response.Any "未登录" +// @Failure 403 {object} response.Any "无管理员权限或尝试修改自身权限" +// @Failure 404 {object} response.Any "用户不存在" +// @Failure 500 {object} response.Any "内部错误" +// @Router /api/v1/admin/users/{id} [put] +func UpdateUser(c *gin.Context) { + var req model.UpdateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + + id, ok := parseUserID(c) + if !ok { + return + } + + currUser, _ := ginutil.GetFromContext[*contracts.UserDTO](c, contracts.AuthUserObjKey) + if currUser == nil { + response.AbortUnauthorized(c, errs.AdminRequired) + return + } + + err := service.AdminUpdateUser(c.Request.Context(), currUser.ID, contracts.AdminUpdateUserRequest{ + ID: id, + Nickname: req.Nickname, + Email: req.Email, + IsAdmin: req.IsAdmin, + Password: req.Password, + }) + if err != nil { + if abortUserLogicError(c, err, errs.UserNotFound, []string{errs.CannotRevokeSelfAdmin}, []string{errs.EmailRequired, errs.EmailExists, errs.PasswordTooShort}) { + return + } + response.AbortInternal(c, errs.UpdateUserInfoFailed) + return + } + + c.JSON(http.StatusOK, response.OKNil()) +} diff --git a/backend/plugins/domain/admin/migrations/postgres/00001_initial.sql b/backend/plugins/domain/admin/migrations/postgres/00001_initial.sql new file mode 100644 index 00000000..c32ba6bf --- /dev/null +++ b/backend/plugins/domain/admin/migrations/postgres/00001_initial.sql @@ -0,0 +1,131 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS w_system_configs ( + key VARCHAR(64) PRIMARY KEY, + value TEXT NOT NULL, + type VARCHAR(32) NOT NULL DEFAULT 'system', + visibility INTEGER NOT NULL DEFAULT 0, + description VARCHAR(255), + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS w_templates ( + id BIGINT PRIMARY KEY, + key VARCHAR(80) NOT NULL UNIQUE, + name VARCHAR(100) NOT NULL, + type VARCHAR(20) NOT NULL DEFAULT 'email', + subject VARCHAR(255), + content TEXT NOT NULL, + description VARCHAR(255), + is_system BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_w_templates_is_system ON w_templates (is_system); +CREATE INDEX IF NOT EXISTS idx_w_templates_created_at ON w_templates (created_at); +CREATE INDEX IF NOT EXISTS idx_w_templates_updated_at ON w_templates (updated_at); + +CREATE TABLE IF NOT EXISTS w_schedules ( + id BIGINT PRIMARY KEY, + name VARCHAR(128) NOT NULL, + task_type VARCHAR(64) NOT NULL, + cron VARCHAR(64) NOT NULL, + payload TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_w_schedules_is_active ON w_schedules (is_active); + +-- Seed initial cleanup task +INSERT INTO w_schedules (id, name, task_type, cron, payload, is_active, created_at, updated_at) +VALUES (1, '系统定期垃圾清理', 'system_cleanup', '0 3 * * *', '{}', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT (id) DO NOTHING; + +CREATE TABLE IF NOT EXISTS w_task_executions ( + id BIGINT PRIMARY KEY, + task_id VARCHAR(128) NOT NULL UNIQUE, + task_type VARCHAR(64) NOT NULL, + task_name VARCHAR(128), + status VARCHAR(32) NOT NULL, + retryable BOOLEAN NOT NULL DEFAULT FALSE, + max_retry INTEGER NOT NULL DEFAULT 0, + retry_count INTEGER NOT NULL DEFAULT 0, + log TEXT, + error_message TEXT, + result TEXT, + started_at TIMESTAMPTZ, + finished_at TIMESTAMPTZ, + duration BIGINT, + payload TEXT, + triggered_by VARCHAR(32) NOT NULL DEFAULT 'system', + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_w_task_executions_task_type ON w_task_executions (task_type); +CREATE INDEX IF NOT EXISTS idx_w_task_executions_status ON w_task_executions (status); +CREATE INDEX IF NOT EXISTS idx_w_task_executions_started_at ON w_task_executions (started_at); +CREATE INDEX IF NOT EXISTS idx_w_task_executions_created_at ON w_task_executions (created_at); + +-- Seed system configs (all default platform configs) +INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at) VALUES + ('cap_login_enabled', 'false', 'system', 1, '是否启用登录人机验证(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('cap_auto_solve', 'true', 'system', 1, '打开页面后是否自动开始计算,关闭则需用户手动点击触发', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('cap_challenge_count', '1', 'system', 0, '客户端需求解的 PoW 难题总数,默认 1,推荐 1~5', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('cap_challenge_size', '32', 'system', 0, '人机验证盐值长度', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('cap_challenge_difficulty', '4', 'system', 0, '人机验证 PoW 难度(目标前缀长度)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('cap_challenge_ttl_seconds', '600', 'system', 0, '人机验证难题有效时间(秒)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('cap_token_ttl_seconds', '1200', 'system', 0, '人机验证兑换凭证有效时间(秒)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('server_address', '', 'system', 0, '服务器地址(用于跨域源控制,不设定则允许任意源)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('smtp_host', '', 'system', 0, 'SMTP 服务器地址(例如 smtp.example.com)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('smtp_port', '587', 'system', 0, 'SMTP 端口(例如 587 或 465)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('smtp_username', '', 'system', 0, 'SMTP 账户(如 sender@example.com)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('smtp_password', '', 'system', 0, 'SMTP 访问凭证(授权码/密码)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('upload_allowed_extensions', 'jpg,png,webp', 'system', 1, '允许上传的图片扩展名(逗号分隔)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('site_name', 'Wavelet', 'system', 1, '系统平台的展示名称', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('password_login_enabled', 'true', 'system', 1, '是否允许使用账号密码登录', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('registration_enabled', 'true', 'system', 1, '控制普通用户是否可以自主注册(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('password_register_enabled', 'true', 'system', 1, '是否允许通过密码创建本地账号', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('oidc_login_enabled', 'true', 'system', 1, '是否允许使用第三方 OIDC 认证源登录', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('max_api_keys_per_user', '5', 'business', 1, '限制每个普通用户可以创建的 API Key 最大数量', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('email_login_verification_enabled', 'false', 'system', 1, '是否开启邮箱登录验证(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('email_register_verification_enabled', 'false', 'system', 1, '是否开启邮箱注册验证(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('menu_display_config', '{}', 'system', 1, '目录显示配置(JSON 字符串,格式为 {url: enabled})', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('search_engine_indexing_enabled', 'false', 'system', 1, '是否允许搜索引擎爬取/检索该站点(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('update_upstream_repository', 'Rain-kl/Wavelet', 'system', 0, 'GitHub Actions Release 上游仓库(owner/repo 或 GitHub 仓库地址)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('storage_config', '{"driver":"local","local":{"root":"."},"s3":{"region":"us-east-1"},"r2":{"region":"auto"},"minio":{"region":"us-east-1","path_style":true},"oss":{},"webdav":{}}', 'system', 0, '文件存储驱动及连接配置(JSON)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('disk_cache_max_size_mb', '1024', 'system', 0, '磁盘缓存最大空间大小(MB)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('disk_cache_ttl_minutes', '1440', 'system', 0, '磁盘缓存默认有效期(分钟)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('disk_cache_lru_enabled', 'true', 'system', 0, '是否启用 LRU 淘汰机制', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('file_access_whitelist', '["avatar"]', 'system', 0, '免登录访问的文件业务类型白名单 (JSON 数组)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('login_session_ttl_hours', '168', 'system', 0, '登录会话过期时间(小时)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('log_database', '', 'system', 0, '当前日志主库(postgres/sqlite/clickhouse),由切换任务写入', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('log_db_migration', '', 'system', 0, '日志库迁移冻结标记(空或 migrating)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT (key) DO NOTHING; + +INSERT INTO w_templates (id, key, name, type, subject, content, description, is_system, created_at, updated_at) VALUES + (1, 'login_email', '登录验证码邮件', 'email', 'Wavelet 登录验证码', '

Wavelet 登录验证

您的登录验证码为:{{.Code}},5分钟内有效,请勿将验证码泄露给他人。

', '用户密码登录时发送的验证码邮件模板,支持变量:{{.Code}}', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (2, 'register_email', '注册验证码邮件', 'email', 'Wavelet 注册验证码', '

Wavelet 注册验证

您的注册验证码为:{{.Code}},5分钟内有效,请勿泄露给他人。

', '用户注册时发送的验证码邮件模板,支持变量:{{.Code}}', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT (key) DO NOTHING; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DELETE FROM w_templates WHERE key IN ('login_email', 'register_email'); +DELETE FROM w_system_configs WHERE key IN ( + 'cap_login_enabled', 'cap_auto_solve', 'cap_challenge_count', 'cap_challenge_size', + 'cap_challenge_difficulty', 'cap_challenge_ttl_seconds', 'cap_token_ttl_seconds', + 'server_address', 'smtp_host', 'smtp_port', 'smtp_username', 'smtp_password', + 'upload_allowed_extensions', 'site_name', 'password_login_enabled', 'registration_enabled', + 'password_register_enabled', 'oidc_login_enabled', 'max_api_keys_per_user', + 'email_login_verification_enabled', 'email_register_verification_enabled', + 'menu_display_config', 'search_engine_indexing_enabled', 'update_upstream_repository', + 'storage_config', 'disk_cache_max_size_mb', 'disk_cache_ttl_minutes', 'disk_cache_lru_enabled', + 'file_access_whitelist', 'login_session_ttl_hours', 'log_database', 'log_db_migration' +); +DROP TABLE IF EXISTS w_task_executions; +DROP TABLE IF EXISTS w_schedules; +DROP TABLE IF EXISTS w_templates; +DROP TABLE IF EXISTS w_system_configs; +-- +goose StatementEnd diff --git a/backend/plugins/domain/admin/migrations/sqlite/00001_initial.sql b/backend/plugins/domain/admin/migrations/sqlite/00001_initial.sql new file mode 100644 index 00000000..d44474e6 --- /dev/null +++ b/backend/plugins/domain/admin/migrations/sqlite/00001_initial.sql @@ -0,0 +1,131 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS w_system_configs ( + key VARCHAR(64) PRIMARY KEY, + value TEXT NOT NULL, + type VARCHAR(32) NOT NULL DEFAULT 'system', + visibility INTEGER NOT NULL DEFAULT 0, + description VARCHAR(255), + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS w_templates ( + id BIGINT PRIMARY KEY, + key VARCHAR(80) NOT NULL UNIQUE, + name VARCHAR(100) NOT NULL, + type VARCHAR(20) NOT NULL DEFAULT 'email', + subject VARCHAR(255), + content TEXT NOT NULL, + description VARCHAR(255), + is_system BOOLEAN NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_w_templates_is_system ON w_templates (is_system); +CREATE INDEX IF NOT EXISTS idx_w_templates_created_at ON w_templates (created_at); +CREATE INDEX IF NOT EXISTS idx_w_templates_updated_at ON w_templates (updated_at); + +CREATE TABLE IF NOT EXISTS w_schedules ( + id BIGINT PRIMARY KEY, + name VARCHAR(128) NOT NULL, + task_type VARCHAR(64) NOT NULL, + cron VARCHAR(64) NOT NULL, + payload TEXT, + is_active BOOLEAN NOT NULL DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_w_schedules_is_active ON w_schedules (is_active); + +-- Seed initial cleanup task +INSERT INTO w_schedules (id, name, task_type, cron, payload, is_active, created_at, updated_at) +VALUES (1, '系统定期垃圾清理', 'system_cleanup', '0 3 * * *', '{}', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT (id) DO NOTHING; + +CREATE TABLE IF NOT EXISTS w_task_executions ( + id BIGINT PRIMARY KEY, + task_id VARCHAR(128) NOT NULL UNIQUE, + task_type VARCHAR(64) NOT NULL, + task_name VARCHAR(128), + status VARCHAR(32) NOT NULL, + retryable BOOLEAN NOT NULL DEFAULT 0, + max_retry INTEGER NOT NULL DEFAULT 0, + retry_count INTEGER NOT NULL DEFAULT 0, + log TEXT, + error_message TEXT, + result TEXT, + started_at DATETIME, + finished_at DATETIME, + duration BIGINT, + payload TEXT, + triggered_by VARCHAR(32) NOT NULL DEFAULT 'system', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_w_task_executions_task_type ON w_task_executions (task_type); +CREATE INDEX IF NOT EXISTS idx_w_task_executions_status ON w_task_executions (status); +CREATE INDEX IF NOT EXISTS idx_w_task_executions_started_at ON w_task_executions (started_at); +CREATE INDEX IF NOT EXISTS idx_w_task_executions_created_at ON w_task_executions (created_at); + +-- Seed system configs (all default platform configs) +INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at) VALUES + ('cap_login_enabled', 'false', 'system', 1, '是否启用登录人机验证(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('cap_auto_solve', 'true', 'system', 1, '打开页面后是否自动开始计算,关闭则需用户手动点击触发', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('cap_challenge_count', '1', 'system', 0, '客户端需求解的 PoW 难题总数,默认 1,推荐 1~5', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('cap_challenge_size', '32', 'system', 0, '人机验证盐值长度', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('cap_challenge_difficulty', '4', 'system', 0, '人机验证 PoW 难度(目标前缀长度)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('cap_challenge_ttl_seconds', '600', 'system', 0, '人机验证难题有效时间(秒)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('cap_token_ttl_seconds', '1200', 'system', 0, '人机验证兑换凭证有效时间(秒)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('server_address', '', 'system', 0, '服务器地址(用于跨域源控制,不设定则允许任意源)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('smtp_host', '', 'system', 0, 'SMTP 服务器地址(例如 smtp.example.com)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('smtp_port', '587', 'system', 0, 'SMTP 端口(例如 587 或 465)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('smtp_username', '', 'system', 0, 'SMTP 账户(如 sender@example.com)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('smtp_password', '', 'system', 0, 'SMTP 访问凭证(授权码/密码)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('upload_allowed_extensions', 'jpg,png,webp', 'system', 1, '允许上传的图片扩展名(逗号分隔)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('site_name', 'Wavelet', 'system', 1, '系统平台的展示名称', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('password_login_enabled', 'true', 'system', 1, '是否允许使用账号密码登录', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('registration_enabled', 'true', 'system', 1, '控制普通用户是否可以自主注册(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('password_register_enabled', 'true', 'system', 1, '是否允许通过密码创建本地账号', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('oidc_login_enabled', 'true', 'system', 1, '是否允许使用第三方 OIDC 认证源登录', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('max_api_keys_per_user', '5', 'business', 1, '限制每个普通用户可以创建的 API Key 最大数量', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('email_login_verification_enabled', 'false', 'system', 1, '是否开启邮箱登录验证(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('email_register_verification_enabled', 'false', 'system', 1, '是否开启邮箱注册验证(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('menu_display_config', '{}', 'system', 1, '目录显示配置(JSON 字符串,格式为 {url: enabled})', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('search_engine_indexing_enabled', 'false', 'system', 1, '是否允许搜索引擎爬取/检索该站点(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('update_upstream_repository', 'Rain-kl/Wavelet', 'system', 0, 'GitHub Actions Release 上游仓库(owner/repo 或 GitHub 仓库地址)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('storage_config', '{"driver":"local","local":{"root":"."},"s3":{"region":"us-east-1"},"r2":{"region":"auto"},"minio":{"region":"us-east-1","path_style":true},"oss":{},"webdav":{}}', 'system', 0, '文件存储驱动及连接配置(JSON)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('disk_cache_max_size_mb', '1024', 'system', 0, '磁盘缓存最大空间大小(MB)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('disk_cache_ttl_minutes', '1440', 'system', 0, '磁盘缓存默认有效期(分钟)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('disk_cache_lru_enabled', 'true', 'system', 0, '是否启用 LRU 淘汰机制', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('file_access_whitelist', '["avatar"]', 'system', 0, '免登录访问的文件业务类型白名单 (JSON 数组)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('login_session_ttl_hours', '168', 'system', 0, '登录会话过期时间(小时)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('log_database', '', 'system', 0, '当前日志主库(postgres/sqlite/clickhouse),由切换任务写入', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('log_db_migration', '', 'system', 0, '日志库迁移冻结标记(空或 migrating)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT (key) DO NOTHING; + +INSERT INTO w_templates (id, key, name, type, subject, content, description, is_system, created_at, updated_at) VALUES + (1, 'login_email', '登录验证码邮件', 'email', 'Wavelet 登录验证码', '

Wavelet 登录验证

您的登录验证码为:{{.Code}},5分钟内有效,请勿将验证码泄露给他人。

', '用户密码登录时发送的验证码邮件模板,支持变量:{{.Code}}', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (2, 'register_email', '注册验证码邮件', 'email', 'Wavelet 注册验证码', '

Wavelet 注册验证

您的注册验证码为:{{.Code}},5分钟内有效,请勿泄露给他人。

', '用户注册时发送的验证码邮件模板,支持变量:{{.Code}}', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT (key) DO NOTHING; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DELETE FROM w_templates WHERE key IN ('login_email', 'register_email'); +DELETE FROM w_system_configs WHERE key IN ( + 'cap_login_enabled', 'cap_auto_solve', 'cap_challenge_count', 'cap_challenge_size', + 'cap_challenge_difficulty', 'cap_challenge_ttl_seconds', 'cap_token_ttl_seconds', + 'server_address', 'smtp_host', 'smtp_port', 'smtp_username', 'smtp_password', + 'upload_allowed_extensions', 'site_name', 'password_login_enabled', 'registration_enabled', + 'password_register_enabled', 'oidc_login_enabled', 'max_api_keys_per_user', + 'email_login_verification_enabled', 'email_register_verification_enabled', + 'menu_display_config', 'search_engine_indexing_enabled', 'update_upstream_repository', + 'storage_config', 'disk_cache_max_size_mb', 'disk_cache_ttl_minutes', 'disk_cache_lru_enabled', + 'file_access_whitelist', 'login_session_ttl_hours', 'log_database', 'log_db_migration' +); +DROP TABLE IF EXISTS w_task_executions; +DROP TABLE IF EXISTS w_schedules; +DROP TABLE IF EXISTS w_templates; +DROP TABLE IF EXISTS w_system_configs; +-- +goose StatementEnd diff --git a/backend/plugins/domain/admin/model/config.go b/backend/plugins/domain/admin/model/config.go new file mode 100644 index 00000000..355b6b71 --- /dev/null +++ b/backend/plugins/domain/admin/model/config.go @@ -0,0 +1,20 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package model + +// DatabaseConfig holds database configuration needed by the admin plugin. +type DatabaseConfig struct { + Enabled bool `config:"enabled" env:"DB_ENABLED" default:"false" autoEnable:"DB_HOST"` + Host string `config:"host" env:"DB_HOST"` + Port int `config:"port" env:"DB_PORT" default:"5432"` + Database string `config:"database" env:"DB_DATABASE"` + Username string `config:"username" env:"DB_USERNAME"` + Password string `config:"password" env:"DB_PASSWORD" secret:"true"` + SQLitePath string `config:"sqlite_path" env:"DB_SQLITE_PATH" default:"./data/wavelet.db"` +} + +// ClickHouseConfig holds clickhouse enablement status needed by admin log queries/switching. +type ClickHouseConfig struct { + Enabled bool `config:"enabled" env:"CLICKHOUSE_ENABLED" default:"false" autoEnable:"CLICKHOUSE_HOST"` +} diff --git a/backend/plugins/domain/admin/model/dto.go b/backend/plugins/domain/admin/model/dto.go new file mode 100644 index 00000000..5a15eecd --- /dev/null +++ b/backend/plugins/domain/admin/model/dto.go @@ -0,0 +1,382 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package model + +import ( + "Wavelet/pkg/logger" + "fmt" + "math" + "time" +) + +const ( + binaryKB = 0 + binaryMB = 1 + binaryGB = 2 + valueThreshold = 10 + maxStringLength = 200 +) + +// FormatBytes renders a byte count using binary units. +func FormatBytes(bytes uint64) string { + const unit = 1024 + if bytes < unit { + return fmt.Sprintf("%d B", bytes) + } + div, exp := int64(unit), 0 + for n := bytes / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + value := float64(bytes) / float64(div) + var suffix string + switch exp { + case binaryKB: + suffix = "KiB" + case binaryMB: + suffix = "MiB" + case binaryGB: + suffix = "GiB" + default: + suffix = "TiB" + } + + if value == math.Trunc(value) { + if value >= valueThreshold { + return fmt.Sprintf("%.0f %s", value, suffix) + } + return fmt.Sprintf("%.1f %s", value, suffix) + } + return fmt.Sprintf("%.1f %s", value, suffix) +} + +// TruncateDisplayValue caps oversized cell values before they reach the console UI. +func TruncateDisplayValue(value string) string { + runes := []rune(value) + if len(runes) > maxStringLength { + return string(runes[:maxStringLength]) + "..." + } + return value +} + +// DBOverviewResponse 数据库运行概览响应结构体 +type DBOverviewResponse struct { + Type string `json:"type"` + Version string `json:"version"` + Name string `json:"name"` + Size string `json:"size"` + TableCount int64 `json:"table_count"` + Connections int64 `json:"connections"` +} + +// GetTableDataRequest 分页拉取表数据请求结构体 +type GetTableDataRequest struct { + Table string `form:"table" binding:"required"` + Page int `form:"page,default=1"` + PageSize int `form:"pageSize,default=10"` +} + +// TableDataResponse 动态数据表响应结构体 +type TableDataResponse struct { + Columns []string `json:"columns"` + Total int64 `json:"total"` + Results []map[string]interface{} `json:"results"` +} + +// ExecuteSQLRequest 执行自定义 SQL 请求结构体 +type ExecuteSQLRequest struct { + SQL string `json:"sql" binding:"required"` +} + +// ExecuteSQLResponse 执行自定义 SQL 响应结构体 +type ExecuteSQLResponse struct { + Type string `json:"type"` // "select" 或 "exec" + Columns []string `json:"columns,omitempty"` + Results []map[string]interface{} `json:"results,omitempty"` + AffectedRows int64 `json:"affected_rows"` + ExecutionTimeMs int64 `json:"execution_time_ms"` +} + +// DatabaseInfoResponse 数据库信息响应结构体 +type DatabaseInfoResponse struct { + Type string `json:"type"` + Name string `json:"name"` + Version string `json:"version"` +} + +// SystemStatusResponse 系统状态响应结构体 +type SystemStatusResponse struct { + Uptime string `json:"uptime"` + NumGoroutine int `json:"num_goroutine"` + Alloc string `json:"alloc"` + TotalAlloc string `json:"total_alloc"` + Sys string `json:"sys"` + Lookups uint64 `json:"lookups"` + Mallocs uint64 `json:"mallocs"` + Frees uint64 `json:"frees"` + HeapAlloc string `json:"heap_alloc"` + HeapSys string `json:"heap_sys"` + HeapIdle string `json:"heap_idle"` + HeapInuse string `json:"heap_inuse"` + HeapReleased string `json:"heap_released"` + HeapObjects uint64 `json:"heap_objects"` + StackInuse string `json:"stack_inuse"` + StackSys string `json:"stack_sys"` + MSpanInuse string `json:"mspan_inuse"` + MSpanSys string `json:"mspan_sys"` + MCacheInuse string `json:"mcache_inuse"` + MCacheSys string `json:"mcache_sys"` + BuckHashSys string `json:"buck_hash_sys"` + GCSys string `json:"gc_sys"` + OtherSys string `json:"other_sys"` + NextGC string `json:"next_gc"` + LastGCTime string `json:"last_gc_time"` + PauseTotalNs string `json:"pause_total_ns"` + LastPause string `json:"last_pause"` + NumGC uint32 `json:"num_gc"` +} + +// LogDatabaseStatus 日志库状态。 +type LogDatabaseStatus struct { + ActiveDatabase string `json:"active_database"` + Migration string `json:"migration"` + RetentionDays map[string]int `json:"retention_days"` + AvailableTargets []string `json:"available_targets"` +} + +// CreateSystemConfigRequest 创建系统配置请求 +type CreateSystemConfigRequest struct { + Key string `json:"key" binding:"required,max=64"` + Value string `json:"value" binding:"required"` + Type string `json:"type" binding:"required,oneof=system business"` + Visibility int `json:"visibility" binding:"oneof=0 1"` + Description string `json:"description" binding:"max=255"` +} + +// UpdateSystemConfigRequest 更新系统配置请求 +type UpdateSystemConfigRequest struct { + Value string `json:"value" binding:"required"` + Visibility *int `json:"visibility" binding:"omitempty,oneof=0 1"` + Description string `json:"description" binding:"max=255"` +} + +// TestSMTPRequest 测试 SMTP 配置请求 +type TestSMTPRequest struct { + SMTPHost string `json:"smtp_host" binding:"required,max=255"` + SMTPPort int `json:"smtp_port" binding:"required"` + SMTPUsername string `json:"smtp_username" binding:"required,max=255"` + SMTPPassword string `json:"smtp_password" binding:"required,max=255"` + To string `json:"to" binding:"required,email"` +} + +// TestSMTPResponse 测试 SMTP 配置响应 +type TestSMTPResponse struct { + Success bool `json:"success"` + Log string `json:"log"` + Error string `json:"error"` +} + +// UpdateCacheConfigRequest 磁盘缓存策略更新请求 +type UpdateCacheConfigRequest struct { + MaxSizeMB int64 `json:"max_size_mb" binding:"required,min=1"` + TTLMinutes int64 `json:"ttl_minutes" binding:"required,min=0"` + LRUEnabled bool `json:"lru_enabled"` +} + +// CreateTemplateRequest 创建模板请求 +type CreateTemplateRequest struct { + Key string `json:"key" binding:"required,max=80"` + Name string `json:"name" binding:"required,max=100"` + Type string `json:"type" binding:"required,max=20"` + Subject string `json:"subject" binding:"max=255"` + Content string `json:"content" binding:"required"` + Description string `json:"description" binding:"max=255"` +} + +// UpdateTemplateRequest 更新模板请求 +type UpdateTemplateRequest struct { + Name string `json:"name" binding:"required,max=100"` + Type string `json:"type" binding:"required,max=20"` + Subject string `json:"subject" binding:"max=255"` + Content string `json:"content" binding:"required"` + Description string `json:"description" binding:"max=255"` +} + +// DispatchTaskRequest 下发任务请求 +type DispatchTaskRequest struct { + TaskType string `json:"task_type" binding:"required"` + StartTime *time.Time `json:"start_time"` + EndTime *time.Time `json:"end_time"` + UserID *uint64 `json:"user_id"` + Payload string `json:"payload"` +} + +// CreateScheduleRequest 创建定时任务请求 +type CreateScheduleRequest struct { + Name string `json:"name" binding:"required"` + TaskType string `json:"task_type" binding:"required"` + Cron string `json:"cron" binding:"required"` + Payload string `json:"payload"` + IsActive *bool `json:"is_active" binding:"required"` +} + +// UpdateScheduleRequest 修改定时任务请求 +type UpdateScheduleRequest struct { + Name string `json:"name" binding:"required"` + TaskType string `json:"task_type" binding:"required"` + Cron string `json:"cron" binding:"required"` + Payload string `json:"payload"` + IsActive *bool `json:"is_active" binding:"required"` +} + +// ListTaskExecutionsRequest 分页查询任务执行记录请求参数 +type ListTaskExecutionsRequest struct { + Page int `form:"page"` + PageSize int `form:"page_size"` + Status string `form:"status"` + TaskType string `form:"task_type"` + TaskTypes string `form:"task_types"` + TaskTypePrefix string `form:"task_type_prefix"` +} + +// ListUsersRequest 用户列表查询请求 +type ListUsersRequest struct { + Page int `form:"page" binding:"min=1"` + PageSize int `form:"page_size" binding:"min=1,max=100"` + UserID *uint64 `form:"user_id" binding:"omitempty,gt=0"` + Username string `form:"username"` + Email string `form:"email"` +} + +// UserResponse 用户资料响应 +type UserResponse struct { + ID uint64 `json:"id,string"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Email string `json:"email"` + AvatarURL string `json:"avatar_url"` + IsActive bool `json:"is_active"` + IsAdmin bool `json:"is_admin"` + Bio string `json:"bio"` + Phone string `json:"phone"` + Gender string `json:"gender"` + Website string `json:"website"` + Location string `json:"location"` + LastLoginAt time.Time `json:"last_login_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ListUsersResponse 用户列表响应 +type ListUsersResponse struct { + Users []UserResponse `json:"users"` + Total int64 `json:"total"` +} + +// UpdateUserStatusRequest 更新用户状态请求 +type UpdateUserStatusRequest struct { + IsActive bool `json:"is_active"` +} + +// CreateUserRequest 创建用户请求 +type CreateUserRequest struct { + Username string `json:"username" binding:"required,min=3,max=64"` + Password string `json:"password" binding:"required,min=8,max=64"` + Nickname string `json:"nickname" binding:"omitempty,max=64"` + Email string `json:"email" binding:"required,email,max=255"` + IsActive bool `json:"is_active"` + IsAdmin bool `json:"is_admin"` +} + +// UpdateUserRequest 更新用户信息请求 +type UpdateUserRequest struct { + Nickname string `json:"nickname" binding:"max=64"` + Email string `json:"email" binding:"required,email,max=255"` + IsAdmin bool `json:"is_admin"` + Password string `json:"password" binding:"omitempty,min=8,max=64"` +} + +// LogsResponse 历史日志查询响应 +type LogsResponse struct { + Lines []logger.LogEntry `json:"lines"` + HasMore bool `json:"has_more"` + NextCursor int `json:"next_cursor"` // 用于加载更早日志的 cursor +} + +// AccessLogItem 访问日志单条数据 +type AccessLogItem struct { + ID uint64 `json:"id,string"` + TraceID string `json:"trace_id"` + UserID uint64 `json:"user_id,string"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Path string `json:"path"` + Method string `json:"method"` + IP string `json:"ip"` + UserAgent string `json:"user_agent"` + Headers string `json:"headers"` + Status int32 `json:"status"` + Latency int64 `json:"latency"` + CreatedAt string `json:"created_at"` +} + +// AccessLogsResponse 访问日志查询响应 +type AccessLogsResponse struct { + Total uint64 `json:"total"` + List []AccessLogItem `json:"list"` +} + +// TrendItem 趋势图数据点 +type TrendItem struct { + Date string `json:"date"` + Count uint64 `json:"count"` +} + +// BrowserItem 浏览器占比排行 +type BrowserItem struct { + Browser string `json:"browser"` + Count uint64 `json:"count"` +} + +// TopUserItem 活跃用户数据 +type TopUserItem struct { + UserID uint64 `json:"user_id,string"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Count uint64 `json:"count"` +} + +// LogsAnalyticsResponse 访问日志数据分析结果 +type LogsAnalyticsResponse struct { + Trend []TrendItem `json:"trend"` + Browsers []BrowserItem `json:"browsers"` + TopUsers []TopUserItem `json:"top_users"` +} + +// AccessLogQuery carries the raw console filters before they become a contract filter. +type AccessLogQuery struct { + Username string + Path string + StartTime string + EndTime string + Page int + PageSize int +} + +// UpdaterStatus describes the current build and the newest compatible upstream release. +type UpdaterStatus struct { + CurrentVersion string `json:"current_version"` + BuildTime string `json:"build_time"` + LatestVersion string `json:"latest_version"` + UpdateAvailable bool `json:"update_available"` + CanUpgrade bool `json:"can_upgrade"` + Prerelease bool `json:"prerelease"` + ReleaseName string `json:"release_name"` + ReleaseNotes string `json:"release_notes"` + ReleaseURL string `json:"release_url"` + PublishedAt string `json:"published_at"` + UpstreamRepository string `json:"upstream_repository"` + AssetName string `json:"asset_name"` + Platform string `json:"platform"` +} diff --git a/backend/plugins/domain/admin/model/entity.go b/backend/plugins/domain/admin/model/entity.go new file mode 100644 index 00000000..e712a61c --- /dev/null +++ b/backend/plugins/domain/admin/model/entity.go @@ -0,0 +1,214 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package model contains database entities and data transfer objects for the admin domain. +package model + +import ( + "Wavelet/plugins/domain/admin/errs" + "bytes" + "errors" + "strings" + "text/template" + "time" +) + +// 配置键常量 - 所有系统配置的 key 定义 +const ( + ConfigKeyUploadAllowedExtensions = "upload_allowed_extensions" // 允许上传的文件扩展名,逗号分隔 + ConfigKeySiteName = "site_name" // 站点名称 + ConfigKeyPasswordLoginEnabled = "password_login_enabled" // 是否允许密码登录 + ConfigKeyRegistrationEnabled = "registration_enabled" // 是否允许注册 + ConfigKeyPasswordRegisterEnabled = "password_register_enabled" // 是否允许密码注册 + ConfigKeyOIDCLoginEnabled = "oidc_login_enabled" // 是否允许 OIDC 登录 + ConfigKeyMaxAPIKeysPerUser = "max_api_keys_per_user" //nolint:gosec // false positive: config key name, not credentials + ConfigKeyCapLoginEnabled = "cap_login_enabled" // 是否启用登录人机验证 + ConfigKeyCapAutoSolve = "cap_auto_solve" // 打开页面后是否自动开始计算(false 则需用户手动点击) + ConfigKeyCapChallengeCount = "cap_challenge_count" // 客户端需求解的 PoW 难题总数,默认 1,推荐 1~5 + ConfigKeyCapChallengeSize = "cap_challenge_size" // 人机验证盐值长度 + ConfigKeyCapChallengeDifficulty = "cap_challenge_difficulty" // 人机验证 PoW 难度(目标前缀长度) + ConfigKeyCapChallengeTTL = "cap_challenge_ttl_seconds" // 人机验证难题有效时间(秒) + ConfigKeyCapTokenTTL = "cap_token_ttl_seconds" //nolint:gosec // false positive: config key name, not credentials + ConfigKeyServerAddress = "server_address" // 服务器地址 + ConfigKeySMTPHost = "smtp_host" // SMTP 服务器地址 + ConfigKeySMTPPort = "smtp_port" // SMTP 端口 + ConfigKeySMTPUsername = "smtp_username" // SMTP 账户 + ConfigKeySMTPPassword = "smtp_password" // SMTP 访问凭证 + ConfigKeyEmailLoginVerificationEnabled = "email_login_verification_enabled" // 是否启用邮箱登录验证 + ConfigKeyEmailRegisterVerificationEnabled = "email_register_verification_enabled" // 是否启用邮箱注册验证 + ConfigKeyMenuDisplayConfig = "menu_display_config" // 目录显示配置 (JSON 字符串) + ConfigKeySearchEngineIndexingEnabled = "search_engine_indexing_enabled" // 是否允许搜索引擎检索 + ConfigKeyFileAccessWhitelist = "file_access_whitelist" // 免登录访问的文件业务类型白名单 (JSON 数组格式) + ConfigKeyDiskCacheMaxSizeMB = "disk_cache_max_size_mb" // 磁盘缓存最大空间大小 (MB) + ConfigKeyDiskCacheTTLMinutes = "disk_cache_ttl_minutes" // 磁盘缓存默认有效期 (分钟) + ConfigKeyDiskCacheLRUEnabled = "disk_cache_lru_enabled" // 是否启用 LRU 淘汰机制 + ConfigKeyLoginSessionTTLHours = "login_session_ttl_hours" // 登录会话过期时间 (小时) + ConfigKeyUpdateUpstreamRepository = "update_upstream_repository" // GitHub Actions Release 上游仓库 + ConfigKeyStorageConfig = "storage_config" // 文件存储配置 (JSON) + ConfigKeyLogDatabase = "log_database" // 当前日志主库(postgres/sqlite/clickhouse),受保护 + ConfigKeyLogDBMigration = "log_db_migration" // 日志库迁移冻结标记(空/migrating),受保护 + ConfigKeyLogRetentionDaysPostgres = "log_retention_days_postgres" // PostgreSQL 用户访问日志保留天数 + ConfigKeyLogRetentionDaysSQLite = "log_retention_days_sqlite" // SQLite 用户访问日志保留天数 + ConfigKeyLogRetentionDaysClickHouse = "log_retention_days_clickhouse" // ClickHouse 用户访问日志保留天数 +) + +const ( + // ConfigVisibilityHidden 表示配置不通过公共配置接口暴露 + ConfigVisibilityHidden = 0 + // ConfigVisibilityVisible 表示配置通过公共配置接口暴露 + ConfigVisibilityVisible = 1 +) + +// SystemConfig 系统配置实体 +type SystemConfig struct { + Key string `json:"key" gorm:"primaryKey;size:64;not null"` + Value string `json:"value" gorm:"type:text;not null"` + Type string `json:"type" gorm:"size:32;not null;default:'system'"` + Visibility int `json:"visibility" gorm:"not null;default:0"` + Description string `json:"description" gorm:"size:255"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` +} + +// TableName 表名 +func (SystemConfig) TableName() string { + return "w_system_configs" +} + +// Template 邮件/消息模板实体 +type Template struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + Key string `json:"key" gorm:"uniqueIndex;size:80;not null"` + Name string `json:"name" gorm:"size:100;not null"` + Type string `json:"type" gorm:"size:20;not null;default:'email'"` + Subject string `json:"subject" gorm:"size:255"` + Content string `json:"content" gorm:"type:text;not null"` + Description string `json:"description" gorm:"size:255"` + IsSystem bool `json:"is_system" gorm:"index;not null;default:false"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"` +} + +// TableName 表名 +func (Template) TableName() string { + return "w_templates" +} + +// TemplateTypeEmail 邮件模板类型 +const TemplateTypeEmail = "email" + +// Normalize 规范化模板字段 +func (t *Template) Normalize() { + t.Key = strings.TrimSpace(t.Key) + t.Name = strings.TrimSpace(t.Name) + t.Type = strings.ToLower(strings.TrimSpace(t.Type)) + t.Subject = strings.TrimSpace(t.Subject) + t.Content = strings.TrimSpace(t.Content) + t.Description = strings.TrimSpace(t.Description) + if t.Type == "" { + t.Type = TemplateTypeEmail + } +} + +// Validate 校验模板必填字段 +func (t *Template) Validate() error { + t.Normalize() + if t.Key == "" { + return errors.New(errs.TemplateKeyRequired) + } + if t.Name == "" { + return errors.New(errs.TemplateNameRequired) + } + if t.Content == "" { + return errors.New(errs.TemplateContentRequired) + } + return nil +} + +// Render 渲染模板的 Subject 和 Content +func (t *Template) Render(data any) (string, string, error) { + var subject string + if t.Subject != "" { + tmplSubject, err := template.New(t.Key + "_subject").Parse(t.Subject) + if err != nil { + return "", "", err + } + var subBuf bytes.Buffer + if err := tmplSubject.Execute(&subBuf, data); err != nil { + return "", "", err + } + subject = subBuf.String() + } + + tmplContent, err := template.New(t.Key + "_content").Parse(t.Content) + if err != nil { + return "", "", err + } + var bodyBuf bytes.Buffer + if err := tmplContent.Execute(&bodyBuf, data); err != nil { + return "", "", err + } + + return subject, bodyBuf.String(), nil +} + +// Schedule 定时任务配置表 +type Schedule struct { + ID uint64 `json:"id,string" gorm:"primaryKey"` + Name string `json:"name" gorm:"size:128;not null"` + TaskType string `json:"task_type" gorm:"size:64;not null"` + Cron string `json:"cron" gorm:"size:64;not null"` + Payload string `json:"payload" gorm:"type:text"` + IsActive bool `json:"is_active" gorm:"not null;default:true"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` +} + +// TableName 表名 +func (Schedule) TableName() string { + return "w_schedules" +} + +// TaskExecutionStatus 任务执行状态 +type TaskExecutionStatus string + +// 任务执行状态 +const ( + TaskExecutionStatusPending TaskExecutionStatus = "pending" + TaskExecutionStatusRunning TaskExecutionStatus = "running" + TaskExecutionStatusSucceeded TaskExecutionStatus = "succeeded" + TaskExecutionStatusFailed TaskExecutionStatus = "failed" +) + +// TaskExecution 任务执行记录 +type TaskExecution struct { + ID uint64 `json:"id,string" gorm:"primaryKey"` + TaskID string `json:"task_id" gorm:"size:128;uniqueIndex;not null"` + TaskType string `json:"task_type" gorm:"size:64;index;not null"` + TaskName string `json:"task_name" gorm:"size:128"` + Status TaskExecutionStatus `json:"status" gorm:"size:32;index;not null"` + Retryable bool `json:"retryable" gorm:"not null;default:false"` + MaxRetry int `json:"max_retry" gorm:"not null;default:0"` + RetryCount int `json:"retry_count" gorm:"not null;default:0"` + Log string `json:"log" gorm:"type:text"` + ErrorMessage string `json:"error_message" gorm:"type:text"` + Result string `json:"result" gorm:"type:text"` + StartedAt *time.Time `json:"started_at" gorm:"index"` + FinishedAt *time.Time `json:"finished_at"` + Duration int64 `json:"duration" gorm:"comment:耗时毫秒"` + Payload string `json:"payload" gorm:"type:text"` + TriggeredBy string `json:"triggered_by" gorm:"size:32;not null;default:system"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` +} + +// TableName 表名 +func (TaskExecution) TableName() string { + return "w_task_executions" +} + +// TaskExecutionCleanupStats 任务日志清理结果统计 +type TaskExecutionCleanupStats struct { + HighFrequencyDeleted int64 `json:"high_frequency_deleted"` + LowFrequencyDeleted int64 `json:"low_frequency_deleted"` +} diff --git a/backend/plugins/domain/admin/plugin.go b/backend/plugins/domain/admin/plugin.go new file mode 100644 index 00000000..ecbb30b4 --- /dev/null +++ b/backend/plugins/domain/admin/plugin.go @@ -0,0 +1,205 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package admin provides the system management console, diagnostics, audit logging, and configuration hot-reloading domain plugin for Cordis. +package admin + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "Wavelet/core/extpoints" + "Wavelet/pkg/ginutil" + "Wavelet/plugins/domain/admin/handler" + "Wavelet/plugins/domain/admin/model" + "Wavelet/plugins/domain/admin/service" + "context" + "embed" + "reflect" + + "github.com/gin-gonic/gin" +) + +// SystemConfig aliases model.SystemConfig for external compatibility. +type SystemConfig = model.SystemConfig + +//go:embed migrations/*/*.sql +var adminMigrations embed.FS + +// Option configures the admin plugin. +type Option func(*Plugin) + +// Plugin implements core.Plugin to provide system administration and management APIs. +type Plugin struct{} + +// New creates a new admin domain plugin. +func New(opts ...Option) *Plugin { + p := &Plugin{} + for _, opt := range opts { + if opt != nil { + opt(p) + } + } + return p +} + +// Name returns the unique identifier for the admin domain plugin. +func (p *Plugin) Name() string { + return "admin" +} + +// Inject declares required dependencies for the admin domain plugin. +func (p *Plugin) Inject() []reflect.Type { + return []reflect.Type{ + reflect.TypeFor[contracts.DBService](), + reflect.TypeFor[contracts.CacheService](), + reflect.TypeFor[contracts.UserService](), + reflect.TypeFor[contracts.AuthService](), + } +} + +// Manifest returns the plugin metadata. +func (p *Plugin) Manifest() core.Manifest { + return core.Manifest{ + Name: "admin", + Version: "1.0.0", + Description: "System administration console, diagnostic monitoring, and configuration hot-reload plugin", + Author: "Wavelet Team", + } +} + +// DeclareConfig declares configuration bindings consumed by the admin plugin. +func (p *Plugin) DeclareConfig() []core.ConfigBinding { + return []core.ConfigBinding{ + {Prefix: "database", Target: &model.DatabaseConfig{}}, + {Prefix: "clickhouse", Target: &model.ClickHouseConfig{}}, + } +} + +// Apply registers admin routes, tasks, schedules, and settings into the Context. +func (p *Plugin) Apply(ctx *core.Context) error { + var dbCfg model.DatabaseConfig + _ = ctx.Config().Bind("database", &dbCfg) + service.SetDBConfig(dbCfg) + + var chCfg model.ClickHouseConfig + _ = ctx.Config().Bind("clickhouse", &chCfg) + service.SetClickHouseConfig(chCfg) + + // 0. Bind Services reactively + if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil { + service.SetDBService(db) + } else { + core.When[contracts.DBService](ctx, func(db contracts.DBService) { + service.SetDBService(db) + }) + } + if cache, err := core.Inject[contracts.CacheService](ctx); err == nil && cache != nil { + service.SetCacheService(cache) + } else { + core.When[contracts.CacheService](ctx, func(cache contracts.CacheService) { + service.SetCacheService(cache) + }) + } + if user, err := core.Inject[contracts.UserService](ctx); err == nil && user != nil { + service.SetUserService(user) + } else { + core.When[contracts.UserService](ctx, func(user contracts.UserService) { + service.SetUserService(user) + }) + } + if auth, err := core.Inject[contracts.AuthService](ctx); err == nil && auth != nil { + service.SetAuthService(auth) + } else { + core.When[contracts.AuthService](ctx, func(auth contracts.AuthService) { + service.SetAuthService(auth) + }) + } + if task, err := core.Inject[contracts.TaskService](ctx); err == nil && task != nil { + service.SetTaskService(task) + } else { + core.When[contracts.TaskService](ctx, func(task contracts.TaskService) { + service.SetTaskService(task) + }) + } + if storage, err := core.Inject[contracts.StorageService](ctx); err == nil && storage != nil { + service.SetStorageService(storage) + } else { + core.When[contracts.StorageService](ctx, func(storage contracts.StorageService) { + service.SetStorageService(storage) + }) + } + if rc, err := core.Inject[contracts.RiskControlService](ctx); err == nil && rc != nil { + service.SetRiskControlService(rc) + } else { + core.When[contracts.RiskControlService](ctx, func(rc contracts.RiskControlService) { + service.SetRiskControlService(rc) + }) + } + service.SetEventEmitter(ctx.Events().Emit) + + ctx.OnDispose(func() error { + service.ResetServices() + return nil + }) + + // 0a. Dynamic Auth Middlewares + denyAuth := ginutil.AuthUnavailable() + var loginMW gin.HandlerFunc = func(c *gin.Context) { + if authSvc := service.GetAuthService(c.Request.Context()); authSvc != nil { + if mw, ok := authSvc.RequireAuthMiddleware().(gin.HandlerFunc); ok { + mw(c) + return + } + } + denyAuth(c) + } + var adminMW gin.HandlerFunc = func(c *gin.Context) { + if authSvc := service.GetAuthService(c.Request.Context()); authSvc != nil { + if mw, ok := authSvc.RequireAdminMiddleware().(gin.HandlerFunc); ok { + mw(c) + return + } + } + denyAuth(c) + } + + // 0b. Register migrations + ctx.Migrations().Register("admin", adminMigrations) + + // 1. Register Admin HTTP Routes + adminRouter := ctx.Router().Group("/api/v1/admin", loginMW, adminMW) + handler.RegisterRoutes(adminRouter) + + // 2. Register Background Tasks + logSwitchHandler := &service.LogDBSwitchHandler{} + ctx.Task().Register(service.LogDBSwitchTask, func(c context.Context, payload []byte) error { + _, err := logSwitchHandler.Execute(c, payload) + return err + }, extpoints.WithTaskMeta(service.LogDBSwitchMeta)) + + ctx.Task().Register("admin:system_cleanup", func(_ context.Context, _ []byte) error { + return nil + }, + extpoints.WithTaskType("system_cleanup"), + extpoints.WithTaskName("系统垃圾清理"), + extpoints.WithTaskDescription("定期清理未使用上传文件、历史推送记录和过期任务执行日志"), + extpoints.WithTaskCategory("maintenance"), + extpoints.WithTaskRetry(1), + extpoints.WithTaskQueue("default"), + extpoints.WithTaskRetryable(true), + ) + + // 3. Register Cron Schedules + ctx.Schedule().RegisterCron("0 4 * * *", "admin:system_cleanup", map[string]string{"type": "daily"}) + + // 4. Register Settings Schemas + ctx.Settings().Register(extpoints.SettingSchema{ + Key: "admin.system_cleanup_cron", + Default: "0 4 * * *", + Description: "Cron expression for nightly system logs and expired tokens cleanup", + Type: "string", + Category: "maintenance", + }) + + return nil +} diff --git a/backend/plugins/domain/admin/plugin_test.go b/backend/plugins/domain/admin/plugin_test.go new file mode 100644 index 00000000..b0509906 --- /dev/null +++ b/backend/plugins/domain/admin/plugin_test.go @@ -0,0 +1,84 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package admin_test + +import ( + "Wavelet/core" + "Wavelet/plugins/domain/admin" + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAdminPluginUnit(t *testing.T) { + ctx := core.NewContext(context.Background()) + p := admin.New() + assert.Equal(t, "admin", p.Name()) + assert.Equal(t, "1.0.0", p.Manifest().Version) + require.NoError(t, p.Apply(ctx)) + + // Verify routes + routes := ctx.Router().Routes() + assert.NotEmpty(t, routes) + + // Verify tasks + _, ok := ctx.Tasks().Get("admin:system_cleanup") + require.True(t, ok) + + // Verify schedules + sched, ok := ctx.Schedules().Get("admin:system_cleanup") + require.True(t, ok) + assert.Equal(t, "0 4 * * *", sched.Spec) + + // Verify settings + setting, ok := ctx.Settings().Get("admin.system_cleanup_cron") + require.True(t, ok) + assert.Equal(t, "0 4 * * *", setting.Default) +} + +func TestAdminMigrationsIncludeTaskExecutionsAndSchedules(t *testing.T) { + ctx := core.NewContext(context.Background()) + p := admin.New() + require.NoError(t, p.Apply(ctx)) + + entry, ok := ctx.Migrations().Get("admin") + require.True(t, ok, "admin plugin must register migrations") + assert.Equal(t, "admin", entry.PluginID) + + // Verify sqlite migration files include w_schedules and w_task_executions + sqliteDir, err := entry.FS.Open("migrations/sqlite/00001_initial.sql") + require.NoError(t, err) + defer sqliteDir.Close() + + stat, err := sqliteDir.Stat() + require.NoError(t, err) + buf := make([]byte, stat.Size()) + _, err = sqliteDir.Read(buf) + require.NoError(t, err) + content := string(buf) + + assert.Contains(t, content, "CREATE TABLE IF NOT EXISTS w_task_executions") + assert.Contains(t, content, "CREATE TABLE IF NOT EXISTS w_schedules") + assert.Contains(t, content, "CREATE TABLE IF NOT EXISTS w_system_configs") + assert.Contains(t, content, "CREATE TABLE IF NOT EXISTS w_templates") + + // Verify postgres migration files include w_schedules and w_task_executions + pgDir, err := entry.FS.Open("migrations/postgres/00001_initial.sql") + require.NoError(t, err) + defer pgDir.Close() + + stat, err = pgDir.Stat() + require.NoError(t, err) + buf = make([]byte, stat.Size()) + _, err = pgDir.Read(buf) + require.NoError(t, err) + pgContent := string(buf) + + assert.Contains(t, pgContent, "CREATE TABLE IF NOT EXISTS w_task_executions") + assert.Contains(t, pgContent, "CREATE TABLE IF NOT EXISTS w_schedules") + assert.Contains(t, pgContent, "CREATE TABLE IF NOT EXISTS w_system_configs") + assert.Contains(t, pgContent, "CREATE TABLE IF NOT EXISTS w_templates") +} diff --git a/backend/plugins/domain/admin/repository/config_cache.go b/backend/plugins/domain/admin/repository/config_cache.go new file mode 100644 index 00000000..f267b2c6 --- /dev/null +++ b/backend/plugins/domain/admin/repository/config_cache.go @@ -0,0 +1,144 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package repository + +import ( + "Wavelet/pkg/cache/ram" + "Wavelet/plugins/domain/admin/model" + "context" + "encoding/json" + "errors" + "time" + + "gorm.io/gorm" +) + +const ( + // SystemConfigBroadcastChannel broadcasts system config cache updates across nodes. + SystemConfigBroadcastChannel = "system:config_broadcast" + + // SystemConfigInvalidationChannel is kept as an alias for backward compatibility. + SystemConfigInvalidationChannel = SystemConfigBroadcastChannel + + // SystemConfigRedisHashKey is kept for backward compatibility in tests. + SystemConfigRedisHashKey = "system:system_configs" + // SystemConfigVisibleListRedisKey is kept for backward compatibility in tests. + SystemConfigVisibleListRedisKey = "system:visible_configs" + + // ConfigCacheType is the cache type for all system configs. + ConfigCacheType = "config" +) + +// ConfigLoader loads configuration data from the database. +type ConfigLoader struct{} + +// LoadAll loads all system configs from database as CacheItems. +func (ConfigLoader) LoadAll(ctx context.Context, configType string) ([]ram.CacheItem, error) { + configs, err := PreheatSystemConfigs(ctx) + if err != nil { + return nil, err + } + + items := make([]ram.CacheItem, len(configs)) + for i, cfg := range configs { + valBytes, err := json.Marshal(cfg) + if err != nil { + return nil, err + } + items[i] = ram.CacheItem{ + Key: cfg.Key, + Value: string(valBytes), + Type: configType, + TTL: determineTTL(cfg.Key), + } + } + return items, nil +} + +// LoadOne loads a single system config from database as CacheItem. +func (ConfigLoader) LoadOne(ctx context.Context, configType, key string) (ram.CacheItem, error) { + cfg, err := GetSystemConfigByKey(ctx, key) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ram.CacheItem{}, ram.ErrNotFound + } + return ram.CacheItem{}, err + } + valBytes, err := json.Marshal(cfg) + if err != nil { + return ram.CacheItem{}, err + } + return ram.CacheItem{ + Key: cfg.Key, + Value: string(valBytes), + Type: configType, + TTL: determineTTL(cfg.Key), + }, nil +} + +// GetCachedSystemConfig retrieves a single system config with RAM L1 fallback to DB. +func GetCachedSystemConfig(ctx context.Context, key string) (*model.SystemConfig, error) { + if item, ok := ram.Get(ConfigCacheType, key); ok { + var cfg model.SystemConfig + if err := json.Unmarshal([]byte(item.Value), &cfg); err == nil { + return &cfg, nil + } + } + + cfg, err := GetSystemConfigByKey(ctx, key) + if err != nil { + return nil, err + } + + valBytes, err := json.Marshal(cfg) + if err == nil { + ram.Set(ram.CacheItem{ + Key: cfg.Key, + Value: string(valBytes), + Type: ConfigCacheType, + TTL: determineTTL(key), + }) + } + return &cfg, nil +} + +// StopSystemConfigCacheListener stops the cache invalidation listener (kept for backward compatibility). +func StopSystemConfigCacheListener() { +} + +// StartSystemConfigCacheListener starts the cache listener (kept for backward compatibility). +func StartSystemConfigCacheListener() { +} + +func ensureSystemConfigCacheListener() { +} + +func determineTTL(_ string) time.Duration { + return -1 +} + +// InvalidateSystemConfigCache triggers a broadcast to refresh the cache for key. +func InvalidateSystemConfigCache(ctx context.Context, key string) error { + ram.Delete(ConfigCacheType, key) + if cacheSvc := GetCache(ctx); cacheSvc != nil { + _ = cacheSvc.Delete(ctx, "system:config:"+key) + _ = cacheSvc.Delete(ctx, SystemConfigVisibleListRedisKey) + } + return nil +} + +// InvalidateAllSystemConfigCaches triggers a broadcast to refresh the entire config cache. +func InvalidateAllSystemConfigCaches(ctx context.Context) error { + ram.UpdateTypeItems(ConfigCacheType, nil) + if cacheSvc := GetCache(ctx); cacheSvc != nil { + _ = cacheSvc.Delete(ctx, SystemConfigRedisHashKey) + _ = cacheSvc.Delete(ctx, SystemConfigVisibleListRedisKey) + } + return nil +} + +// ResetSystemConfigRAMCacheForTest clears only the process-local RAM cache. +func ResetSystemConfigRAMCacheForTest() { + ram.ResetForTest() +} diff --git a/backend/plugins/domain/admin/repository/db.go b/backend/plugins/domain/admin/repository/db.go new file mode 100644 index 00000000..17eeb322 --- /dev/null +++ b/backend/plugins/domain/admin/repository/db.go @@ -0,0 +1,394 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package repository + +import ( + "Wavelet/plugins/domain/admin/errs" + "Wavelet/plugins/domain/admin/model" + "context" + "database/sql" + "errors" + "fmt" + "os" + "os/exec" + "strings" + "sync" + "time" +) + +const ( + defaultSQLiteDBPath = "./data/wavelet.db" + logDBNameSQLite = "sqlite" +) + +var ( + dbConfigMu sync.RWMutex + dbConfig = model.DatabaseConfig{ + SQLitePath: defaultSQLiteDBPath, + } +) + +// SetDBConfig sets the database configuration. +func SetDBConfig(cfg model.DatabaseConfig) { + dbConfigMu.Lock() + defer dbConfigMu.Unlock() + dbConfig = cfg +} + +// GetDBConfig gets the database configuration. +func GetDBConfig() model.DatabaseConfig { + dbConfigMu.RLock() + defer dbConfigMu.RUnlock() + return dbConfig +} + +// sqliteDatabasePath resolves the effective SQLite file path from configuration. +func sqliteDatabasePath() string { + name := GetDBConfig().SQLitePath + if name == "" { + name = defaultSQLiteDBPath + } + return name +} + +// QuoteTableName escapes a raw identifier for use inside a quoted SQL fragment. +func QuoteTableName(table string) string { + return `"` + strings.ReplaceAll(table, `"`, `""`) + `"` +} + +// GetSQLiteOverview collects the SQLite runtime overview. +func GetSQLiteOverview(ctx context.Context) (model.DBOverviewResponse, error) { + gormDB := GetDB(ctx) + if gormDB == nil { + return model.DBOverviewResponse{}, errs.ErrDatabaseUninitialized + } + + name := sqliteDatabasePath() + + var version string + var ver string + if err := gormDB.Raw("SELECT sqlite_version()").Scan(&ver).Error; err == nil { + version = "SQLite " + ver + } else { + version = "SQLite" + } + + var sizeStr string + if fi, err := os.Stat(name); err == nil { + size := fi.Size() + if size < 0 { + size = 0 + } + sizeStr = model.FormatBytes(uint64(size)) + } else { + sizeStr = "0 B" + } + + var tableCount int64 + if err := gormDB.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&tableCount).Error; err != nil { + tableCount = 0 + } + + var connCount int64 + if sqlDB, err := gormDB.DB(); err == nil { + connCount = int64(sqlDB.Stats().OpenConnections) + } else { + connCount = 1 + } + + return model.DBOverviewResponse{ + Type: logDBNameSQLite, + Version: version, + Name: name, + Size: sizeStr, + TableCount: tableCount, + Connections: connCount, + }, nil +} + +// GetPostgresOverview collects the PostgreSQL runtime overview. +func GetPostgresOverview(ctx context.Context) (model.DBOverviewResponse, error) { + gormDB := GetDB(ctx) + if gormDB == nil { + return model.DBOverviewResponse{}, errs.ErrDatabaseUninitialized + } + + name := GetDBConfig().Database + + var version string + var ver string + if err := gormDB.Raw("SELECT version()").Scan(&ver).Error; err == nil { + version = ver + } else { + version = "PostgreSQL" + } + + var sizeStr string + var sizeBytes sql.NullInt64 + if err := gormDB.Raw("SELECT pg_database_size(current_database())").Scan(&sizeBytes).Error; err == nil && sizeBytes.Valid { + size := sizeBytes.Int64 + if size < 0 { + size = 0 + } + sizeStr = model.FormatBytes(uint64(size)) + } else { + sizeStr = "0 B" + } + + var tableCount int64 + if err := gormDB.Raw("SELECT count(*) FROM information_schema.tables WHERE table_schema = current_schema()").Scan(&tableCount).Error; err != nil { + tableCount = 0 + } + + var connCount int64 + var pgc sql.NullInt64 + if err := gormDB.Raw("SELECT count(*) FROM pg_stat_activity WHERE datname = current_database()").Scan(&pgc).Error; err == nil && pgc.Valid { + connCount = pgc.Int64 + } else { + if sqlDB, err := gormDB.DB(); err == nil { + connCount = int64(sqlDB.Stats().OpenConnections) + } else { + connCount = 1 + } + } + + return model.DBOverviewResponse{ + Type: "postgres", + Version: version, + Name: name, + Size: sizeStr, + TableCount: tableCount, + Connections: connCount, + }, nil +} + +// ListDatabaseTableNames returns every user table of the active database. +func ListDatabaseTableNames(ctx context.Context) ([]string, error) { + gormDB := GetDB(ctx) + if gormDB == nil { + return nil, errs.ErrDatabaseUninitialized + } + + var tables []string + var err error + + if !GetDBConfig().Enabled { + err = gormDB.Raw("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name").Scan(&tables).Error + } else { + err = gormDB.Raw("SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema() ORDER BY table_name").Scan(&tables).Error + } + if err != nil { + return nil, err + } + return tables, nil +} + +// CountDatabaseTableRows counts the rows of the quoted table. +func CountDatabaseTableRows(ctx context.Context, quotedTable string) (int64, error) { + gormDB := GetDB(ctx) + if gormDB == nil { + return 0, errs.ErrDatabaseUninitialized + } + + var total int64 + if err := gormDB.Raw("SELECT count(*) FROM " + quotedTable).Scan(&total).Error; err != nil { + return 0, errs.NewInvalidInputError(err.Error()) + } + return total, nil +} + +// QueryDatabaseTableRows loads one page of raw rows from the quoted table. +func QueryDatabaseTableRows( + ctx context.Context, + quotedTable string, + limit int, + offset int, +) ([]string, []map[string]any, error) { + gormDB := GetDB(ctx) + if gormDB == nil { + return nil, nil, errs.ErrDatabaseUninitialized + } + + rows, err := gormDB.Raw("SELECT * FROM "+quotedTable+" LIMIT ? OFFSET ?", limit, offset).Rows() + if err != nil { + return nil, nil, errs.NewInvalidInputError(err.Error()) + } + defer func() { + _ = rows.Close() + }() + + cols, err := rows.Columns() + if err != nil { + return nil, nil, err + } + + results, err := scanTableRows(rows, cols) + if err != nil { + return nil, nil, err + } + return cols, results, nil +} + +// RunSelectSQL executes an arbitrary select-like statement. +func RunSelectSQL(ctx context.Context, sqlStr string) ([]string, []map[string]any, error) { + gormDB := GetDB(ctx) + if gormDB == nil { + return nil, nil, errs.ErrDatabaseUninitialized + } + + rows, err := gormDB.Raw(sqlStr).Rows() + if err != nil { + return nil, nil, errs.NewInvalidInputError(err.Error()) + } + defer func() { + _ = rows.Close() + }() + + cols, err := rows.Columns() + if err != nil { + return nil, nil, err + } + + results, err := scanTableRows(rows, cols) + if err != nil { + return nil, nil, err + } + return cols, results, nil +} + +// RunMutationSQL executes a non-query statement and reports affected rows. +func RunMutationSQL(ctx context.Context, sqlStr string) (int64, error) { + gormDB := GetDB(ctx) + if gormDB == nil { + return 0, errs.ErrDatabaseUninitialized + } + tx := gormDB.Exec(sqlStr) + if tx.Error != nil { + return 0, errs.NewInvalidInputError(tx.Error.Error()) + } + return tx.RowsAffected, nil +} + +// scanTableRows decodes every row of the result set into a column keyed map. +func scanTableRows(rows *sql.Rows, cols []string) ([]map[string]any, error) { + results := make([]map[string]any, 0) + for rows.Next() { + row, err := scanRowAsMap(rows, cols) + if err != nil { + return nil, err + } + results = append(results, row) + } + return results, nil +} + +// scanRowAsMap decodes a single row, normalising driver byte slices to strings. +func scanRowAsMap(rows *sql.Rows, cols []string) (map[string]any, error) { + columns := make([]any, len(cols)) + columnPointers := make([]any, len(cols)) + for i := range columns { + columnPointers[i] = &columns[i] + } + + if err := rows.Scan(columnPointers...); err != nil { + return nil, err + } + + rowMap := make(map[string]any) + for i, colName := range cols { + val := columns[i] + if b, ok := val.([]byte); ok { + rowMap[colName] = string(b) + continue + } + rowMap[colName] = val + } + return rowMap, nil +} + +// GetSQLiteInfo collects the SQLite type/name/version triple. +func GetSQLiteInfo(ctx context.Context) model.DatabaseInfoResponse { + cfg := GetDBConfig() + info := model.DatabaseInfoResponse{ + Type: logDBNameSQLite, + Name: cfg.SQLitePath, + Version: "SQLite", + } + if info.Name == "" { + info.Name = defaultSQLiteDBPath + } + gormDB := GetDB(ctx) + if gormDB == nil { + return info + } + var ver string + if err := gormDB.Raw("SELECT sqlite_version()").Scan(&ver).Error; err == nil && ver != "" { + info.Version = "SQLite " + ver + } + return info +} + +// GetPostgresInfo collects the PostgreSQL type/name/version triple. +func GetPostgresInfo(ctx context.Context) model.DatabaseInfoResponse { + cfg := GetDBConfig() + info := model.DatabaseInfoResponse{ + Type: "postgres", + Name: cfg.Database, + Version: "PostgreSQL", + } + gormDB := GetDB(ctx) + if gormDB == nil { + return info + } + var ver string + if err := gormDB.Raw("SELECT version()").Scan(&ver).Error; err == nil && ver != "" { + info.Version = ver + } + return info +} + +// OpenSQLiteExportFile opens the active SQLite database file together with its stat info. +func OpenSQLiteExportFile() (*os.File, os.FileInfo, error) { + // export db file path is trusted + f, err := os.Open(sqliteDatabasePath()) + if err != nil { + return nil, nil, fmt.Errorf("%s: %w", errs.ErrOpenDatabaseFileFailed, err) + } + + fi, err := f.Stat() + if err != nil { + _ = f.Close() + return nil, nil, fmt.Errorf("%s: %w", errs.ErrReadDatabaseFileInfoFailed, err) + } + return f, fi, nil +} + +// NewPgDumpCommand builds the streaming pg_dump command for the active database. +func NewPgDumpCommand(ctx context.Context) (*exec.Cmd, string, error) { + dbCfg := GetDBConfig() + + pgDumpPath, err := exec.LookPath("pg_dump") + if err != nil { + return nil, "", errors.New(errs.ErrPgDumpUnavailable) + } + + args := []string{ + "--no-password", + "-h", dbCfg.Host, + "-p", fmt.Sprintf("%d", dbCfg.Port), + "-U", dbCfg.Username, + dbCfg.Database, + } + + //nolint:gosec // pg_dump args are constructed from validated db config + cmd := exec.CommandContext(ctx, pgDumpPath, args...) + if dbCfg.Password != "" { + cmd.Env = append(os.Environ(), "PGPASSWORD="+dbCfg.Password) + } else { + cmd.Env = os.Environ() + } + + fileName := fmt.Sprintf("wavelet_%s.sql", time.Now().Format("20060102_150405")) + return cmd, fileName, nil +} diff --git a/backend/plugins/domain/admin/repository/flush_log_test.go b/backend/plugins/domain/admin/repository/flush_log_test.go new file mode 100644 index 00000000..09125581 --- /dev/null +++ b/backend/plugins/domain/admin/repository/flush_log_test.go @@ -0,0 +1,157 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package repository_test + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + "github.com/redis/go-redis/v9/maintnotifications" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + + "Wavelet/core" + "Wavelet/core/contracts" + "Wavelet/plugins/domain/admin/model" + "Wavelet/plugins/domain/admin/repository" + cacheplugin "Wavelet/plugins/infra/cache" +) + +// stubDBService 用内存 SQLite 满足 DBService 契约,隔离外部依赖。 +type stubDBService struct{ db *gorm.DB } + +func (s stubDBService) GORM() *gorm.DB { return s.db } + +func (s stubDBService) DB(context.Context) *gorm.DB { return s.db } + +func (s stubDBService) Named(string) *gorm.DB { return s.db } + +// newFlushLogTestCache 构建真实多层缓存服务并注入 admin 插件上下文。 +func newFlushLogTestCache(t *testing.T) (contracts.CacheService, *miniredis.Miniredis, func()) { + t.Helper() + mr, err := miniredis.Run() + require.NoError(t, err) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr(), MaintNotificationsConfig: &maintnotifications.Config{Mode: maintnotifications.ModeDisabled}}) + + p := cacheplugin.New(cacheplugin.WithRedis(rdb), cacheplugin.WithRAMCapacity(64)) + ctx := core.NewContext(context.Background()) + ctx.Config().SetSource(core.NewMapSource(map[string]any{ + "redis": map[string]any{ + "enabled": true, + "addrs": []string{mr.Addr()}, + }, + })) + require.NoError(t, ctx.Config().Resolve()) + require.NoError(t, p.Apply(ctx)) + svc, err := core.Inject[contracts.CacheService](ctx) + require.NoError(t, err) + + repository.SetCacheService(svc) + cleanup := func() { + repository.SetCacheService(nil) + _ = rdb.Close() + mr.Close() + } + return svc, mr, cleanup +} + +// TestFlushTaskExecutionLogPropagatesCacheError 回归:缓存读取失败(非未命中)时, +// FlushTaskExecutionLog 必须返回错误而不是静默吞掉日志并误报成功(nilerr 修复)。 +func TestFlushTaskExecutionLogPropagatesCacheError(t *testing.T) { + _, mr, cleanup := newFlushLogTestCache(t) + defer cleanup() + + ctx := context.Background() + const taskID = "flush-err-task" + + // 先缓冲一行日志 + require.NoError(t, repository.AppendTaskExecutionLog(ctx, taskID, "step-1 ok")) + + // 关闭 miniredis 模拟缓存基础设施故障(读取出错而非未命中) + mr.Close() + + err := repository.FlushTaskExecutionLog(ctx, taskID) + assert.Error(t, err, "缓存故障时必须返回错误,防止缓冲日志被静默丢弃") +} + +// TestFlushTaskExecutionLogCacheMissIsNoop 回归:任务无缓冲日志(未命中)时应为空操作成功。 +func TestFlushTaskExecutionLogCacheMissIsNoop(t *testing.T) { + _, _, cleanup := newFlushLogTestCache(t) + defer cleanup() + + ctx := context.Background() + assert.NoError(t, repository.FlushTaskExecutionLog(ctx, "missing-task")) +} + +// TestFlushTaskExecutionLogPersistsAndClears 验证正常路径:缓冲日志写入执行记录后清理缓存。 +func TestFlushTaskExecutionLogPersistsAndClears(t *testing.T) { + svc, _, cleanup := newFlushLogTestCache(t) + defer cleanup() + + ctx := context.Background() + const taskID = "flush-ok-task" + require.NoError(t, repository.AppendTaskExecutionLog(ctx, taskID, "done")) + + sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, sqliteDB.AutoMigrate(&model.TaskExecution{})) + repository.SetDBService(stubDBService{db: sqliteDB}) + defer repository.SetDBService(nil) + gormDB := sqliteDB + exec := &model.TaskExecution{TaskID: taskID, TaskType: "upload:test", TaskName: "t", Status: model.TaskExecutionStatusSucceeded} + require.NoError(t, gormDB.Create(exec).Error) + + require.NoError(t, repository.FlushTaskExecutionLog(ctx, taskID)) + + var got model.TaskExecution + require.NoError(t, gormDB.First(&got, exec.ID).Error) + assert.Contains(t, got.Log, "done") + + // 缓存中的缓冲日志应已被清理 + var buf string + err = svc.Get(ctx, repository.TaskExecutionLogRedisKey(taskID), &buf) + assert.True(t, errors.Is(err, contracts.ErrCacheMiss), "flush 后缓存应清空, got %v", err) +} + +// readFailCache 读取永远报错而写入成功,用于区分「未命中」与「缓存故障」两种语义。 +type readFailCache struct { + writes []string +} + +func (c *readFailCache) Get(context.Context, string, any) error { + return errors.New("cache unavailable") +} + +func (c *readFailCache) Set(_ context.Context, key string, value any, _ time.Duration) error { + c.writes = append(c.writes, fmt.Sprintf("%s=%v", key, value)) + return nil +} + +func (c *readFailCache) Delete(context.Context, string) error { return nil } + +func (c *readFailCache) GetOrSet(context.Context, string, any, time.Duration, func() (any, error)) error { + return errors.New("cache unavailable") +} + +func (c *readFailCache) Invalidate(context.Context, string) error { return nil } + +// TestAppendTaskExecutionLogKeepsBufferOnCacheReadError 回归:缓存读取失败(而非未命中)时 +// 不得把「读不到」当成「没有缓冲」继续写入,否则整段任务日志会被最新一行覆盖丢失。 +func TestAppendTaskExecutionLogKeepsBufferOnCacheReadError(t *testing.T) { + fake := &readFailCache{} + repository.SetCacheService(fake) + defer repository.SetCacheService(nil) + + err := repository.AppendTaskExecutionLog(context.Background(), "append-err-task", "step-2") + assert.Error(t, err, "缓存故障必须上抛,而不是覆盖缓冲") + assert.Empty(t, fake.writes, "读取失败时不得写入,避免覆盖已缓冲日志") +} diff --git a/backend/plugins/domain/admin/repository/log.go b/backend/plugins/domain/admin/repository/log.go new file mode 100644 index 00000000..10d1ee20 --- /dev/null +++ b/backend/plugins/domain/admin/repository/log.go @@ -0,0 +1,53 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package repository + +import ( + "Wavelet/pkg/util" + "context" +) + +// UserDisplayName is the minimal user projection needed to decorate access log rows. +type UserDisplayName struct { + Username string + Nickname string +} + +// SearchUserIDsByUsername is the database fallback used when the user contract is absent. +func SearchUserIDsByUsername(ctx context.Context, username string) ([]uint64, error) { + gormDB := GetDB(ctx) + if gormDB == nil { + return nil, nil + } + + var ids []uint64 + if err := gormDB.Table("w_users"). + Where("username LIKE ? ESCAPE '\\'", "%"+util.EscapeLike(username)+"%"). + Pluck("id", &ids).Error; err != nil { + return nil, err + } + return ids, nil +} + +// LoadUserDisplayNames resolves usernames and nicknames for the given ids. +func LoadUserDisplayNames(ctx context.Context, userIDs []uint64) (map[uint64]UserDisplayName, error) { + result := make(map[uint64]UserDisplayName, len(userIDs)) + gormDB := GetDB(ctx) + if gormDB == nil || len(userIDs) == 0 { + return result, nil + } + + var users []struct { + ID uint64 + Username string + Nickname string + } + if err := gormDB.Table("w_users").Where("id IN ?", userIDs).Find(&users).Error; err != nil { + return nil, err + } + for _, u := range users { + result[u.ID] = UserDisplayName{Username: u.Username, Nickname: u.Nickname} + } + return result, nil +} diff --git a/backend/plugins/domain/admin/repository/repository.go b/backend/plugins/domain/admin/repository/repository.go new file mode 100644 index 00000000..6118614a --- /dev/null +++ b/backend/plugins/domain/admin/repository/repository.go @@ -0,0 +1,421 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package repository provides persistence operations for the admin domain. +package repository + +import ( + "Wavelet/core/contracts" + "Wavelet/pkg/cache/ram" + "Wavelet/pkg/logger" + "Wavelet/plugins/domain/admin/errs" + "Wavelet/plugins/domain/admin/model" + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "sync" + "time" + + "github.com/shopspring/decimal" + "gorm.io/gorm" +) + +const ( + configTypeSystem = "system" +) + +var ( + repoMu sync.RWMutex + dbService contracts.DBService + cacheService contracts.CacheService +) + +// SetDBService injects the DBService contract. +func SetDBService(s contracts.DBService) { + repoMu.Lock() + defer repoMu.Unlock() + dbService = s +} + +// SetCacheService injects the CacheService contract. +func SetCacheService(s contracts.CacheService) { + repoMu.Lock() + defer repoMu.Unlock() + cacheService = s +} + +// ResetServices clears injected persistence services. +func ResetServices() { + repoMu.Lock() + defer repoMu.Unlock() + dbService = nil + cacheService = nil +} + +// GetDB returns the GORM DB instance bound to the context if available. +func GetDB(ctx context.Context) *gorm.DB { + repoMu.RLock() + defer repoMu.RUnlock() + if dbService == nil { + return nil + } + return dbService.DB(ctx) +} + +// GetCache returns the unified CacheService instance. +func GetCache(_ context.Context) contracts.CacheService { + repoMu.RLock() + defer repoMu.RUnlock() + return cacheService +} + +// PreheatSystemConfigs loads all system configs from database. +func PreheatSystemConfigs(ctx context.Context) ([]model.SystemConfig, error) { + database := GetDB(ctx) + if database == nil { + return nil, errors.New(errs.ErrDatabaseNotInitialized) + } + + var configs []model.SystemConfig + if err := database.Find(&configs).Error; err != nil { + return nil, err + } + return configs, nil +} + +// PreheatSystemConfigByKey loads a single config key from database. +func PreheatSystemConfigByKey(ctx context.Context, key string) (model.SystemConfig, error) { + database := GetDB(ctx) + if database == nil { + return model.SystemConfig{}, errors.New(errs.ErrDatabaseNotInitialized) + } + + var sc model.SystemConfig + if err := database.Where("key = ?", key).First(&sc).Error; err != nil { + return model.SystemConfig{}, err + } + return sc, nil +} + +// GetSystemConfigByGroup queries a configuration by Type and Key. +func GetSystemConfigByGroup(ctx context.Context, configType, key string) (model.SystemConfig, error) { + ensureSystemConfigCacheListener() + + if item, ok := ram.Get(configType, key); ok { + var sc model.SystemConfig + if err := json.Unmarshal([]byte(item.Value), &sc); err == nil { + return sc, nil + } + } + + database := GetDB(ctx) + if database == nil { + return model.SystemConfig{}, errors.New(errs.ErrDatabaseNotInitialized) + } + + var sc model.SystemConfig + if err := database.Where("key = ?", key).First(&sc).Error; err != nil { + return model.SystemConfig{}, err + } + + valBytes, err := json.Marshal(sc) + if err == nil { + ram.Set(ram.CacheItem{ + Key: sc.Key, + Value: string(valBytes), + Type: configType, + TTL: determineTTL(sc.Key), + }) + } + + return sc, nil +} + +// GetSystemConfigByKey queries config by key. +func GetSystemConfigByKey(ctx context.Context, key string) (model.SystemConfig, error) { + return GetSystemConfigByGroup(ctx, ConfigCacheType, key) +} + +// ListSystemConfigsByKeys loads multiple config keys. +func ListSystemConfigsByKeys(ctx context.Context, keys []string) (map[string]model.SystemConfig, error) { + if len(keys) == 0 { + return map[string]model.SystemConfig{}, nil + } + + ensureSystemConfigCacheListener() + + result := make(map[string]model.SystemConfig, len(keys)) + missing := make([]string, 0, len(keys)) + + for _, key := range keys { + if item, ok := ram.Get(ConfigCacheType, key); ok { + var sc model.SystemConfig + if err := json.Unmarshal([]byte(item.Value), &sc); err == nil { + result[key] = sc + continue + } + } + missing = append(missing, key) + } + + if len(missing) == 0 { + return result, nil + } + + database := GetDB(ctx) + if database == nil { + return nil, errors.New(errs.ErrDatabaseNotInitialized) + } + + var configs []model.SystemConfig + if err := database.Where("key IN ?", missing).Find(&configs).Error; err != nil { + return nil, err + } + + for i := range configs { + valBytes, err := json.Marshal(configs[i]) + if err == nil { + ram.Set(ram.CacheItem{ + Key: configs[i].Key, + Value: string(valBytes), + Type: ConfigCacheType, + TTL: determineTTL(configs[i].Key), + }) + } + result[configs[i].Key] = configs[i] + } + + return result, nil +} + +// InvalidateVisibleSystemConfigsCache clears the cached public config list. +func InvalidateVisibleSystemConfigsCache(ctx context.Context) error { + return InvalidateAllSystemConfigCaches(ctx) +} + +// ListVisibleSystemConfigs queries visible configs using local cache store. +func ListVisibleSystemConfigs(ctx context.Context) ([]model.SystemConfig, error) { + ensureSystemConfigCacheListener() + + items := ram.GetTypeItems(ConfigCacheType) + if len(items) > 0 { + var list []model.SystemConfig + for _, item := range items { + var sc model.SystemConfig + if err := json.Unmarshal([]byte(item.Value), &sc); err == nil { + if sc.Visibility == model.ConfigVisibilityVisible { + list = append(list, sc) + } + } + } + return list, nil + } + + database := GetDB(ctx) + if database == nil { + return nil, errors.New(errs.ErrDatabaseNotInitialized) + } + + var configs []model.SystemConfig + if err := database.Where("visibility = ?", model.ConfigVisibilityVisible).Find(&configs).Error; err != nil { + return nil, err + } + + for _, cfg := range configs { + valBytes, err := json.Marshal(cfg) + if err == nil { + ram.Set(ram.CacheItem{ + Key: cfg.Key, + Value: string(valBytes), + Type: ConfigCacheType, + TTL: determineTTL(cfg.Key), + }) + } + } + + return configs, nil +} + +// GetIntByKey queries config and converts to int. +func GetIntByKey(ctx context.Context, key string) (int, error) { + sc, err := GetSystemConfigByKey(ctx, key) + if err != nil { + return 0, err + } + + value, err := strconv.Atoi(sc.Value) + if err != nil { + return 0, fmt.Errorf(errs.ErrConfigIntParseFailed, key, sc.Value, err) + } + + return value, nil +} + +// GetDecimalByKey queries config and converts to decimal.Decimal. +func GetDecimalByKey(ctx context.Context, key string, precision int32) (decimal.Decimal, error) { + sc, err := GetSystemConfigByKey(ctx, key) + if err != nil { + return decimal.Zero, err + } + + value, err := decimal.NewFromString(sc.Value) + if err != nil { + return decimal.Zero, fmt.Errorf(errs.ErrConfigDecimalParseFailed, key, sc.Value, err) + } + + return value.Truncate(precision), nil +} + +// GetBoolByKey queries config and converts to bool. +func GetBoolByKey(ctx context.Context, key string) (bool, error) { + sc, err := GetSystemConfigByKey(ctx, key) + if err != nil { + return false, err + } + + value, err := strconv.ParseBool(sc.Value) + if err != nil { + return false, fmt.Errorf(errs.ErrConfigBoolParseFailed, key, sc.Value, err) + } + + return value, nil +} + +// GetMenuDisplayConfig queries and parses menu config. +func GetMenuDisplayConfig(ctx context.Context) (map[string]bool, error) { + sc, err := GetSystemConfigByKey(ctx, model.ConfigKeyMenuDisplayConfig) + if err != nil { + return nil, err + } + + config := make(map[string]bool) + if sc.Value == "" || sc.Value == "{}" { + return config, nil + } + + if err := json.Unmarshal([]byte(sc.Value), &config); err != nil { + return nil, fmt.Errorf(errs.ErrParseMenuDisplayConfigFailed, err) + } + + return config, nil +} + +// ListAdminSystemConfigs returns all configs, optionally filtered by type. +func ListAdminSystemConfigs(ctx context.Context, configType string) ([]model.SystemConfig, error) { + query := GetDB(ctx).Order("created_at DESC") + if configType != "" { + query = query.Where("type = ?", configType) + } + var configs []model.SystemConfig + if err := query.Find(&configs).Error; err != nil { + return nil, err + } + return configs, nil +} + +// GetAdminSystemConfigByKey loads a config directly from DB. +func GetAdminSystemConfigByKey(ctx context.Context, key string) (model.SystemConfig, error) { + var config model.SystemConfig + if err := GetDB(ctx).Where("key = ?", key).First(&config).Error; err != nil { + return model.SystemConfig{}, err + } + return config, nil +} + +// SystemConfigExists reports whether a config key already exists. +func SystemConfigExists(ctx context.Context, key string) (bool, error) { + var existing model.SystemConfig + err := GetDB(ctx).Where("key = ?", key).First(&existing).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} + +// CreateSystemConfigRecord persists a new system config row. +func CreateSystemConfigRecord(ctx context.Context, config *model.SystemConfig) error { + return GetDB(ctx).Create(config).Error +} + +// UpdateSystemConfigFields applies partial updates to a system config row. +func UpdateSystemConfigFields(ctx context.Context, config *model.SystemConfig, updates map[string]any) error { + return GetDB(ctx).Model(config).Updates(updates).Error +} + +// UpdateSystemConfigTx applies the config row updates inside a transaction and, when +// resolveTaskType is not empty, marks that task type's failed executions as succeeded +// within the same transaction. +func UpdateSystemConfigTx( + ctx context.Context, + config *model.SystemConfig, + updates map[string]any, + resolveTaskType string, + resolveResult string, +) error { + database := GetDB(ctx) + if database == nil { + return errors.New(errs.ErrDatabaseServiceNotAvailable) + } + + return database.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(config).Updates(updates).Error; err != nil { + return err + } + if resolveTaskType == "" { + return nil + } + if err := MarkFailedTaskExecutionsSucceededTx(tx, resolveTaskType, resolveResult, time.Now()); err != nil { + logger.ErrorF(ctx, errs.ErrAutoResolveMigrationTaskFailed, err) + } + return nil + }) +} + +// SaveOrUpdateSystemConfig creates or updates a config row and invalidates cache. +func SaveOrUpdateSystemConfig(ctx context.Context, key, value string) error { + var sc model.SystemConfig + err := GetDB(ctx).Where("key = ?", key).First(&sc).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + if errors.Is(err, gorm.ErrRecordNotFound) { + sc = model.SystemConfig{ + Key: key, + Value: value, + Type: configTypeSystem, + Visibility: model.ConfigVisibilityHidden, + } + if err := GetDB(ctx).Create(&sc).Error; err != nil { + return err + } + } else { + sc.Value = value + if err := GetDB(ctx).Save(&sc).Error; err != nil { + return err + } + } + return InvalidateSystemConfigCache(ctx, key) +} + +// CountActiveUploads counts non-deleted rows of the storage upload table. A missing +// database handle yields zero, matching the pre-refactor guard behaviour. +func CountActiveUploads(ctx context.Context) (int64, error) { + gormDB := GetDB(ctx) + if gormDB == nil { + return 0, nil + } + + var uploadCount int64 + if err := gormDB.Table("w_uploads"). + Where("status != ?", "deleted"). + Count(&uploadCount).Error; err != nil { + return 0, fmt.Errorf(errs.ErrCheckExistingUploadsFailed, err) + } + return uploadCount, nil +} diff --git a/backend/plugins/domain/admin/repository/task.go b/backend/plugins/domain/admin/repository/task.go new file mode 100644 index 00000000..c6e65b0a --- /dev/null +++ b/backend/plugins/domain/admin/repository/task.go @@ -0,0 +1,330 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package repository + +import ( + "Wavelet/core/contracts" + "Wavelet/pkg/idgen" + "Wavelet/pkg/util" + "Wavelet/plugins/domain/admin/errs" + "Wavelet/plugins/domain/admin/model" + "context" + "errors" + "fmt" + "strings" + "time" + + "gorm.io/gorm" +) + +const ( + taskExecutionLogRedisKeyPrefix = "task:execution:log:" + taskExecutionLogExpiration = 24 * time.Hour +) + +// CreateScheduleRecord 创建定时任务 +func CreateScheduleRecord(ctx context.Context, schedule *model.Schedule) error { + return GetDB(ctx).Create(schedule).Error +} + +// UpdateScheduleRecord 更新定时任务 +func UpdateScheduleRecord(ctx context.Context, schedule *model.Schedule) error { + return GetDB(ctx).Save(schedule).Error +} + +// DeleteScheduleRecord 删除定时任务 +func DeleteScheduleRecord(ctx context.Context, id uint64) error { + return GetDB(ctx).Delete(&model.Schedule{}, id).Error +} + +// GetScheduleByID 根据 ID 获取定时任务 +func GetScheduleByID(ctx context.Context, id uint64) (*model.Schedule, error) { + var schedule model.Schedule + if err := GetDB(ctx).Where("id = ?", id).First(&schedule).Error; err != nil { + return nil, err + } + return &schedule, nil +} + +// ListSchedulesRecord 获取所有定时任务 +func ListSchedulesRecord(ctx context.Context) ([]model.Schedule, error) { + var schedules []model.Schedule + if err := GetDB(ctx).Order("id DESC").Find(&schedules).Error; err != nil { + return nil, err + } + return schedules, nil +} + +// ListActiveSchedules 获取所有启用的定时任务 +func ListActiveSchedules(ctx context.Context) ([]model.Schedule, error) { + var schedules []model.Schedule + if err := GetDB(ctx).Where("is_active = ?", true).Find(&schedules).Error; err != nil { + return nil, err + } + return schedules, nil +} + +// CreateTaskExecutionRecord 创建任务执行记录 +func CreateTaskExecutionRecord(ctx context.Context, execution *model.TaskExecution) error { + execution.ID = idgen.NextUint64ID() + return GetDB(ctx).Create(execution).Error +} + +// UpdateTaskExecutionRecord 更新任务执行记录,忽略由 Redis 缓冲和归档流程管理的 log 字段。 +func UpdateTaskExecutionRecord(ctx context.Context, execution *model.TaskExecution) error { + return GetDB(ctx).Omit("log").Save(execution).Error +} + +// GetTaskExecutionByTaskID 根据 TaskID 获取执行记录 +func GetTaskExecutionByTaskID(ctx context.Context, taskID string) (*model.TaskExecution, error) { + var execution model.TaskExecution + if err := GetDB(ctx).Where("task_id = ?", taskID).First(&execution).Error; err != nil { + return nil, err + } + loadTaskExecutionLog(ctx, &execution) + return &execution, nil +} + +// GetTaskExecutionByID 根据 ID 获取执行记录 +func GetTaskExecutionByID(ctx context.Context, id uint64) (*model.TaskExecution, error) { + var execution model.TaskExecution + if err := GetDB(ctx).Where("id = ?", id).First(&execution).Error; err != nil { + return nil, err + } + loadTaskExecutionLog(ctx, &execution) + return &execution, nil +} + +// GetLatestTaskExecutionByTaskType returns the most recent execution for a task type. +func GetLatestTaskExecutionByTaskType(ctx context.Context, taskType string) (*model.TaskExecution, bool, error) { + var execution model.TaskExecution + err := GetDB(ctx). + Where("task_type = ?", taskType). + Order("id DESC"). + First(&execution).Error + if err == nil { + loadTaskExecutionLog(ctx, &execution) + return &execution, true, nil + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, false, nil + } + return nil, false, err +} + +// AppendTaskExecutionLog 将日志追加到缓冲,任务完成后再持久化到数据库。 +func AppendTaskExecutionLog(ctx context.Context, taskID, logLine string) error { + cacheSvc := GetCache(ctx) + if cacheSvc == nil { + return errors.New(errs.ErrCacheServiceNotInitialized) + } + + now := time.Now().Format("15:04:05") + line := fmt.Sprintf("[%s] %s\n", now, logLine) + key := TaskExecutionLogRedisKey(taskID) + + var existing string + if err := cacheSvc.Get(ctx, key, &existing); err != nil { + // 只有未命中才代表「尚无缓冲」;其余读取失败若被当作空缓冲继续写入, + // 会用这一行覆盖掉整段已缓冲的任务日志。 + if !errors.Is(err, contracts.ErrCacheMiss) { + return fmt.Errorf("load buffered task execution log: %w", err) + } + } + return cacheSvc.Set(ctx, key, existing+line, taskExecutionLogExpiration) +} + +// FlushTaskExecutionLog 将缓冲中的完整任务日志写入数据库,并在成功后清理缓存。 +func FlushTaskExecutionLog(ctx context.Context, taskID string) error { + cacheSvc := GetCache(ctx) + if cacheSvc == nil { + return errors.New(errs.ErrCacheServiceNotInitialized) + } + + key := TaskExecutionLogRedisKey(taskID) + var logText string + if err := cacheSvc.Get(ctx, key, &logText); err != nil { + // 缓存未命中属于正常情况(任务无输出),其余错误必须上抛, + // 否则缓冲日志会被静默丢弃并误报持久化成功。 + if !errors.Is(err, contracts.ErrCacheMiss) { + return fmt.Errorf("load buffered task execution log: %w", err) + } + return nil + } + if logText == "" { + return nil + } + + gormDB := GetDB(ctx) + if gormDB == nil { + return errors.New(errs.ErrDatabaseNotInitialized) + } + result := gormDB.Model(&model.TaskExecution{}). + Where("task_id = ?", taskID). + Update("log", logText) + if result.Error != nil { + return fmt.Errorf("persist task execution log: %w", result.Error) + } + if result.RowsAffected == 0 { + return fmt.Errorf("persist task execution log: task %q not found", taskID) + } + + _ = cacheSvc.Delete(ctx, key) + return nil +} + +// ListTaskExecutionRecords 分页查询任务执行记录 +func ListTaskExecutionRecords(ctx context.Context, req model.ListTaskExecutionsRequest) ([]model.TaskExecution, int64, error) { + if req.Page <= 0 { + req.Page = 1 + } + if req.PageSize <= 0 { + req.PageSize = 20 + } + + query := GetDB(ctx).Model(&model.TaskExecution{}) + + if req.Status != "" { + query = query.Where("status = ?", req.Status) + } + if req.TaskType != "" { + query = query.Where("task_type = ?", req.TaskType) + } else if types := parseTaskTypesFilter(req.TaskTypes); len(types) > 0 { + query = query.Where("task_type IN ?", types) + } else if req.TaskTypePrefix != "" { + query = query.Where("task_type LIKE ? ESCAPE '\\'", util.EscapeLike(req.TaskTypePrefix)+"%") + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + var executions []model.TaskExecution + offset := (req.Page - 1) * req.PageSize + if err := query.Order("id DESC").Offset(offset).Limit(req.PageSize).Find(&executions).Error; err != nil { + return nil, 0, err + } + loadTaskExecutionLogs(ctx, executions) + + return executions, total, nil +} + +func parseTaskTypesFilter(raw string) []string { + if strings.TrimSpace(raw) == "" { + return nil + } + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + out = append(out, part) + } + } + return out +} + +// MarkFailedTaskExecutionsSucceededTx marks failed executions of a task type as succeeded within a transaction. +func MarkFailedTaskExecutionsSucceededTx( + tx *gorm.DB, + taskType string, + result string, + finishedAt time.Time, +) error { + return tx.Model(&model.TaskExecution{}). + Where("task_type = ? AND status = ?", taskType, model.TaskExecutionStatusFailed). + Updates(map[string]any{ + "status": model.TaskExecutionStatusSucceeded, + "result": result, + "finished_at": finishedAt, + }).Error +} + +// CleanupTaskExecutionLogs removes finished task execution logs according to frequency-based retention. +func CleanupTaskExecutionLogs(ctx context.Context, now time.Time) (model.TaskExecutionCleanupStats, error) { + const ( + frequencyWindowDays = 30 + highFrequencyThreshold = frequencyWindowDays + ) + + frequencyWindowStart := now.AddDate(0, 0, -frequencyWindowDays) + highFrequencyCutoff := now.AddDate(0, 0, -3) + lowFrequencyCutoff := now.AddDate(0, 0, -30) + terminalStatuses := []model.TaskExecutionStatus{model.TaskExecutionStatusSucceeded, model.TaskExecutionStatusFailed} + + var highFrequencyTaskTypes []string + if err := GetDB(ctx). + Model(&model.TaskExecution{}). + Select("task_type"). + Where("created_at >= ?", frequencyWindowStart). + Group("task_type"). + Having("COUNT(*) > ?", highFrequencyThreshold). + Pluck("task_type", &highFrequencyTaskTypes).Error; err != nil { + return model.TaskExecutionCleanupStats{}, fmt.Errorf("query high-frequency task types: %w", err) + } + + var highFrequencyDeleted int64 + if len(highFrequencyTaskTypes) > 0 { + highFrequencyResult := GetDB(ctx). + Where("status IN ?", terminalStatuses). + Where("created_at < ?", highFrequencyCutoff). + Where("task_type IN ?", highFrequencyTaskTypes). + Delete(&model.TaskExecution{}) + if highFrequencyResult.Error != nil { + return model.TaskExecutionCleanupStats{}, fmt.Errorf("delete high-frequency task execution logs: %w", highFrequencyResult.Error) + } + highFrequencyDeleted = highFrequencyResult.RowsAffected + } + + lowFrequencyQuery := GetDB(ctx). + Where("status IN ?", terminalStatuses). + Where("created_at < ?", lowFrequencyCutoff) + if len(highFrequencyTaskTypes) > 0 { + lowFrequencyQuery = lowFrequencyQuery.Where("task_type NOT IN ?", highFrequencyTaskTypes) + } + lowFrequencyResult := lowFrequencyQuery.Delete(&model.TaskExecution{}) + if lowFrequencyResult.Error != nil { + return model.TaskExecutionCleanupStats{}, fmt.Errorf("delete low-frequency task execution logs: %w", lowFrequencyResult.Error) + } + + return model.TaskExecutionCleanupStats{ + HighFrequencyDeleted: highFrequencyDeleted, + LowFrequencyDeleted: lowFrequencyResult.RowsAffected, + }, nil +} + +// TaskExecutionLogRedisKey builds the Redis key for task execution logs. +func TaskExecutionLogRedisKey(taskID string) string { + return taskExecutionLogRedisKeyPrefix + taskID +} + +// loadTaskExecutionLog best-effort enriches an execution with its cached log; +// a cache miss or failure simply leaves the stored log column in place. +func loadTaskExecutionLog(ctx context.Context, execution *model.TaskExecution) { + cacheSvc := GetCache(ctx) + if cacheSvc == nil { + return + } + + var logText string + if err := cacheSvc.Get(ctx, TaskExecutionLogRedisKey(execution.TaskID), &logText); err == nil && logText != "" { + execution.Log = logText + } +} + +// loadTaskExecutionLogs best-effort enriches every execution with its cached log. +func loadTaskExecutionLogs(ctx context.Context, executions []model.TaskExecution) { + cacheSvc := GetCache(ctx) + if cacheSvc == nil || len(executions) == 0 { + return + } + + for i := range executions { + var logText string + if err := cacheSvc.Get(ctx, TaskExecutionLogRedisKey(executions[i].TaskID), &logText); err == nil && logText != "" { + executions[i].Log = logText + } + } +} diff --git a/backend/plugins/domain/admin/repository/template.go b/backend/plugins/domain/admin/repository/template.go new file mode 100644 index 00000000..ebf88a61 --- /dev/null +++ b/backend/plugins/domain/admin/repository/template.go @@ -0,0 +1,58 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package repository + +import ( + "Wavelet/plugins/domain/admin/model" + "context" + "errors" + + "gorm.io/gorm" +) + +// ListTemplatesRecord returns all templates ordered by system flag and creation time. +func ListTemplatesRecord(ctx context.Context) ([]model.Template, error) { + var templates []model.Template + if err := GetDB(ctx).Order("is_system DESC, created_at DESC").Find(&templates).Error; err != nil { + return nil, err + } + return templates, nil +} + +// GetTemplateByKey loads a template by its key. +func GetTemplateByKey(ctx context.Context, key string) (model.Template, error) { + var tmpl model.Template + if err := GetDB(ctx).Where("key = ?", key).First(&tmpl).Error; err != nil { + return model.Template{}, err + } + return tmpl, nil +} + +// TemplateExistsByKey reports whether a template key is already taken. +func TemplateExistsByKey(ctx context.Context, key string) (bool, error) { + var existing model.Template + err := GetDB(ctx).Where("key = ?", key).First(&existing).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} + +// CreateTemplateRecord persists a new template. +func CreateTemplateRecord(ctx context.Context, tmpl *model.Template) error { + return GetDB(ctx).Create(tmpl).Error +} + +// SaveTemplateRecord updates an existing template. +func SaveTemplateRecord(ctx context.Context, tmpl *model.Template) error { + return GetDB(ctx).Save(tmpl).Error +} + +// DeleteTemplateRecord removes a template record. +func DeleteTemplateRecord(ctx context.Context, tmpl *model.Template) error { + return GetDB(ctx).Delete(tmpl).Error +} diff --git a/backend/plugins/domain/admin/service/auth_source.go b/backend/plugins/domain/admin/service/auth_source.go new file mode 100644 index 00000000..3ffef7d8 --- /dev/null +++ b/backend/plugins/domain/admin/service/auth_source.go @@ -0,0 +1,87 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "Wavelet/core/contracts" + "Wavelet/pkg/logger" + "Wavelet/plugins/domain/admin/errs" + "context" + "errors" + "fmt" +) + +// ListAuthSources returns every configured authentication source. +func ListAuthSources(ctx context.Context) ([]contracts.AuthSourceViewDTO, error) { + authSvc, err := requireAuthService(ctx) + if err != nil { + return nil, err + } + + views, err := authSvc.ListAuthSources(ctx) + if err != nil { + logger.ErrorF(ctx, "List auth sources failed: %v", err) + return nil, errors.New(errs.ListAuthSourcesFailed) + } + return views, nil +} + +// CreateAuthSource registers a new authentication source. +func CreateAuthSource(ctx context.Context, source contracts.AuthSourceDTO) (*contracts.AuthSourceDTO, error) { + authSvc, err := requireAuthService(ctx) + if err != nil { + return nil, err + } + + created, err := authSvc.CreateAuthSource(ctx, source) + if err != nil { + return nil, fmt.Errorf("%s%w", errs.CreateAuthSourceFailed, err) + } + return created, nil +} + +// UpdateAuthSource rewrites an existing authentication source. +func UpdateAuthSource( + ctx context.Context, + id uint64, + source contracts.AuthSourceDTO, +) (*contracts.AuthSourceDTO, error) { + authSvc, err := requireAuthService(ctx) + if err != nil { + return nil, err + } + + updated, err := authSvc.UpdateAuthSource(ctx, id, source) + if err != nil { + return nil, err + } + return updated, nil +} + +// ToggleAuthSource flips the active state of an authentication source. +func ToggleAuthSource(ctx context.Context, id uint64) (*contracts.AuthSourceDTO, error) { + authSvc, err := requireAuthService(ctx) + if err != nil { + return nil, err + } + + toggled, err := authSvc.ToggleAuthSource(ctx, id) + if err != nil { + return nil, fmt.Errorf("%s%w", errs.ToggleAuthSourceFailed, err) + } + return toggled, nil +} + +// DeleteAuthSource removes an authentication source. +func DeleteAuthSource(ctx context.Context, id uint64) error { + authSvc, err := requireAuthService(ctx) + if err != nil { + return err + } + + if err := authSvc.DeleteAuthSource(ctx, id); err != nil { + return fmt.Errorf("%s%w", errs.DeleteAuthSourceFailed, err) + } + return nil +} diff --git a/backend/plugins/domain/admin/service/cache.go b/backend/plugins/domain/admin/service/cache.go new file mode 100644 index 00000000..521f1e07 --- /dev/null +++ b/backend/plugins/domain/admin/service/cache.go @@ -0,0 +1,45 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "context" + "strconv" + + pkgcache "Wavelet/pkg/cache/disk" + "Wavelet/plugins/domain/admin/model" + "Wavelet/plugins/domain/admin/repository" +) + +// DiskCacheStatus reports the disk cache usage counters. +func DiskCacheStatus() pkgcache.Status { + return pkgcache.Default().Status() +} + +// ClearDiskCache purges every cached object and resets the tracking counters. +func ClearDiskCache() error { + return pkgcache.Default().Clear() +} + +// UpdateDiskCachePolicy persists the disk cache settings and applies them hot. +func UpdateDiskCachePolicy(ctx context.Context, req model.UpdateCacheConfigRequest) error { + if err := saveOrUpdateCacheConfig(ctx, model.ConfigKeyDiskCacheMaxSizeMB, strconv.FormatInt(req.MaxSizeMB, 10)); err != nil { + return err + } + + if err := saveOrUpdateCacheConfig(ctx, model.ConfigKeyDiskCacheTTLMinutes, strconv.FormatInt(req.TTLMinutes, 10)); err != nil { + return err + } + + if err := saveOrUpdateCacheConfig(ctx, model.ConfigKeyDiskCacheLRUEnabled, strconv.FormatBool(req.LRUEnabled)); err != nil { + return err + } + + pkgcache.Default().UpdatePolicy(req.MaxSizeMB, req.TTLMinutes, req.LRUEnabled) + return nil +} + +func saveOrUpdateCacheConfig(ctx context.Context, key, value string) error { + return repository.SaveOrUpdateSystemConfig(ctx, key, value) +} diff --git a/backend/plugins/domain/admin/service/config.go b/backend/plugins/domain/admin/service/config.go new file mode 100644 index 00000000..f453536b --- /dev/null +++ b/backend/plugins/domain/admin/service/config.go @@ -0,0 +1,299 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "Wavelet/core/contracts" + "Wavelet/pkg/logger" + mail "Wavelet/pkg/mail" + "Wavelet/plugins/domain/admin/errs" + "Wavelet/plugins/domain/admin/model" + "Wavelet/plugins/domain/admin/repository" + "context" + "encoding/json" + "errors" + "fmt" +) + +const maskedConfigValue = "******" + +// PublicSystemConfigs returns the key/value map exposed to unauthenticated clients. +func PublicSystemConfigs(ctx context.Context) (map[string]string, error) { + configs, err := repository.ListVisibleSystemConfigs(ctx) + if err != nil { + return nil, err + } + + resp := make(map[string]string, len(configs)) + for _, config := range configs { + resp[config.Key] = config.Value + } + return resp, nil +} + +// ListAdminSystemConfigs returns every config, optionally filtered by type, with secrets masked. +func ListAdminSystemConfigs(ctx context.Context, configType string) ([]model.SystemConfig, error) { + configs, err := repository.ListAdminSystemConfigs(ctx, configType) + if err != nil { + return nil, err + } + for i := range configs { + configs[i].Value = MaskSensitiveConfig(configs[i].Key, configs[i].Value) + } + return configs, nil +} + +// GetAdminSystemConfig loads a single config with its secrets masked. +func GetAdminSystemConfig(ctx context.Context, key string) (model.SystemConfig, error) { + config, err := repository.GetAdminSystemConfigByKey(ctx, key) + if err != nil { + return model.SystemConfig{}, translateNotFound(err, errs.ErrSystemConfigNotFound) + } + config.Value = MaskSensitiveConfig(config.Key, config.Value) + return config, nil +} + +// CreateAdminSystemConfig persists a new config key and refreshes the cache layer. +func CreateAdminSystemConfig(ctx context.Context, req model.CreateSystemConfigRequest) error { + if isProtectedConfigKey(req.Key) { + return errs.ErrProtectedConfigKey + } + exists, err := repository.SystemConfigExists(ctx, req.Key) + if err != nil { + return err + } + if exists { + return errs.ErrConfigKeyExists + } + + config := model.SystemConfig{ + Key: req.Key, + Value: req.Value, + Type: req.Type, + Visibility: req.Visibility, + Description: req.Description, + } + if err := repository.CreateSystemConfigRecord(ctx, &config); err != nil { + return err + } + + invalidateSystemConfigCaches(ctx, req.Key) + if err := repository.InvalidateVisibleSystemConfigsCache(ctx); err != nil { + logger.WarnF(ctx, "清理公共配置列表缓存失败: %v", err) + } + return nil +} + +// UpdateAdminSystemConfig applies an update to a protected-aware config key inside a transaction. +func UpdateAdminSystemConfig(ctx context.Context, key string, req model.UpdateSystemConfigRequest) error { + if isProtectedConfigKey(key) { + return errs.ErrProtectedConfigKey + } + config, err := repository.GetAdminSystemConfigByKey(ctx, key) + if err != nil { + return translateNotFound(err, errs.ErrSystemConfigNotFound) + } + + var originalDriver contracts.StorageDriver + resolveTaskType := "" + resolveResult := "" + if key == model.ConfigKeyStorageConfig { + var currentCfg contracts.StorageConfigDTO + if err := json.Unmarshal([]byte(config.Value), ¤tCfg); err == nil { + originalDriver = currentCfg.Driver + } + + validatedVal, err := validateAndMergeStorageConfig(ctx, req.Value, config.Value) + if err != nil { + return err + } + req.Value = validatedVal + + var newCfg contracts.StorageConfigDTO + if err := json.Unmarshal([]byte(req.Value), &newCfg); err == nil { + resolveTaskType, resolveResult = storageMigrationResolutionTask(originalDriver, newCfg.Driver) + } + } + + updates := map[string]any{ + "description": req.Description, + } + if req.Visibility != nil { + updates["visibility"] = *req.Visibility + config.Visibility = *req.Visibility + } + if key != model.ConfigKeySMTPPassword || req.Value != maskedConfigValue { + updates["value"] = req.Value + config.Value = req.Value + } + + if err := repository.UpdateSystemConfigTx(ctx, &config, updates, resolveTaskType, resolveResult); err != nil { + return err + } + + invalidateCachesAfterConfigUpdate(ctx, key) + return nil +} + +// storageMigrationResolutionTask reports the failed-task resolution that a direct storage +// config rewrite implies. An empty task type means nothing has to be resolved. +func storageMigrationResolutionTask( + originalDriver contracts.StorageDriver, + newDriver contracts.StorageDriver, +) (string, string) { + if originalDriver == "" || newDriver != originalDriver { + return "", "" + } + return errs.StorageMigrationTaskType, errs.StorageDriverResolvedResult +} + +func isProtectedConfigKey(key string) bool { + return key == model.ConfigKeyLogDatabase || key == model.ConfigKeyLogDBMigration +} + +func invalidateSystemConfigCaches(ctx context.Context, key string) { + if err := repository.InvalidateSystemConfigCache(ctx, key); err != nil { + logger.WarnF(ctx, "清理系统配置缓存失败: %v", err) + } + _ = EmitEvent(ctx, contracts.EventTopicConfigChanged, contracts.ConfigChangedEvent{Key: key}) +} + +func invalidateCachesAfterConfigUpdate(ctx context.Context, key string) { + invalidateSystemConfigCaches(ctx, key) + + if err := repository.InvalidateVisibleSystemConfigsCache(ctx); err != nil { + logger.WarnF(ctx, "清理公共配置列表缓存失败: %v", err) + } +} + +// TestSMTP sends a probe mail, resolving a masked password from the stored config. +func TestSMTP(ctx context.Context, req model.TestSMTPRequest) model.TestSMTPResponse { + password := req.SMTPPassword + if password == maskedConfigValue { + if sc, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeySMTPPassword); err == nil { + password = sc.Value + } + } + + cfg := mail.Config{ + Host: req.SMTPHost, + Port: req.SMTPPort, + Username: req.SMTPUsername, + Password: password, + } + + subject := "Wavelet SMTP Test Mail" + body := `

SMTP Mail Connection Test

+

If you received this message, your SMTP configuration is correct and mail sending is working properly.

+

Sent from Wavelet.

` + + logs, err := mail.SendMailWithLog(ctx, cfg, req.To, subject, body) + resp := model.TestSMTPResponse{ + Success: err == nil, + Log: logs, + } + if err != nil { + resp.Error = err.Error() + } + return resp +} + +// MaskSensitiveConfig masks secret config values before exposing to clients. +func MaskSensitiveConfig(key, value string) string { + if value == "" { + return value + } + switch key { + case model.ConfigKeySMTPPassword: + return maskedConfigValue + case model.ConfigKeyStorageConfig: + return maskStorageConfig(value) + } + return value +} + +func maskStorageConfig(value string) string { + var cfg contracts.StorageConfigDTO + if err := json.Unmarshal([]byte(value), &cfg); err != nil { + return value + } + if cfg.S3.SecretAccessKey != "" { + cfg.S3.SecretAccessKey = maskedConfigValue + } + if cfg.R2.SecretAccessKey != "" { + cfg.R2.SecretAccessKey = maskedConfigValue + } + if cfg.MinIO.SecretAccessKey != "" { + cfg.MinIO.SecretAccessKey = maskedConfigValue + } + if cfg.OSS.SecretAccessKey != "" { + cfg.OSS.SecretAccessKey = maskedConfigValue + } + if cfg.WebDAV.Password != "" { + cfg.WebDAV.Password = maskedConfigValue + } + val, err := json.Marshal(cfg) + if err != nil { + return value + } + return string(val) +} + +// validateAndMergeStorageConfig parses, merges unmasked secrets, validates parameter values, +// and tests connectivity of the new storage configuration. +func validateAndMergeStorageConfig(ctx context.Context, value, currentConfig string) (string, error) { + var currentCfg contracts.StorageConfigDTO + if err := json.Unmarshal([]byte(currentConfig), ¤tCfg); err != nil { + return "", fmt.Errorf(errs.ErrParseCurrentStorageConfigFailed, err) + } + + var newCfg contracts.StorageConfigDTO + if err := json.Unmarshal([]byte(value), &newCfg); err != nil { + return "", fmt.Errorf(errs.ErrParseTargetStorageConfigFailed, err) + } + + // 合并被掩码屏蔽的敏感信息,获取完整的真实配置 + targetCfg := newCfg + if targetCfg.S3.SecretAccessKey == maskedConfigValue { + targetCfg.S3.SecretAccessKey = currentCfg.S3.SecretAccessKey + } + if targetCfg.R2.SecretAccessKey == maskedConfigValue { + targetCfg.R2.SecretAccessKey = currentCfg.R2.SecretAccessKey + } + if targetCfg.MinIO.SecretAccessKey == maskedConfigValue { + targetCfg.MinIO.SecretAccessKey = currentCfg.MinIO.SecretAccessKey + } + if targetCfg.OSS.SecretAccessKey == maskedConfigValue { + targetCfg.OSS.SecretAccessKey = currentCfg.OSS.SecretAccessKey + } + if targetCfg.WebDAV.Password == maskedConfigValue { + targetCfg.WebDAV.Password = currentCfg.WebDAV.Password + } + + if err := validateMergedStorageConfig(ctx, currentCfg, newCfg, targetCfg); err != nil { + return "", err + } + + // 序列化为最终保存的真实明文配置,防止保存屏蔽的 ****** 字符 + unmaskedVal, err := json.Marshal(targetCfg) + if err != nil { + return "", fmt.Errorf(errs.ErrSerializeStorageConfigFailed, err) + } + + return string(unmaskedVal), nil +} + +func validateMergedStorageConfig(ctx context.Context, currentCfg, newCfg, _ contracts.StorageConfigDTO) error { + if newCfg.Driver != "" && newCfg.Driver != currentCfg.Driver { + uploadCount, err := repository.CountActiveUploads(ctx) + if err != nil { + return err + } + if uploadCount > 0 { + return errors.New(errs.StorageDriverSwitchRequiresMigration) + } + } + + return nil +} diff --git a/backend/plugins/domain/admin/service/db.go b/backend/plugins/domain/admin/service/db.go new file mode 100644 index 00000000..c1d2ec95 --- /dev/null +++ b/backend/plugins/domain/admin/service/db.go @@ -0,0 +1,166 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "Wavelet/plugins/domain/admin/model" + "Wavelet/plugins/domain/admin/repository" + "context" + "os" + "os/exec" + "strings" + "sync" + "time" +) + +var ( + dbConfigMu sync.RWMutex + dbConfig model.DatabaseConfig + chConfig model.ClickHouseConfig +) + +// SetDBConfig sets the database configuration in service and repository. +func SetDBConfig(cfg model.DatabaseConfig) { + dbConfigMu.Lock() + defer dbConfigMu.Unlock() + dbConfig = cfg + repository.SetDBConfig(cfg) +} + +// GetDBConfig returns the database configuration. +func GetDBConfig() model.DatabaseConfig { + dbConfigMu.RLock() + defer dbConfigMu.RUnlock() + return dbConfig +} + +// SetClickHouseConfig sets the clickhouse configuration. +func SetClickHouseConfig(cfg model.ClickHouseConfig) { + dbConfigMu.Lock() + defer dbConfigMu.Unlock() + chConfig = cfg +} + +// GetClickHouseConfig returns the clickhouse configuration. +func GetClickHouseConfig() model.ClickHouseConfig { + dbConfigMu.RLock() + defer dbConfigMu.RUnlock() + return chConfig +} + +// selectSQLKeywords marks statements that return a result set instead of a row count. +var selectSQLKeywords = []string{"select", "show", "explain", "describe", "pragma"} + +// DatabaseOverview collects the runtime overview of the active database. +func DatabaseOverview(ctx context.Context) (model.DBOverviewResponse, error) { + if !GetDBConfig().Enabled { + return repository.GetSQLiteOverview(ctx) + } + return repository.GetPostgresOverview(ctx) +} + +// DatabaseTableNames returns every user table of the active database. +func DatabaseTableNames(ctx context.Context) ([]string, error) { + return repository.ListDatabaseTableNames(ctx) +} + +// DatabaseTableData loads one page of a table with its column layout and total row count. +func DatabaseTableData(ctx context.Context, req model.GetTableDataRequest) (model.TableDataResponse, error) { + quotedTable := repository.QuoteTableName(req.Table) + + total, err := repository.CountDatabaseTableRows(ctx, quotedTable) + if err != nil { + return model.TableDataResponse{}, err + } + + offset := (req.Page - 1) * req.PageSize + if offset < 0 { + offset = 0 + } + limit := req.PageSize + if limit <= 0 { + limit = 10 + } + + cols, results, err := repository.QueryDatabaseTableRows(ctx, quotedTable, limit, offset) + if err != nil { + return model.TableDataResponse{}, err + } + + return model.TableDataResponse{ + Columns: cols, + Total: total, + Results: truncateCellValues(results), + }, nil +} + +// truncateCellValues caps oversized string cells before they reach the console grid. +func truncateCellValues(rows []map[string]any) []map[string]any { + for _, row := range rows { + for column, value := range row { + if str, ok := value.(string); ok { + row[column] = model.TruncateDisplayValue(str) + } + } + } + return rows +} + +// ExecuteCustomSQL runs an arbitrary statement issued from the console SQL runner. +func ExecuteCustomSQL(ctx context.Context, trimmedSQL string) (model.ExecuteSQLResponse, error) { + startTime := time.Now() + + if isSelectStatement(trimmedSQL) { + cols, results, err := repository.RunSelectSQL(ctx, trimmedSQL) + if err != nil { + return model.ExecuteSQLResponse{}, err + } + return model.ExecuteSQLResponse{ + Type: "select", + Columns: cols, + Results: results, + AffectedRows: int64(len(results)), + ExecutionTimeMs: time.Since(startTime).Milliseconds(), + }, nil + } + + affectedRows, err := repository.RunMutationSQL(ctx, trimmedSQL) + if err != nil { + return model.ExecuteSQLResponse{}, err + } + return model.ExecuteSQLResponse{ + Type: "exec", + AffectedRows: affectedRows, + ExecutionTimeMs: time.Since(startTime).Milliseconds(), + }, nil +} + +// isSelectStatement reports whether the statement yields a result set. +func isSelectStatement(trimmedSQL string) bool { + lowerSQL := strings.ToLower(trimmedSQL) + for _, kw := range selectSQLKeywords { + if strings.HasPrefix(lowerSQL, kw) { + return true + } + } + return false +} + +// DatabaseInfo returns the active database type, name and version. +func DatabaseInfo(ctx context.Context) model.DatabaseInfoResponse { + if !GetDBConfig().Enabled { + return repository.GetSQLiteInfo(ctx) + } + return repository.GetPostgresInfo(ctx) +} + +// OpenSQLiteExportFile opens the active SQLite database file together with its stat info. +func OpenSQLiteExportFile() (*os.File, os.FileInfo, error) { + return repository.OpenSQLiteExportFile() +} + +// NewPgDumpCommand builds the streaming pg_dump command for the active database. +func NewPgDumpCommand(ctx context.Context) (*exec.Cmd, string, error) { + return repository.NewPgDumpCommand(ctx) +} diff --git a/backend/plugins/domain/admin/service/log.go b/backend/plugins/domain/admin/service/log.go new file mode 100644 index 00000000..82a6dd84 --- /dev/null +++ b/backend/plugins/domain/admin/service/log.go @@ -0,0 +1,240 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "Wavelet/core/contracts" + "Wavelet/pkg/logger" + "Wavelet/plugins/domain/admin/errs" + "Wavelet/plugins/domain/admin/model" + "Wavelet/plugins/domain/admin/repository" + "context" + "fmt" + "net/url" + "strings" + "time" +) + +const ( + analyticsDays = 7 + + denyingRobotsFile = "User-Agent: *\nDisallow: /\n" + allowingRobotsFile = "User-Agent: *\nAllow: /\n" +) + +// RecentSystemLogs reads a page of the process log ring buffer. +func RecentSystemLogs(cursor, limit int) model.LogsResponse { + entries, hasMore := logger.GlobalRingBuffer.Query(cursor, limit) + + resp := model.LogsResponse{ + Lines: entries, + HasMore: hasMore, + } + if len(entries) > 0 { + resp.NextCursor = entries[0].Index + } + return resp +} + +// RobotsTxtBody resolves the robots.txt payload from the indexing setting. +func RobotsTxtBody(ctx context.Context) string { + enabled, err := repository.GetBoolByKey(ctx, model.ConfigKeySearchEngineIndexingEnabled) + if err == nil && enabled { + return allowingRobotsFile + } + return denyingRobotsFile +} + +// IsAllowedLogOrigin reports whether a WebSocket handshake origin may subscribe to logs. +func IsAllowedLogOrigin(ctx context.Context, origin, host string) bool { + if origin == "" { + return true + } + + // 1. 同源检查 (Same-origin check) + u, err := url.Parse(origin) + if err == nil && strings.EqualFold(u.Host, host) { + return true + } + + // 2. 检查配置的允许跨域 Origin (Check allowed origins in system config) + sc, cfgErr := repository.GetSystemConfigByKey(ctx, model.ConfigKeyServerAddress) + if cfgErr != nil || sc.Value == "" { + return false + } + originToCheck := strings.TrimRight(strings.TrimSpace(origin), "/") + for _, allowed := range strings.Split(sc.Value, ",") { + allowed = strings.TrimRight(strings.TrimSpace(allowed), "/") + if allowed != "" && strings.EqualFold(allowed, originToCheck) { + return true + } + } + return false +} + +// AccessLogs queries the analytical access log store and decorates rows with user names. +func AccessLogs(ctx context.Context, q model.AccessLogQuery) (model.AccessLogsResponse, error) { + rc := GetRiskControlService() + if rc == nil { + return model.AccessLogsResponse{}, errs.ErrLogStoreUnavailable + } + + filter, err := buildAccessLogFilter(ctx, q) + if err != nil { + return model.AccessLogsResponse{}, err + } + if filter.UserIDs != nil && len(filter.UserIDs) == 0 { + return model.AccessLogsResponse{Total: 0, List: []model.AccessLogItem{}}, nil + } + + logs, total, err := rc.QueryAccessLogs(ctx, filter, q.Page, q.PageSize) + if err != nil { + return model.AccessLogsResponse{}, err + } + if total == 0 { + return model.AccessLogsResponse{Total: 0, List: []model.AccessLogItem{}}, nil + } + + list := make([]model.AccessLogItem, len(logs)) + for i, logItem := range logs { + list[i] = model.AccessLogItem{ + ID: logItem.ID, + UserID: logItem.UserID, + Path: logItem.Path, + Method: logItem.Method, + IP: logItem.IP, + UserAgent: logItem.UserAgent, + Status: logItem.Status, + Latency: logItem.Latency, + CreatedAt: logItem.CreatedAt.Format(time.RFC3339), + } + } + enrichAccessLogsWithUsers(ctx, list) + + return model.AccessLogsResponse{Total: total, List: list}, nil +} + +// AccessLogAnalytics aggregates the daily trend of the access log store. +func AccessLogAnalytics(ctx context.Context) (model.LogsAnalyticsResponse, error) { + rc := GetRiskControlService() + if rc == nil { + return model.LogsAnalyticsResponse{}, errs.ErrLogStoreUnavailable + } + + stats, err := rc.QueryAccessLogStats(ctx, analyticsDays) + if err != nil { + return model.LogsAnalyticsResponse{}, fmt.Errorf("%s%w", errs.ErrQueryAccessTrendFailed, err) + } + + trendList := make([]model.TrendItem, len(stats)) + for i, st := range stats { + trendList[i] = model.TrendItem{ + Date: st.Date, + Count: st.PV, + } + } + + return model.LogsAnalyticsResponse{ + Trend: trendList, + Browsers: []model.BrowserItem{}, + TopUsers: []model.TopUserItem{}, + }, nil +} + +// findUserIDsByUsername resolves the user id filter behind a username search term. +func findUserIDsByUsername(ctx context.Context, username string) ([]uint64, error) { + if userSvc := GetUserService(ctx); userSvc != nil { + users, _, err := userSvc.ListUsers(ctx, 1, userQueryMaxLimit, username) + if err != nil { + return nil, fmt.Errorf(errs.ErrQueryUserFailed, err) + } + ids := make([]uint64, 0, len(users)) + for _, u := range users { + ids = append(ids, u.ID) + } + return ids, nil + } + + ids, err := repository.SearchUserIDsByUsername(ctx, username) + if err != nil { + return nil, fmt.Errorf(errs.ErrQueryUserFailed, err) + } + return ids, nil +} + +const userQueryMaxLimit = 100 + +func buildAccessLogFilter(ctx context.Context, q model.AccessLogQuery) (contracts.AccessLogFilterDTO, error) { + filter := contracts.AccessLogFilterDTO{} + + if q.Username != "" { + userIDs, err := findUserIDsByUsername(ctx, q.Username) + if err != nil { + return filter, err + } + filter.UserIDs = userIDs + } + + if q.Path != "" { + filter.Path = q.Path + } + + if q.StartTime != "" { + if t, err := parseAccessLogTime(q.StartTime); err == nil { + filter.StartTime = &t + } + } + + if q.EndTime != "" { + if t, err := parseAccessLogTime(q.EndTime); err == nil { + filter.EndTime = &t + } + } + + return filter, nil +} + +func parseAccessLogTime(value string) (time.Time, error) { + if t, err := time.Parse(time.RFC3339, value); err == nil { + return t, nil + } + return time.Parse("2006-01-02 15:04:05", value) +} + +// enrichAccessLogsWithUsers attaches usernames and nicknames to access log rows. +func enrichAccessLogsWithUsers(ctx context.Context, list []model.AccessLogItem) { + if len(list) == 0 { + return + } + + userIDs := make([]uint64, 0, len(list)) + seen := make(map[uint64]struct{}, len(list)) + for _, item := range list { + if _, ok := seen[item.UserID]; ok { + continue + } + seen[item.UserID] = struct{}{} + userIDs = append(userIDs, item.UserID) + } + + userMap := make(map[uint64]repository.UserDisplayName, len(userIDs)) + if userSvc := GetUserService(ctx); userSvc != nil { + if users, err := userSvc.GetUsersByIDs(ctx, userIDs); err == nil { + for _, u := range users { + if u != nil { + userMap[u.ID] = repository.UserDisplayName{Username: u.Username, Nickname: u.Nickname} + } + } + } + } else if names, err := repository.LoadUserDisplayNames(ctx, userIDs); err == nil { + userMap = names + } + + for i := range list { + if info, ok := userMap[list[i].UserID]; ok { + list[i].Username = info.Username + list[i].Nickname = info.Nickname + } + } +} diff --git a/backend/plugins/domain/admin/service/log_switch.go b/backend/plugins/domain/admin/service/log_switch.go new file mode 100644 index 00000000..792eb95f --- /dev/null +++ b/backend/plugins/domain/admin/service/log_switch.go @@ -0,0 +1,185 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "Wavelet/core/contracts" + "Wavelet/pkg/logger" + "Wavelet/plugins/domain/admin/errs" + "Wavelet/plugins/domain/admin/model" + "Wavelet/plugins/domain/admin/repository" + "context" + "encoding/json" + "errors" + "fmt" +) + +const ( + // LogDBSwitchTask 切换日志数据库任务标识。 + LogDBSwitchTask = "logs:db_switch" + // TaskTypeLogDBSwitch 管理端任务类型。 + TaskTypeLogDBSwitch = "logs_db_switch" + + targetPostgres = "postgres" + targetSQLite = "sqlite" + targetClickHouse = "clickhouse" + + errParseTaskPayloadFailed = "参数解析失败: %w" + errInvalidLogTarget = "目标日志库不合法: %s" +) + +// LogDBSwitchMeta 描述切换日志数据库任务。 +var LogDBSwitchMeta = contracts.TaskMetaDTO{ + Type: TaskTypeLogDBSwitch, + AsynqTask: LogDBSwitchTask, + Name: "切换日志数据库", + DisplayName: "切换日志数据库", + Description: "复制迁移用户访问日志并在成功后切换日志主库(期间禁止日志写入)", + Category: "system", + SupportsTime: false, + MaxRetry: 3, + Queue: "default", + Retryable: true, + Params: []contracts.TaskParamDTO{ + { + Name: "target", + Label: "目标日志库", + Type: "string", + Required: true, + Placeholder: "postgres|sqlite|clickhouse", + Description: "迁移目标:postgres(主库为 PG 时)、sqlite(主库为 SQLite 时)或 clickhouse", + }, + }, +} + +type logDBSwitchPayload struct { + Target string `json:"target"` +} + +// LogDBSwitchHandler 切换日志数据库任务处理器。 +type LogDBSwitchHandler struct{} + +// ValidatePayload 校验并规范化参数。 +func (h *LogDBSwitchHandler) ValidatePayload(payload []byte) ([]byte, error) { + var p logDBSwitchPayload + if err := json.Unmarshal(payload, &p); err != nil { + return nil, fmt.Errorf(errParseTaskPayloadFailed, err) + } + p.Target = normalizeTarget(p.Target) + if !validTarget(p.Target) { + return nil, fmt.Errorf(errInvalidLogTarget, p.Target) + } + out, err := json.Marshal(p) + if err != nil { + return nil, err + } + return out, nil +} + +func normalizeTarget(v string) string { + switch v { + case targetPostgres, "postgresql": + return targetPostgres + case targetSQLite, "sqlite3": + return targetSQLite + case targetClickHouse, "ch": + return targetClickHouse + } + return v +} + +func validTarget(v string) bool { + return v == targetPostgres || v == targetSQLite || v == targetClickHouse +} + +// Execute 执行迁移。 +func (h *LogDBSwitchHandler) Execute(ctx context.Context, payload []byte) (*contracts.TaskResultDTO, error) { + var p logDBSwitchPayload + if err := json.Unmarshal(payload, &p); err != nil { + return nil, fmt.Errorf(errParseTaskPayloadFailed, err) + } + p.Target = normalizeTarget(p.Target) + if err := validateSwitch(ctx, p.Target); err != nil { + return nil, err + } + + source, err := currentLogDatabase(ctx) + if err != nil { + return nil, err + } + + taskSvc := GetTaskService() + if taskSvc != nil { + taskSvc.AppendLog(ctx, "开始切换日志数据库:%s -> %s", source, p.Target) + } + + if err := setMigrationFlag(ctx, logMigrationInProgress); err != nil { + return nil, err + } + defer func() { + if err := setMigrationFlag(ctx, ""); err != nil { + logger.ErrorF(ctx, "清除日志迁移冻结标记失败: %v", err) + } + }() + + rc := GetRiskControlService() + if rc != nil { + if err := rc.SwitchLogEngine(ctx, p.Target); err != nil { + return nil, err + } + } + + if err := flipLogDatabase(ctx, p.Target); err != nil { + return nil, err + } + + if taskSvc != nil { + taskSvc.AppendLog(ctx, "日志数据库已切换为 %s,写入恢复", p.Target) + } + return &contracts.TaskResultDTO{Message: fmt.Sprintf("日志数据库已从 %s 切换为 %s", source, p.Target)}, nil +} + +func validateSwitch(ctx context.Context, target string) error { + source, err := currentLogDatabase(ctx) + if err != nil { + return err + } + if source == target { + return errors.New(errs.ErrSameLogTarget) + } + switch target { + case targetClickHouse: + if !GetClickHouseConfig().Enabled { + return errors.New(errs.ErrClickHouseNotEnabled) + } + case targetPostgres: + if !GetDBConfig().Enabled { + return errors.New(errs.ErrPostgresNotEnabled) + } + case targetSQLite: + if GetDBConfig().Enabled { + return errors.New(errs.ErrSQLiteNotAllowedAsLogDB) + } + } + return nil +} + +func currentLogDatabase(ctx context.Context) (string, error) { + cfg, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyLogDatabase) + if err != nil { + return "", fmt.Errorf(errs.ErrReadLogDatabaseFailed, err) + } + if cfg.Value == "" { + return "", errors.New(errs.ErrLogDatabaseEmpty) + } + return cfg.Value, nil +} + +func setMigrationFlag(ctx context.Context, v string) error { + return repository.SaveOrUpdateSystemConfig(ctx, model.ConfigKeyLogDBMigration, v) +} + +func flipLogDatabase(ctx context.Context, target string) error { + return repository.SaveOrUpdateSystemConfig(ctx, model.ConfigKeyLogDatabase, target) +} diff --git a/backend/plugins/domain/admin/service/restart_unix.go b/backend/plugins/domain/admin/service/restart_unix.go new file mode 100644 index 00000000..5d80b61d --- /dev/null +++ b/backend/plugins/domain/admin/service/restart_unix.go @@ -0,0 +1,50 @@ +//go:build !windows + +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "Wavelet/pkg/logger" + "context" + "fmt" + "os" + "path/filepath" + "syscall" +) + +const installedBinaryMode = 0o755 + +// ReplaceAndRestart replaces the current executable binary with the staged binary and restarts via syscall.Exec. +func ReplaceAndRestart(executable, stagedBinary string) error { + ctx := context.Background() + logger.InfoF(ctx, "[Updater] Swapping executable: %s -> %s", executable, stagedBinary) + backup := executable + ".old" + + if err := os.Remove(backup); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("删除旧备份失败: %w", err) + } + + if err := os.Rename(executable, backup); err != nil { + return fmt.Errorf("备份当前程序失败: %w", err) + } + + if err := os.Rename(stagedBinary, executable); err != nil { + _ = os.Rename(backup, executable) + return fmt.Errorf("替换当前程序失败: %w", err) + } + + if err := os.Chmod(executable, installedBinaryMode); err != nil { + _ = os.Remove(executable) + _ = os.Rename(backup, executable) + return fmt.Errorf("设置程序执行权限失败: %w", err) + } + + stagingDir := filepath.Dir(stagedBinary) + _ = os.RemoveAll(stagingDir) + + logger.InfoF(ctx, "[Updater] Executing syscall.Exec to restart service: %s %v", executable, os.Args) + //nolint:gosec // restart process via exec with same binary and args + return syscall.Exec(executable, os.Args, os.Environ()) +} diff --git a/backend/plugins/domain/admin/service/restart_windows.go b/backend/plugins/domain/admin/service/restart_windows.go new file mode 100644 index 00000000..fe906577 --- /dev/null +++ b/backend/plugins/domain/admin/service/restart_windows.go @@ -0,0 +1,16 @@ +//go:build windows + +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "Wavelet/plugins/domain/admin/errs" + "errors" +) + +// ReplaceAndRestart is blocked on Windows. +func ReplaceAndRestart(_, _ string) error { + return errors.New(errs.ErrAutomaticUpgradeBlocked) +} diff --git a/backend/plugins/domain/admin/service/service.go b/backend/plugins/domain/admin/service/service.go new file mode 100644 index 00000000..ba4a2e98 --- /dev/null +++ b/backend/plugins/domain/admin/service/service.go @@ -0,0 +1,204 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package service provides business logic and orchestration for the admin domain. +package service + +import ( + "Wavelet/core/contracts" + "Wavelet/plugins/domain/admin/errs" + "Wavelet/plugins/domain/admin/repository" + "context" + "errors" + "sync" + + "gorm.io/gorm" +) + +var ( + servicesMu sync.RWMutex + dbService contracts.DBService + cacheService contracts.CacheService + userService contracts.UserService + authService contracts.AuthService + taskService contracts.TaskService + storageSvc contracts.StorageService + riskControlService contracts.RiskControlService + eventEmitter func(ctx context.Context, topic string, payload any) error +) + +// SetDBService injects the DBService contract. +func SetDBService(s contracts.DBService) { + servicesMu.Lock() + defer servicesMu.Unlock() + dbService = s + repository.SetDBService(s) +} + +// SetCacheService injects the CacheService contract. +func SetCacheService(s contracts.CacheService) { + servicesMu.Lock() + defer servicesMu.Unlock() + cacheService = s + repository.SetCacheService(s) +} + +// SetUserService injects the UserService contract. +func SetUserService(s contracts.UserService) { + servicesMu.Lock() + defer servicesMu.Unlock() + userService = s +} + +// SetAuthService injects the AuthService contract. +func SetAuthService(s contracts.AuthService) { + servicesMu.Lock() + defer servicesMu.Unlock() + authService = s +} + +// SetTaskService injects the TaskService contract. +func SetTaskService(s contracts.TaskService) { + servicesMu.Lock() + defer servicesMu.Unlock() + taskService = s +} + +// SetStorageService injects the StorageService contract. +func SetStorageService(s contracts.StorageService) { + servicesMu.Lock() + defer servicesMu.Unlock() + storageSvc = s +} + +// SetRiskControlService injects the RiskControlService contract. +func SetRiskControlService(s contracts.RiskControlService) { + servicesMu.Lock() + defer servicesMu.Unlock() + riskControlService = s +} + +// SetEventEmitter sets the event emission callback. +func SetEventEmitter(fn func(ctx context.Context, topic string, payload any) error) { + servicesMu.Lock() + defer servicesMu.Unlock() + eventEmitter = fn +} + +// EmitEvent publishes a domain event if an emitter is registered. +func EmitEvent(ctx context.Context, topic string, payload any) error { + servicesMu.RLock() + defer servicesMu.RUnlock() + if eventEmitter == nil { + return nil + } + return eventEmitter(ctx, topic, payload) +} + +// ResetServices clears all injected services (used on disposal and testing). +func ResetServices() { + servicesMu.Lock() + defer servicesMu.Unlock() + dbService = nil + cacheService = nil + userService = nil + authService = nil + taskService = nil + storageSvc = nil + riskControlService = nil + eventEmitter = nil + repository.ResetServices() +} + +// GetDB returns the GORM DB instance bound to the context if available. +func GetDB(ctx context.Context) *gorm.DB { + servicesMu.RLock() + defer servicesMu.RUnlock() + if dbService == nil { + return nil + } + return dbService.DB(ctx) +} + +// GetCache returns the unified CacheService instance. +func GetCache(_ context.Context) contracts.CacheService { + servicesMu.RLock() + defer servicesMu.RUnlock() + return cacheService +} + +// GetUserService returns the UserService instance. +func GetUserService(_ context.Context) contracts.UserService { + servicesMu.RLock() + defer servicesMu.RUnlock() + return userService +} + +// GetAuthService returns the AuthService instance. +func GetAuthService(_ context.Context) contracts.AuthService { + servicesMu.RLock() + defer servicesMu.RUnlock() + return authService +} + +// GetTaskService returns the TaskService instance. +func GetTaskService() contracts.TaskService { + servicesMu.RLock() + defer servicesMu.RUnlock() + return taskService +} + +// GetStorageService returns the StorageService instance. +func GetStorageService() contracts.StorageService { + servicesMu.RLock() + defer servicesMu.RUnlock() + return storageSvc +} + +// GetRiskControlService returns the RiskControlService instance. +func GetRiskControlService() contracts.RiskControlService { + servicesMu.RLock() + defer servicesMu.RUnlock() + return riskControlService +} + +// translateNotFound collapses the persistence layer's record-not-found sentinel into +// the plugin's own domain error so that no layer above the repository has to import gorm. +func translateNotFound(err error, notFound error) error { + if errors.Is(err, gorm.ErrRecordNotFound) { + return notFound + } + return err +} + +// isRecordMissing reports whether err originates from a missing persistence row. +func isRecordMissing(err error) bool { + return errors.Is(err, gorm.ErrRecordNotFound) +} + +// requireUserService resolves the injected user contract service. +func requireUserService(ctx context.Context) (contracts.UserService, error) { + userSvc := GetUserService(ctx) + if userSvc == nil { + return nil, errs.ErrUserServiceUnavailable + } + return userSvc, nil +} + +// requireAuthService resolves the injected auth contract service. +func requireAuthService(ctx context.Context) (contracts.AuthService, error) { + authSvc := GetAuthService(ctx) + if authSvc == nil { + return nil, errs.ErrAuthServiceUnavailable + } + return authSvc, nil +} + +// requireTaskService resolves the injected task contract service. +func requireTaskService() (contracts.TaskService, error) { + taskSvc := GetTaskService() + if taskSvc == nil { + return nil, errs.ErrTaskServiceUnavailable + } + return taskSvc, nil +} diff --git a/backend/plugins/domain/admin/service/status.go b/backend/plugins/domain/admin/service/status.go new file mode 100644 index 00000000..dd3ef455 --- /dev/null +++ b/backend/plugins/domain/admin/service/status.go @@ -0,0 +1,163 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "Wavelet/pkg/logger" + "Wavelet/plugins/domain/admin/model" + "Wavelet/plugins/domain/admin/repository" + "context" + "fmt" + "math" + "runtime" + "time" +) + +var startTime = time.Now() + +const ( + hoursInDay = 24 + minutesInHour = 60 + secondsInMinute = 60 + nanosPerSecond = 1e9 + + logDBNamePostgres = "postgres" + logDBNameSQLite = "sqlite" + logDBNameClickHouse = "clickhouse" + defaultLogRetentionDays = 30 + + logMigrationIdle = "idle" + logMigrationInProgress = "migrating" + + unknownGCLabel = "未知" + noGCLabel = "无" +) + +// CollectSystemStatus samples the Go runtime counters for the console status page. +func CollectSystemStatus() model.SystemStatusResponse { + var m runtime.MemStats + runtime.ReadMemStats(&m) + + uptime := formatDuration(time.Since(startTime)) + numGoroutine := runtime.NumGoroutine() + + var lastGCTime string + switch { + case m.LastGC > 0 && m.LastGC <= math.MaxInt64: + lastGCTime = formatDuration(time.Since(time.Unix(0, int64(m.LastGC)))) + case m.LastGC > 0: + lastGCTime = unknownGCLabel + default: + lastGCTime = noGCLabel + } + + var lastPause string + if m.NumGC > 0 { + lastPause = fmt.Sprintf("%.3fs", float64(m.PauseNs[(m.NumGC-1)%256])/nanosPerSecond) + } else { + lastPause = "0.000s" + } + + return model.SystemStatusResponse{ + Uptime: uptime, + NumGoroutine: numGoroutine, + Alloc: model.FormatBytes(m.Alloc), + TotalAlloc: model.FormatBytes(m.TotalAlloc), + Sys: model.FormatBytes(m.Sys), + Lookups: m.Lookups, + Mallocs: m.Mallocs, + Frees: m.Frees, + HeapAlloc: model.FormatBytes(m.HeapAlloc), + HeapSys: model.FormatBytes(m.HeapSys), + HeapIdle: model.FormatBytes(m.HeapIdle), + HeapInuse: model.FormatBytes(m.HeapInuse), + HeapReleased: model.FormatBytes(m.HeapReleased), + HeapObjects: m.HeapObjects, + StackInuse: model.FormatBytes(m.StackInuse), + StackSys: model.FormatBytes(m.StackSys), + MSpanInuse: model.FormatBytes(m.MSpanInuse), + MSpanSys: model.FormatBytes(m.MSpanSys), + MCacheInuse: model.FormatBytes(m.MCacheInuse), + MCacheSys: model.FormatBytes(m.MCacheSys), + BuckHashSys: model.FormatBytes(m.BuckHashSys), + GCSys: model.FormatBytes(m.GCSys), + OtherSys: model.FormatBytes(m.OtherSys), + NextGC: model.FormatBytes(m.NextGC), + LastGCTime: lastGCTime, + PauseTotalNs: fmt.Sprintf("%.1fs", float64(m.PauseTotalNs)/nanosPerSecond), + LastPause: lastPause, + NumGC: m.NumGC, + } +} + +func formatDuration(d time.Duration) string { + days := int(d.Hours()) / hoursInDay + hours := int(d.Hours()) % hoursInDay + minutes := int(d.Minutes()) % minutesInHour + seconds := int(d.Seconds()) % secondsInMinute + + var res string + if days > 0 { + res += fmt.Sprintf("%d天", days) + } + if hours > 0 { + res += fmt.Sprintf("%d小时", hours) + } + if minutes > 0 { + res += fmt.Sprintf("%d分钟", minutes) + } + if seconds > 0 || res == "" { + res += fmt.Sprintf("%d秒钟", seconds) + } + return res +} + +// LogDatabaseStatus reports the active log engine, migration freeze state and retention. +func LogDatabaseStatus(ctx context.Context) model.LogDatabaseStatus { + activeDB := logDBNameSQLite + migration := logMigrationIdle + if rc := GetRiskControlService(); rc != nil { + activeDB = rc.ActiveLogEngine(ctx) + if rc.IsLogEngineMigrating(ctx) { + migration = logMigrationInProgress + } + } + return model.LogDatabaseStatus{ + ActiveDatabase: activeDB, + Migration: migration, + RetentionDays: map[string]int{ + logDBNamePostgres: retentionOr(ctx, model.ConfigKeyLogRetentionDaysPostgres), + logDBNameSQLite: retentionOr(ctx, model.ConfigKeyLogRetentionDaysSQLite), + logDBNameClickHouse: retentionOr(ctx, model.ConfigKeyLogRetentionDaysClickHouse), + }, + AvailableTargets: availableLogTargets(activeDB), + } +} + +func retentionOr(ctx context.Context, key string) int { + v, err := repository.GetIntByKey(ctx, key) + if err != nil { + if !isRecordMissing(err) { + logger.ErrorF(ctx, "读取日志保留天数配置失败 key=%s: %v", key, err) + } + return defaultLogRetentionDays + } + if v < 1 { + return defaultLogRetentionDays + } + return v +} + +func availableLogTargets(active string) []string { + if active == logDBNameClickHouse { + if GetDBConfig().Enabled { + return []string{logDBNamePostgres} + } + return []string{logDBNameSQLite} + } + if GetClickHouseConfig().Enabled { + return []string{logDBNameClickHouse} + } + return []string{} +} diff --git a/backend/plugins/domain/admin/service/system_config_test.go b/backend/plugins/domain/admin/service/system_config_test.go new file mode 100644 index 00000000..bce4321c --- /dev/null +++ b/backend/plugins/domain/admin/service/system_config_test.go @@ -0,0 +1,159 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package service_test + +import ( + "Wavelet/plugins/domain/admin/model" + "Wavelet/plugins/domain/admin/repository" + "Wavelet/plugins/domain/admin/service" + "context" + "testing" + "time" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +type testDBService struct { + db *gorm.DB +} + +func (s *testDBService) DB(ctx context.Context) *gorm.DB { + return s.db +} + +func (s *testDBService) MasterDB(ctx context.Context) *gorm.DB { + return s.db +} + +func (s *testDBService) GORM() *gorm.DB { + return s.db +} + +func (s *testDBService) Named(_ string) *gorm.DB { + return s.db +} + +func setupSystemConfigTest(t *testing.T) (*gorm.DB, func()) { + t.Helper() + + sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ + DisableForeignKeyConstraintWhenMigrating: true, + }) + if err != nil { + t.Fatalf("gorm.Open(sqlite) error = %v", err) + } + if err := sqliteDB.AutoMigrate(&model.SystemConfig{}); err != nil { + t.Fatalf("AutoMigrate(SystemConfig) error = %v", err) + } + + siteConfig := model.SystemConfig{ + Key: model.ConfigKeySiteName, + Value: "Wavelet", + Type: "system", + Description: "系统平台的展示名称", + } + if err := sqliteDB.Create(&siteConfig).Error; err != nil { + t.Fatalf("Create(site_name) error = %v", err) + } + + service.SetDBService(&testDBService{db: sqliteDB}) + + cleanup := func() { + repository.StopSystemConfigCacheListener() + repository.ResetSystemConfigRAMCacheForTest() + service.ResetServices() + } + + return sqliteDB, cleanup +} + +func TestListSystemConfigsByKeys_EmptyKeys(t *testing.T) { + result, err := repository.ListSystemConfigsByKeys(context.Background(), nil) + if err != nil { + t.Fatalf("ListSystemConfigsByKeys(nil) error = %v", err) + } + if len(result) != 0 { + t.Fatalf("ListSystemConfigsByKeys(nil) = %#v, want empty map", result) + } +} + +func TestListSystemConfigsByKeys_LoadsFromRAMCache(t *testing.T) { + dbConn, cleanup := setupSystemConfigTest(t) + defer cleanup() + ctx := context.Background() + + repository.ResetSystemConfigRAMCacheForTest() + + // Initial load + warm, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeySiteName) + if err != nil { + t.Fatalf("GetSystemConfigByKey(site_name) warm error = %v", err) + } + if warm.Value != "Wavelet" { + t.Fatalf("GetSystemConfigByKey(site_name).Value = %q, want %q", warm.Value, "Wavelet") + } + + // Update DB directly + if err := dbConn.Model(&model.SystemConfig{}). + Where("key = ?", model.ConfigKeySiteName). + Update("value", "db_only_value").Error; err != nil { + t.Fatalf("Update(site_name) error = %v", err) + } + + // Fetch via ListSystemConfigsByKeys should serve from local store (meaning the old value "Wavelet") + configs, err := repository.ListSystemConfigsByKeys(ctx, []string{model.ConfigKeySiteName}) + if err != nil { + t.Fatalf("ListSystemConfigsByKeys(site_name) error = %v", err) + } + + sc, ok := configs[model.ConfigKeySiteName] + if !ok { + t.Fatal("ListSystemConfigsByKeys(site_name) missing site_name entry") + } + if sc.Value != "Wavelet" { + t.Fatalf("ListSystemConfigsByKeys(site_name).Value = %q, want cached value %q", sc.Value, "Wavelet") + } +} + +func TestGetSystemConfigByGroupAndInvalidation(t *testing.T) { + dbConn, cleanup := setupSystemConfigTest(t) + defer cleanup() + ctx := context.Background() + + repository.ResetSystemConfigRAMCacheForTest() + + // Get via specific group/type + cfg, err := repository.GetSystemConfigByGroup(ctx, repository.ConfigCacheType, model.ConfigKeySiteName) + if err != nil { + t.Fatalf("GetSystemConfigByGroup error = %v", err) + } + if cfg.Value != "Wavelet" { + t.Fatalf("value = %q, want %q", cfg.Value, "Wavelet") + } + + // Direct DB update + if err := dbConn.Model(&model.SystemConfig{}). + Where("key = ?", model.ConfigKeySiteName). + Update("value", "new_site_name").Error; err != nil { + t.Fatalf("DB Update error = %v", err) + } + + // Invalidate + if err := repository.InvalidateSystemConfigCache(ctx, model.ConfigKeySiteName); err != nil { + t.Fatalf("InvalidateSystemConfigCache error = %v", err) + } + + // Wait for broadcast execution + time.Sleep(100 * time.Millisecond) + + // Fetch again + updated, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeySiteName) + if err != nil { + t.Fatalf("GetSystemConfigByKey error = %v", err) + } + if updated.Value != "new_site_name" { + t.Fatalf("value = %q, want %q", updated.Value, "new_site_name") + } +} diff --git a/backend/plugins/domain/admin/service/task.go b/backend/plugins/domain/admin/service/task.go new file mode 100644 index 00000000..2543ee85 --- /dev/null +++ b/backend/plugins/domain/admin/service/task.go @@ -0,0 +1,217 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "Wavelet/core/contracts" + "Wavelet/pkg/logger" + "Wavelet/plugins/domain/admin/errs" + "Wavelet/plugins/domain/admin/model" + "Wavelet/plugins/domain/admin/repository" + "context" + "fmt" + "strings" + + "github.com/robfig/cron/v3" +) + +// ListTaskTypes returns every dispatchable task type declared in the task registry. +func ListTaskTypes() []contracts.TaskMetaDTO { + taskSvc := GetTaskService() + if taskSvc == nil { + return []contracts.TaskMetaDTO{} + } + return taskSvc.ListTasks() +} + +// DispatchTask validates and enqueues a manual task run, returning the new task id. +func DispatchTask(ctx context.Context, req model.DispatchTaskRequest) (string, error) { + taskSvc, err := requireTaskService() + if err != nil { + return "", err + } + + if _, ok := taskSvc.GetTaskMeta(req.TaskType); !ok { + return "", errs.ErrInvalidTaskType + } + + validated, err := validateTaskPayload(taskSvc, req.TaskType, req.Payload) + if err != nil { + return "", err + } + + taskID, err := taskSvc.Dispatch(ctx, req.TaskType, validated, "manual") + if err != nil { + return "", fmt.Errorf("%s: %w", errs.TaskDispatchFailed, err) + } + return taskID, nil +} + +// validateTaskPayload normalises an optional raw payload through the task registry. +func validateTaskPayload(taskSvc contracts.TaskService, name, payload string) ([]byte, error) { + var payloadBytes []byte + if strings.TrimSpace(payload) != "" { + payloadBytes = []byte(payload) + } + + validated, err := taskSvc.ValidatePayload(name, payloadBytes) + if err != nil { + return nil, errs.NewInvalidInputError(err.Error()) + } + return validated, nil +} + +// ListTaskExecutions pages task execution records for the console. +func ListTaskExecutions( + ctx context.Context, + req model.ListTaskExecutionsRequest, +) ([]model.TaskExecution, int64, error) { + if req.TaskType != "" { + if taskSvc := GetTaskService(); taskSvc != nil { + if meta, ok := taskSvc.GetTaskMeta(req.TaskType); ok { + req.TaskType = meta.Name + } + } + } + + executions, total, err := repository.ListTaskExecutionRecords(ctx, req) + if err != nil { + return nil, 0, err + } + return executions, total, nil +} + +// TaskExecution loads a single execution record including its buffered log. +func TaskExecution(ctx context.Context, id uint64) (*model.TaskExecution, error) { + return repository.GetTaskExecutionByID(ctx, id) +} + +// RetryTask re-dispatches a failed execution as a new task run. +func RetryTask(ctx context.Context, id uint64) (string, error) { + taskSvc, err := requireTaskService() + if err != nil { + return "", err + } + + newTaskID, err := taskSvc.Retry(ctx, id) + if err != nil { + return "", err + } + return newTaskID, nil +} + +// IsRetryConflictError reports whether the task registry rejected the retry request +// because of the record state rather than an infrastructure failure. +func IsRetryConflictError(err error) bool { + msg := err.Error() + return strings.Contains(msg, errs.RemoteTaskNotFailedMsg) || + strings.Contains(msg, errs.RemoteTaskNotRetryableMsg) || + strings.Contains(msg, errs.RemoteTaskMaxRetryMsg) +} + +// IsRetryMissingError reports whether the referenced execution record is absent. +func IsRetryMissingError(err error) bool { + return strings.Contains(err.Error(), errs.RemoteTaskNotFoundMsg) +} + +// ListSchedules returns every dynamic schedule definition. +func ListSchedules(ctx context.Context) ([]model.Schedule, error) { + return repository.ListSchedulesRecord(ctx) +} + +// CreateSchedule validates a schedule definition, persists it and reloads the scheduler. +func CreateSchedule(ctx context.Context, req model.CreateScheduleRequest) (*model.Schedule, error) { + if _, err := cron.ParseStandard(req.Cron); err != nil { + return nil, errs.ErrInvalidCronExpression + } + + taskSvc, err := requireTaskService() + if err != nil { + return nil, err + } + + meta, ok := taskSvc.GetTaskMeta(req.TaskType) + if !ok { + return nil, errs.ErrInvalidTaskType + } + + validated, err := validateTaskPayload(taskSvc, meta.Name, req.Payload) + if err != nil { + return nil, err + } + + schedule := &model.Schedule{ + Name: req.Name, + TaskType: req.TaskType, + Cron: req.Cron, + Payload: string(validated), + IsActive: *req.IsActive, + } + + if err := repository.CreateScheduleRecord(ctx, schedule); err != nil { + return nil, fmt.Errorf("%s: %w", errs.ScheduleSaveFailed, err) + } + + reloadScheduler(ctx, taskSvc) + return schedule, nil +} + +// UpdateSchedule rewrites an existing schedule definition and reloads the scheduler. +func UpdateSchedule(ctx context.Context, id uint64, req model.UpdateScheduleRequest) (*model.Schedule, error) { + schedule, err := repository.GetScheduleByID(ctx, id) + if err != nil { + return nil, errs.ErrScheduleNotFound + } + + if _, err := cron.ParseStandard(req.Cron); err != nil { + return nil, errs.ErrInvalidCronExpression + } + + taskSvc, err := requireTaskService() + if err != nil { + return nil, err + } + + meta, ok := taskSvc.GetTaskMeta(req.TaskType) + if !ok { + return nil, errs.ErrInvalidTaskType + } + + validated, err := validateTaskPayload(taskSvc, meta.Name, req.Payload) + if err != nil { + return nil, err + } + + schedule.Name = req.Name + schedule.TaskType = req.TaskType + schedule.Cron = req.Cron + schedule.Payload = string(validated) + schedule.IsActive = *req.IsActive + + if err := repository.UpdateScheduleRecord(ctx, schedule); err != nil { + return nil, fmt.Errorf("%s: %w", errs.ScheduleSaveFailed, err) + } + + reloadScheduler(ctx, taskSvc) + return schedule, nil +} + +// DeleteSchedule removes a schedule definition and reloads the scheduler. +func DeleteSchedule(ctx context.Context, id uint64) error { + if err := repository.DeleteScheduleRecord(ctx, id); err != nil { + return fmt.Errorf("%s: %w", errs.ScheduleDeleteFailed, err) + } + + if taskSvc := GetTaskService(); taskSvc != nil { + reloadScheduler(ctx, taskSvc) + } + return nil +} + +// reloadScheduler triggers the hot reload, degrading gracefully when the scheduler rejects it. +func reloadScheduler(ctx context.Context, taskSvc contracts.TaskService) { + if err := taskSvc.ReloadScheduler(); err != nil { + logger.ErrorF(ctx, "[TaskAdmin] 重载调度器失败: %v", err) + } +} diff --git a/backend/plugins/domain/admin/service/template.go b/backend/plugins/domain/admin/service/template.go new file mode 100644 index 00000000..eac911f4 --- /dev/null +++ b/backend/plugins/domain/admin/service/template.go @@ -0,0 +1,86 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "Wavelet/plugins/domain/admin/errs" + "Wavelet/plugins/domain/admin/model" + "Wavelet/plugins/domain/admin/repository" + "context" +) + +// CreateTemplate persists a new notification template after key collision and field checks. +func CreateTemplate(ctx context.Context, req model.CreateTemplateRequest) (model.Template, error) { + exists, err := repository.TemplateExistsByKey(ctx, req.Key) + if err != nil { + return model.Template{}, err + } + if exists { + return model.Template{}, errs.ErrTemplateKeyExists + } + + tmpl := model.Template{ + Key: req.Key, + Name: req.Name, + Type: req.Type, + Subject: req.Subject, + Content: req.Content, + Description: req.Description, + IsSystem: false, + } + if err := tmpl.Validate(); err != nil { + return model.Template{}, err + } + if err := repository.CreateTemplateRecord(ctx, &tmpl); err != nil { + return model.Template{}, err + } + return tmpl, nil +} + +// ListTemplates returns every notification template. +func ListTemplates(ctx context.Context) ([]model.Template, error) { + return repository.ListTemplatesRecord(ctx) +} + +// GetTemplate loads a template by its identifier. +func GetTemplate(ctx context.Context, key string) (model.Template, error) { + tmpl, err := repository.GetTemplateByKey(ctx, key) + if err != nil { + return model.Template{}, translateNotFound(err, errs.ErrTemplateNotFound) + } + return tmpl, nil +} + +// UpdateTemplate rewrites the mutable fields of an existing template. +func UpdateTemplate(ctx context.Context, key string, req model.UpdateTemplateRequest) (model.Template, error) { + tmpl, err := GetTemplate(ctx, key) + if err != nil { + return model.Template{}, err + } + + tmpl.Name = req.Name + tmpl.Type = req.Type + tmpl.Subject = req.Subject + tmpl.Content = req.Content + tmpl.Description = req.Description + if err := tmpl.Validate(); err != nil { + return model.Template{}, err + } + if err := repository.SaveTemplateRecord(ctx, &tmpl); err != nil { + return model.Template{}, err + } + return tmpl, nil +} + +// DeleteTemplate removes a custom template; system presets are protected. +func DeleteTemplate(ctx context.Context, key string) error { + tmpl, err := GetTemplate(ctx, key) + if err != nil { + return err + } + if tmpl.IsSystem { + return errs.ErrSystemTemplateCannotDelete + } + return repository.DeleteTemplateRecord(ctx, &tmpl) +} diff --git a/backend/plugins/domain/admin/service/updater.go b/backend/plugins/domain/admin/service/updater.go new file mode 100644 index 00000000..79ec4b15 --- /dev/null +++ b/backend/plugins/domain/admin/service/updater.go @@ -0,0 +1,635 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "Wavelet/pkg/buildinfo" + "Wavelet/pkg/logger" + "Wavelet/plugins/domain/admin/errs" + "Wavelet/plugins/domain/admin/model" + "Wavelet/plugins/domain/admin/repository" + "archive/tar" + "archive/zip" + "compress/gzip" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "time" + + "golang.org/x/mod/semver" +) + +const ( + githubAPIBaseURL = "https://api.github.com" + maxArchiveSize = int64(1024 * 1024 * 1024) + maxReleaseSize = int64(4 * 1024 * 1024) + repositoryParts = 2 + windowsOS = "windows" + archiveFileMode = 0o600 + stagedBinaryMode = 0o700 +) + +type releaseAsset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + Size int64 `json:"size"` + State string `json:"state"` +} + +type githubRelease struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + Body string `json:"body"` + HTMLURL string `json:"html_url"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` + Published time.Time `json:"published_at"` + Assets []releaseAsset `json:"assets"` +} + +type releaseClient interface { + Do(req *http.Request) (*http.Response, error) +} + +// UpdaterManager manages application binary updates from GitHub releases. +type UpdaterManager struct { + client releaseClient + mu sync.Mutex + upgrading bool +} + +// DefaultUpdaterManager is the default singleton update manager. +var DefaultUpdaterManager = &UpdaterManager{ + client: &http.Client{Timeout: 10 * time.Minute}, +} + +func normalizeVersion(version string) string { + version = strings.TrimSpace(version) + if version == "" || version == "dev" { + return "" + } + if !strings.HasPrefix(version, "v") { + version = "v" + version + } + if !semver.IsValid(version) { + return "" + } + return version +} + +func parseRepository(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", errors.New(errs.ErrInvalidRepository) + } + + if !strings.Contains(raw, "://") { + repo := strings.TrimSuffix(strings.Trim(raw, "/"), ".git") + if len(strings.Split(repo, "/")) == repositoryParts { + return repo, nil + } + return "", errors.New(errs.ErrInvalidRepository) + } + + parsed, err := url.Parse(raw) + if err != nil || !strings.EqualFold(parsed.Hostname(), "github.com") { + return "", errors.New(errs.ErrInvalidRepository) + } + repo := strings.TrimSuffix(strings.Trim(parsed.Path, "/"), ".git") + if len(strings.Split(repo, "/")) != repositoryParts { + return "", errors.New(errs.ErrInvalidRepository) + } + return repo, nil +} + +func expectedAssetName(tag string) string { + extension := "tar.gz" + if runtime.GOOS == windowsOS { + extension = "zip" + } + return fmt.Sprintf("wavelet_%s_%s_%s.%s", tag, runtime.GOOS, runtime.GOARCH, extension) +} + +func expectedAssetNames(repo, tag string) []string { + names := []string{expectedAssetName(tag)} + if parts := strings.Split(repo, "/"); len(parts) == repositoryParts { + repoName := parts[1] + if repoName != "wavelet" { + extension := "tar.gz" + if runtime.GOOS == windowsOS { + extension = "zip" + } + names = append(names, fmt.Sprintf("%s_%s_%s_%s.%s", repoName, tag, runtime.GOOS, runtime.GOARCH, extension)) + } + } + return names +} + +func selectLatestRelease(repo string, releases []githubRelease) (githubRelease, releaseAsset, error) { + var selected githubRelease + var selectedAsset releaseAsset + selectedVersion := "" + + for _, release := range releases { + version := normalizeVersion(release.TagName) + if release.Draft || version == "" { + continue + } + expectedNames := expectedAssetNames(repo, release.TagName) + for _, asset := range release.Assets { + matched := false + for _, name := range expectedNames { + if asset.Name == name { + matched = true + break + } + } + if !matched || asset.BrowserDownloadURL == "" || asset.State != "uploaded" { + continue + } + if selectedVersion == "" || semver.Compare(version, selectedVersion) > 0 { + selected = release + selectedAsset = asset + selectedVersion = version + } + } + } + + if selectedVersion == "" { + return githubRelease{}, releaseAsset{}, errors.New(errs.ErrNoCompatibleRelease) + } + return selected, selectedAsset, nil +} + +func (m *UpdaterManager) fetchRelease(ctx context.Context, repo string) (githubRelease, releaseAsset, error) { + req, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + fmt.Sprintf("%s/repos/%s/releases?per_page=30", githubAPIBaseURL, repo), + nil, + ) + if err != nil { + return githubRelease{}, releaseAsset{}, fmt.Errorf("%s: %w", errs.ErrReleaseRequestFailed, err) + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "Wavelet-Updater") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + resp, err := m.client.Do(req) + if err != nil { + return githubRelease{}, releaseAsset{}, fmt.Errorf("%s: %w", errs.ErrReleaseRequestFailed, err) + } + defer func() { + _ = resp.Body.Close() + }() + if resp.StatusCode != http.StatusOK { + return githubRelease{}, releaseAsset{}, fmt.Errorf("%s: HTTP %d", errs.ErrReleaseRequestFailed, resp.StatusCode) + } + + var releases []githubRelease + decoder := json.NewDecoder(io.LimitReader(resp.Body, maxReleaseSize)) + if err := decoder.Decode(&releases); err != nil { + return githubRelease{}, releaseAsset{}, fmt.Errorf("%s: %w", errs.ErrReleaseResponseInvalid, err) + } + + release, asset, err := selectLatestRelease(repo, releases) + if err != nil { + return githubRelease{}, releaseAsset{}, err + } + logger.InfoF(ctx, "[Updater] Selected latest compatible release: %s (Asset: %s)", release.TagName, asset.Name) + return release, asset, nil +} + +func loadRepository(ctx context.Context) (string, error) { + cfg, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyUpdateUpstreamRepository) + if err != nil { + return "", fmt.Errorf("%s: %w", errs.ErrInvalidRepository, err) + } + return parseRepository(cfg.Value) +} + +// status returns current version and update status. +func (m *UpdaterManager) status(ctx context.Context) (model.UpdaterStatus, releaseAsset, error) { + upstreamRepo, err := loadRepository(ctx) + if err != nil { + return model.UpdaterStatus{}, releaseAsset{}, err + } + release, asset, err := m.fetchRelease(ctx, upstreamRepo) + if err != nil { + return model.UpdaterStatus{}, releaseAsset{}, err + } + + currentVersion := normalizeVersion(buildinfo.Version) + latestVersion := normalizeVersion(release.TagName) + updateAvailable := currentVersion != "" && semver.Compare(latestVersion, currentVersion) > 0 + + logger.InfoF(ctx, "[Updater] Check update complete. current: %s, latest: %s, update_available: %t", buildinfo.Version, release.TagName, updateAvailable) + + return model.UpdaterStatus{ + CurrentVersion: buildinfo.Version, + BuildTime: buildinfo.BuildTime, + LatestVersion: release.TagName, + UpdateAvailable: updateAvailable, + CanUpgrade: updateAvailable && runtime.GOOS != windowsOS, + Prerelease: release.Prerelease, + ReleaseName: release.Name, + ReleaseNotes: release.Body, + ReleaseURL: release.HTMLURL, + PublishedAt: release.Published.Format(time.RFC3339), + UpstreamRepository: upstreamRepo, + AssetName: asset.Name, + Platform: runtime.GOOS + "/" + runtime.GOARCH, + }, asset, nil +} + +// GetUpdateStatus returns current updater status. +func GetUpdateStatus(ctx context.Context) (model.UpdaterStatus, error) { + status, _, err := DefaultUpdaterManager.status(ctx) + return status, err +} + +func downloadArchive(ctx context.Context, client releaseClient, asset releaseAsset, destination string) error { + if asset.Size <= 0 || asset.Size > maxArchiveSize { + return fmt.Errorf(errs.ErrReleaseAssetSizeInvalid, asset.Size) + } + logger.InfoF(ctx, "[Updater] Downloading release asset: %s", asset.Name) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, asset.BrowserDownloadURL, nil) + if err != nil { + return fmt.Errorf(errs.ErrCreateUpgradeRequestFailed, err) + } + req.Header.Set("User-Agent", "Wavelet-Updater") + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf(errs.ErrDownloadUpgradeAssetFailed, err) + } + defer func() { + _ = resp.Body.Close() + }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf(errs.ErrUpgradeAssetHTTPFailed, resp.StatusCode) + } + + //nolint:gosec // updater download destination is validated + file, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, archiveFileMode) + if err != nil { + return fmt.Errorf(errs.ErrCreateUpgradeArchiveFailed, err) + } + + written, err := io.Copy(file, io.LimitReader(resp.Body, maxArchiveSize+1)) + if err != nil { + _ = file.Close() + return fmt.Errorf(errs.ErrWriteUpgradeArchiveFailed, err) + } + if err := file.Close(); err != nil { + return fmt.Errorf(errs.ErrCloseUpgradeArchiveFailed, err) + } + if written > maxArchiveSize || written != asset.Size { + return fmt.Errorf(errs.ErrUpgradeArchiveSizeMismatch, written, asset.Size) + } + logger.InfoF(ctx, "[Updater] Successfully downloaded release asset to %s", destination) + return nil +} + +func safeArchivePath(destination, name string) (string, error) { + cleanName := filepath.Clean(name) + if filepath.IsAbs(cleanName) || cleanName == "." || strings.HasPrefix(cleanName, ".."+string(filepath.Separator)) { + return "", fmt.Errorf(errs.ErrArchiveContainsIllegalPath, name) + } + target := filepath.Join(destination, cleanName) + relative, err := filepath.Rel(destination, target) + if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return "", fmt.Errorf(errs.ErrArchivePathOutOfDestination, name) + } + return target, nil +} + +func matchBinaryName(name string, candidates []string) bool { + for _, candidate := range candidates { + if runtime.GOOS == windowsOS { + if strings.EqualFold(name, candidate) { + return true + } + } else { + if name == candidate { + return true + } + } + } + return false +} + +func getCandidateBinaryNames(executable, repo string) []string { + execName := filepath.Base(executable) + names := []string{execName} + + addName := func(base string) { + name := base + if runtime.GOOS == windowsOS && !strings.HasSuffix(strings.ToLower(name), ".exe") { + name += ".exe" + } + for _, existing := range names { + if existing == name { + return + } + } + names = append(names, name) + } + + if parts := strings.Split(repo, "/"); len(parts) == repositoryParts { + addName(parts[1]) + } + addName("wavelet") + + return names +} + +func isLikelyBinary(name string, isDir bool, mode os.FileMode) bool { + if isDir { + return false + } + base := strings.ToLower(filepath.Base(name)) + + exclusions := []string{ + "license", "licence", "copying", "notice", "readme", "changelog", + } + for _, excl := range exclusions { + if strings.HasPrefix(base, excl) { + return false + } + } + + if runtime.GOOS == windowsOS { + return filepath.Ext(base) == ".exe" + } + + return (mode.Perm()&0o111 != 0) || (filepath.Ext(base) == "") +} + +func findBinaryInTarGz(archivePath string, candidates []string) (string, error) { + //nolint:gosec // updater archivePath is verified + file, err := os.Open(archivePath) + if err != nil { + return "", err + } + defer func() { + _ = file.Close() + }() + + gzipReader, err := gzip.NewReader(file) + if err != nil { + return "", err + } + defer func() { + _ = gzipReader.Close() + }() + + reader := tar.NewReader(gzipReader) + var binaries []string + for { + header, err := reader.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return "", err + } + if header.Typeflag == tar.TypeReg && isLikelyBinary(header.Name, false, header.FileInfo().Mode()) { + binaries = append(binaries, header.Name) + } + } + + if len(binaries) == 1 { + return binaries[0], nil + } + + for _, name := range binaries { + if matchBinaryName(filepath.Base(name), candidates) { + return name, nil + } + } + + return "", errors.New(errs.ErrNoCompatibleAsset) +} + +func findBinaryInZip(archivePath string, candidates []string) (string, error) { + reader, err := zip.OpenReader(archivePath) + if err != nil { + return "", err + } + defer func() { + _ = reader.Close() + }() + + var binaries []string + for _, file := range reader.File { + if !file.FileInfo().IsDir() && isLikelyBinary(file.Name, false, file.FileInfo().Mode()) { + binaries = append(binaries, file.Name) + } + } + + if len(binaries) == 1 { + return binaries[0], nil + } + + for _, name := range binaries { + if matchBinaryName(filepath.Base(name), candidates) { + return name, nil + } + } + + return "", errors.New(errs.ErrNoCompatibleAsset) +} + +func extractTarGz(ctx context.Context, archivePath, destination, targetName string, candidates []string) (string, error) { + binaryPathInArchive, err := findBinaryInTarGz(archivePath, candidates) + if err != nil { + return "", err + } + + logger.InfoF(ctx, "[Updater] Extracting tar.gz archive: %s (extracting: %s)", archivePath, binaryPathInArchive) + //nolint:gosec // updater archivePath is verified + file, err := os.Open(archivePath) + if err != nil { + return "", err + } + defer func() { + _ = file.Close() + }() + gzipReader, err := gzip.NewReader(file) + if err != nil { + return "", err + } + defer func() { + _ = gzipReader.Close() + }() + + reader := tar.NewReader(gzipReader) + for { + header, err := reader.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return "", err + } + if header.Name != binaryPathInArchive { + continue + } + target, err := safeArchivePath(destination, targetName) + if err != nil { + return "", err + } + //nolint:gosec // updater destination is sanitized + output, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, stagedBinaryMode) + if err != nil { + return "", err + } + written, copyErr := io.Copy(output, io.LimitReader(reader, maxArchiveSize+1)) + closeErr := output.Close() + if copyErr != nil { + return "", copyErr + } + if closeErr != nil { + return "", closeErr + } + if written > maxArchiveSize { + return "", errors.New(errs.ErrExtractedBinaryTooLarge) + } + logger.InfoF(ctx, "[Updater] Successfully extracted binary to %s", target) + return target, nil + } + return "", errors.New(errs.ErrNoCompatibleAsset) +} + +func extractZip(ctx context.Context, archivePath, destination, targetName string, candidates []string) (string, error) { + binaryPathInArchive, err := findBinaryInZip(archivePath, candidates) + if err != nil { + return "", err + } + + logger.InfoF(ctx, "[Updater] Extracting zip archive: %s (extracting: %s)", archivePath, binaryPathInArchive) + reader, err := zip.OpenReader(archivePath) + if err != nil { + return "", err + } + defer func() { + _ = reader.Close() + }() + for _, file := range reader.File { + if file.Name != binaryPathInArchive { + continue + } + target, err := safeArchivePath(destination, targetName) + if err != nil { + return "", err + } + input, err := file.Open() + if err != nil { + return "", err + } + //nolint:gosec // updater extraction target is safe + output, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, stagedBinaryMode) + if err != nil { + return "", err + } + written, copyErr := io.Copy(output, io.LimitReader(input, maxArchiveSize+1)) + inputCloseErr := input.Close() + outputCloseErr := output.Close() + if copyErr != nil { + return "", copyErr + } + if inputCloseErr != nil { + return "", inputCloseErr + } + if outputCloseErr != nil { + return "", outputCloseErr + } + if written > maxArchiveSize { + return "", errors.New(errs.ErrExtractedBinaryTooLarge) + } + logger.InfoF(ctx, "[Updater] Successfully extracted binary to %s", target) + return target, nil + } + return "", errors.New(errs.ErrNoCompatibleAsset) +} + +// PrepareUpgrade validates preconditions and downloads the newest binary. +func (m *UpdaterManager) PrepareUpgrade(ctx context.Context) (string, string, error) { + if runtime.GOOS == windowsOS { + return "", "", errors.New(errs.ErrAutomaticUpgradeBlocked) + } + if normalizeVersion(buildinfo.Version) == "" { + return "", "", errors.New(errs.ErrDevelopmentBuild) + } + + m.mu.Lock() + defer m.mu.Unlock() + if m.upgrading { + return "", "", errors.New(errs.ErrUpgradeAlreadyRunning) + } + + status, asset, err := m.status(ctx) + if err != nil { + return "", "", err + } + if !status.UpdateAvailable { + return "", "", errors.New(errs.ErrAlreadyUpToDate) + } + + logger.InfoF(ctx, "[Updater] Preparing upgrade. current: %s, latest: %s", status.CurrentVersion, status.LatestVersion) + + executable, err := os.Executable() + if err != nil { + return "", "", fmt.Errorf(errs.ErrLocateExecutableFailed, err) + } + executable, err = filepath.EvalSymlinks(executable) + if err != nil { + return "", "", fmt.Errorf(errs.ErrResolveExecutablePathFailed, err) + } + + tempDir, err := os.MkdirTemp(filepath.Dir(executable), ".wavelet-update-*") + if err != nil { + return "", "", fmt.Errorf(errs.ErrCreateUpgradeDirFailed, err) + } + + archivePath := filepath.Join(tempDir, asset.Name) + if err := downloadArchive(ctx, m.client, asset, archivePath); err != nil { + _ = os.RemoveAll(tempDir) + return "", "", err + } + + targetName := filepath.Base(executable) + candidates := getCandidateBinaryNames(executable, status.UpstreamRepository) + + var stagedBinary string + if strings.HasSuffix(asset.Name, ".zip") { + stagedBinary, err = extractZip(ctx, archivePath, tempDir, targetName, candidates) + } else { + stagedBinary, err = extractTarGz(ctx, archivePath, tempDir, targetName, candidates) + } + if err != nil { + _ = os.RemoveAll(tempDir) + return "", "", fmt.Errorf(errs.ErrExtractUpgradeAssetFailed, err) + } + logger.InfoF(ctx, "[Updater] Staged binary successfully prepared: %s", stagedBinary) + m.upgrading = true + return executable, stagedBinary, nil +} + +// FinishUpgrade resets the upgrading flag. +func (m *UpdaterManager) FinishUpgrade() { + m.mu.Lock() + defer m.mu.Unlock() + m.upgrading = false +} diff --git a/backend/plugins/domain/admin/service/user.go b/backend/plugins/domain/admin/service/user.go new file mode 100644 index 00000000..ff383368 --- /dev/null +++ b/backend/plugins/domain/admin/service/user.go @@ -0,0 +1,120 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "Wavelet/core/contracts" + "Wavelet/pkg/logger" + "Wavelet/plugins/domain/admin/errs" + "Wavelet/plugins/domain/admin/model" + "context" + "errors" +) + +// ToUserResponse projects the user contract DTO onto the console response shape. +func ToUserResponse(u *contracts.UserDTO) model.UserResponse { + if u == nil { + return model.UserResponse{} + } + return model.UserResponse{ + ID: u.ID, + Username: u.Username, + Nickname: u.Nickname, + Email: u.Email, + AvatarURL: u.AvatarURL, + IsActive: u.IsActive, + IsAdmin: u.IsAdmin, + Bio: u.Bio, + Phone: u.Phone, + Gender: u.Gender, + Website: u.Website, + Location: u.Location, + LastLoginAt: u.LastLoginAt, + CreatedAt: u.CreatedAt, + UpdatedAt: u.UpdatedAt, + } +} + +// AdminListUsers pages users through the user contract service. +func AdminListUsers( + ctx context.Context, + filter contracts.AdminListUsersFilter, +) (int64, []*contracts.UserDTO, error) { + userSvc, err := requireUserService(ctx) + if err != nil { + return 0, nil, err + } + + total, dtos, err := userSvc.AdminListUsers(ctx, filter) + if err != nil { + logger.ErrorF(ctx, "List admin users failed: %v", err) + return 0, nil, errors.New(errs.ListAdminUsersFailed) + } + return total, dtos, nil +} + +// AdminGetUser loads a single user profile. +func AdminGetUser(ctx context.Context, id uint64) (*contracts.UserDTO, error) { + userSvc, err := requireUserService(ctx) + if err != nil { + return nil, err + } + + targetUser, err := userSvc.AdminGetUser(ctx, id) + if err != nil { + return nil, translateNotFound(err, errs.ErrUserNotFound) + } + return targetUser, nil +} + +// AdminUpdateUserStatus enables or disables a user account. +func AdminUpdateUserStatus(ctx context.Context, id uint64, isActive bool) error { + userSvc, err := requireUserService(ctx) + if err != nil { + return err + } + + err = userSvc.AdminUpdateUserStatus(ctx, id, isActive) + return translateNotFound(err, errs.ErrUserNotFound) +} + +// AdminDeleteUser removes a user on behalf of the acting administrator. +func AdminDeleteUser(ctx context.Context, operatorID, id uint64) error { + userSvc, err := requireUserService(ctx) + if err != nil { + return err + } + + err = userSvc.AdminDeleteUser(ctx, operatorID, id) + return translateNotFound(err, errs.ErrUserNotFound) +} + +// AdminCreateUser registers a local-password user. +func AdminCreateUser(ctx context.Context, req contracts.AdminCreateUserRequest) (*contracts.UserDTO, error) { + userSvc, err := requireUserService(ctx) + if err != nil { + return nil, err + } + + newUser, err := userSvc.AdminCreateUser(ctx, req) + if err != nil { + return nil, translateNotFound(err, errs.ErrUserNotFound) + } + return newUser, nil +} + +// AdminUpdateUser rewrites a user profile and optionally resets its password. +func AdminUpdateUser( + ctx context.Context, + operatorID uint64, + req contracts.AdminUpdateUserRequest, +) error { + userSvc, err := requireUserService(ctx) + if err != nil { + return err + } + + err = userSvc.AdminUpdateUser(ctx, operatorID, req) + return translateNotFound(err, errs.ErrUserNotFound) +} diff --git a/backend/plugins/domain/auth/audit.go b/backend/plugins/domain/auth/audit.go new file mode 100644 index 00000000..f5755340 --- /dev/null +++ b/backend/plugins/domain/auth/audit.go @@ -0,0 +1,37 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "Wavelet/core/contracts" + "Wavelet/pkg/logger" + "context" + "encoding/json" + + "github.com/gin-gonic/gin" +) + +// LogForAudit 将登录鉴权审计日志写入 Logger +func LogForAudit(ctx context.Context, user *contracts.UserDTO, c *gin.Context) { + if user == nil || c == nil { + return + } + auditLog := loginRequiredAuditLog{ + UserID: user.ID, + Username: user.Username, + ClientIP: c.ClientIP(), + Method: c.Request.Method, + Path: c.Request.URL.Path, + RequestURI: c.Request.RequestURI, + UserAgent: c.Request.UserAgent(), + Referer: c.Request.Referer(), + } + auditJSON, err := json.Marshal(auditLog) + if err != nil { + logger.ErrorF(ctx, "[LoginRequiredAudit] marshal failed: %v", err) + logger.DebugF(ctx, "[LoginRequiredAudit] %s %d %s", c.ClientIP(), user.ID, user.Username) + } else { + logger.DebugF(ctx, "[LoginRequiredAudit] %s", auditJSON) + } +} diff --git a/backend/plugins/domain/auth/auth_source_resolver.go b/backend/plugins/domain/auth/auth_source_resolver.go new file mode 100644 index 00000000..4d1c3992 --- /dev/null +++ b/backend/plugins/domain/auth/auth_source_resolver.go @@ -0,0 +1,217 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "Wavelet/core/contracts" + "context" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +func isOIDCLoginEnabled(ctx context.Context) bool { + val, err := GetSystemConfigValue(ctx, "oidc_login_enabled") + if err != nil || val == "" { + return true + } + b, err := strconv.ParseBool(val) + if err != nil { + return true + } + return b +} + +func resolveAuthSource(ctx context.Context, sourceName string) (*AuthSource, error) { + name := strings.TrimSpace(strings.ToLower(sourceName)) + if name == "" { + sources, err := GetActiveAuthSourcesCached(ctx) + if err != nil { + return nil, err + } + if len(sources) == 0 { + return nil, errors.New(errNoActiveAuthSource) + } + src, err := GetAuthSourceByNameCached(ctx, sources[0].Name) + if err != nil { + return nil, err + } + return src, nil + } + src, err := GetAuthSourceByNameCached(ctx, name) + if err != nil { + return nil, err + } + return src, nil +} + +func activeLoginSources(ctx context.Context) []AuthSourceView { + if !isOIDCLoginEnabled(ctx) { + return nil + } + + dbSources, err := GetActiveAuthSourcesCached(ctx) + if err != nil { + return nil + } + sources := make([]AuthSourceView, 0, len(dbSources)) + for _, source := range dbSources { + sources = append(sources, AuthSourceView{ + ID: source.ID, + Name: source.Name, + Type: source.Type, + DisplayName: source.DisplayName, + IsActive: source.IsActive, + IconURL: source.IconURL, + ClientSecretConfigured: source.ClientSecretConfigured, + }) + } + return sources +} + +func getFrontendLoginRedirectURL(ctx context.Context) (string, error) { + val, err := GetSystemConfigValue(ctx, "server_address") + if err != nil || strings.TrimSpace(val) == "" { + return "", errors.New(errServerAddressMissing) + } + return strings.TrimRight(val, "/") + "/login", nil +} + +func buildOAuthConfig(ctx context.Context, source *AuthSource, redirectURL string) (*oauth2.Config, *oidc.IDTokenVerifier, error) { + if source == nil { + return nil, nil, errors.New(errAuthSourceRequired) + } + + if source.OpenIDDiscoveryURL == "" { + return nil, nil, errors.New(errDiscoveryURLRequired) + } + + // Clean the issuer URL + issuer := strings.TrimSuffix(strings.TrimSpace(source.OpenIDDiscoveryURL), "/") + issuer = strings.TrimSuffix(issuer, "/.well-known/openid-configuration") + issuer = strings.TrimSuffix(issuer, "/.well-known/oauth-authorization-server") + + provider, err := globalOIDCProviderCache.get(ctx, issuer) + if err != nil { + return nil, nil, err + } + verifier := provider.Verifier(&oidc.Config{ClientID: source.ClientID}) + scopes := strings.Fields(source.Scopes) + if len(scopes) == 0 { + scopes = []string{oidc.ScopeOpenID, "profile", "email"} + } + if !containsScope(scopes, oidc.ScopeOpenID) { + scopes = append([]string{oidc.ScopeOpenID}, scopes...) + } + + return &oauth2.Config{ + ClientID: source.ClientID, + ClientSecret: source.ClientSecret, + RedirectURL: redirectURL, + Scopes: scopes, + Endpoint: provider.Endpoint(), + }, verifier, nil +} + +func containsScope(scopes []string, scope string) bool { + for _, item := range scopes { + if item == scope { + return true + } + } + return false +} + +func buildOAuthUserInfo(ctx context.Context, source *AuthSource, code, nonce, redirectURL string) (*contracts.OAuthUserInfoDTO, error) { + authConfig, verifier, err := buildOAuthConfig(ctx, source, redirectURL) + if err != nil { + return nil, err + } + + token, err := authConfig.Exchange(ctx, code) + if err != nil { + return nil, err + } + + userInfo := &contracts.OAuthUserInfoDTO{Active: true} + if verifier != nil { + if verifyErr := verifyIDToken(ctx, verifier, token, nonce, userInfo); verifyErr != nil { + return nil, verifyErr + } + } + + if userInfo.Username == "" && userInfo.PreferredUsername != "" { + userInfo.Username = userInfo.PreferredUsername + } + if userInfo.Username == "" && userInfo.Email != "" { + userInfo.Username = strings.Split(userInfo.Email, "@")[0] + } + if userInfo.Username == "" && userInfo.Sub != "" { + userInfo.Username = userInfo.Sub + } + if userInfo.Name == "" { + userInfo.Name = userInfo.Username + } + + return userInfo, nil +} + +func verifyIDToken(ctx context.Context, verifier *oidc.IDTokenVerifier, token *oauth2.Token, nonce string, userInfo *contracts.OAuthUserInfoDTO) error { + rawIDToken, ok := token.Extra("id_token").(string) + if !ok { + return nil + } + idToken, verifyErr := verifier.Verify(ctx, rawIDToken) + if verifyErr != nil { + return fmt.Errorf(errIDTokenVerifyFailedFormat, errIDTokenVerifyFailed, verifyErr) + } + if nonce != "" && idToken.Nonce != nonce { + return errors.New(errNonceMismatch) + } + if claimsErr := idToken.Claims(userInfo); claimsErr != nil { + return claimsErr + } + return nil +} + +func normalizeOAuthUserInfo(userInfo *contracts.OAuthUserInfoDTO) error { + userInfo.Username = strings.TrimSpace(userInfo.Username) + userInfo.PreferredUsername = strings.TrimSpace(userInfo.PreferredUsername) + userInfo.Email = strings.TrimSpace(userInfo.Email) + userInfo.Name = strings.TrimSpace(userInfo.Name) + userInfo.AvatarURL = strings.TrimSpace(userInfo.AvatarURL) + + if userInfo.Username == "" && userInfo.PreferredUsername != "" { + userInfo.Username = userInfo.PreferredUsername + } + if userInfo.Username == "" && userInfo.Email != "" { + userInfo.Username = strings.Split(userInfo.Email, "@")[0] + } + if userInfo.Username == "" && userInfo.Sub != "" { + userInfo.Username = userInfo.Sub + } + if userInfo.Username == "" { + return errors.New(errUsernameFromSourceFailed) + } + if userInfo.Name == "" { + userInfo.Name = userInfo.Username + } + if !userInfo.Active { + userInfo.Active = true + } + return nil +} + +func buildCallbackResult(user *contracts.UserDTO, status string) OAuthCallbackResult { + result := OAuthCallbackResult{Status: status} + if user != nil { + info := BuildBasicUserInfo(user, false) + result.User = &info + } + return result +} diff --git a/backend/plugins/domain/auth/cache.go b/backend/plugins/domain/auth/cache.go new file mode 100644 index 00000000..0beda8ec --- /dev/null +++ b/backend/plugins/domain/auth/cache.go @@ -0,0 +1,116 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "Wavelet/core/contracts" + "Wavelet/pkg/cache/ram" + "context" + "fmt" + "time" +) + +const ( + tokenCacheTTL = 5 * time.Minute + userCacheTTL = 5 * time.Minute +) + +// CachedToken represents the minimal cached representation of an access token. +type CachedToken struct { + ID uint64 `json:"id"` + UserID uint64 `json:"user_id"` + IsAdmin bool `json:"is_admin"` +} + +var ( + tokenRAM = ram.MustNew[string, *CachedToken](ram.Options{MaximumSize: 2048}) + userRAM = ram.MustNew[uint64, *contracts.UserDTO](ram.Options{MaximumSize: 2048}) +) + +func tokenCacheKey(tokenHash string) string { + return "oauth:token:" + tokenHash +} + +func userCacheKey(userID uint64) string { + return fmt.Sprintf("oauth:user:%d", userID) +} + +// GetCachedToken 获取缓存的 Token +func GetCachedToken(ctx context.Context, tokenHash string) (*CachedToken, error) { + if val, ok := tokenRAM.GetIfPresent(tokenHash); ok { + return val, nil + } + + if cache := getCache(ctx); cache != nil { + var token CachedToken + key := tokenCacheKey(tokenHash) + if err := cache.Get(ctx, key, &token); err == nil { + tokenRAM.Set(tokenHash, &token) + return &token, nil + } + } + return nil, fmt.Errorf("cache miss") +} + +// SetCachedToken 设置 Token 缓存 +func SetCachedToken(ctx context.Context, tokenHash string, token *CachedToken) { + tokenRAM.Set(tokenHash, token) + if cache := getCache(ctx); cache != nil { + key := tokenCacheKey(tokenHash) + _ = cache.Set(ctx, key, token, tokenCacheTTL) + } +} + +// InvalidateCachedToken 吊销/删除 token 缓存 +func InvalidateCachedToken(ctx context.Context, tokenHash string) { + tokenRAM.Invalidate(tokenHash) + if cache := getCache(ctx); cache != nil { + key := tokenCacheKey(tokenHash) + _ = cache.Delete(ctx, key) + } +} + +// GetCachedUser 获取缓存的 UserDTO +func GetCachedUser(ctx context.Context, userID uint64) (*contracts.UserDTO, error) { + if val, ok := userRAM.GetIfPresent(userID); ok { + return val, nil + } + + if cache := getCache(ctx); cache != nil { + var u contracts.UserDTO + key := userCacheKey(userID) + if err := cache.Get(ctx, key, &u); err == nil { + userRAM.Set(userID, &u) + return &u, nil + } + } + return nil, fmt.Errorf("cache miss") +} + +// SetCachedUser 设置 UserDTO 缓存 +func SetCachedUser(ctx context.Context, userID uint64, u *contracts.UserDTO) { + userRAM.Set(userID, u) + if cache := getCache(ctx); cache != nil { + key := userCacheKey(userID) + _ = cache.Set(ctx, key, u, userCacheTTL) + } +} + +// InvalidateCachedUser 吊销/失效 UserDTO 缓存 +func InvalidateCachedUser(ctx context.Context, userID uint64) { + userRAM.Invalidate(userID) + if cache := getCache(ctx); cache != nil { + key := userCacheKey(userID) + _ = cache.Delete(ctx, key) + } +} + +// StopAuthCacheListener compatibility stub for tests +func StopAuthCacheListener() {} + +// ResetAuthRAMCacheForTest clears only the process-local RAM cache. +func ResetAuthRAMCacheForTest() { + tokenRAM.InvalidateAll() + userRAM.InvalidateAll() +} diff --git a/backend/plugins/domain/auth/cache_test.go b/backend/plugins/domain/auth/cache_test.go new file mode 100644 index 00000000..80e95b6f --- /dev/null +++ b/backend/plugins/domain/auth/cache_test.go @@ -0,0 +1,144 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package auth_test + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "Wavelet/plugins/domain/auth" + "context" + "encoding/json" + "testing" + "time" +) + +type mockCacheService struct { + items map[string][]byte +} + +func newMockCacheService() *mockCacheService { + return &mockCacheService{items: make(map[string][]byte)} +} + +func (m *mockCacheService) Get(ctx context.Context, key string, target any) error { + b, ok := m.items[key] + if !ok { + return contracts.ErrCacheMiss + } + return json.Unmarshal(b, target) +} + +func (m *mockCacheService) Set(ctx context.Context, key string, value any, ttl time.Duration) error { + b, err := json.Marshal(value) + if err != nil { + return err + } + m.items[key] = b + return nil +} + +func (m *mockCacheService) Delete(ctx context.Context, key string) error { + delete(m.items, key) + return nil +} + +func (m *mockCacheService) Invalidate(ctx context.Context, key string) error { + return m.Delete(ctx, key) +} + +func (m *mockCacheService) GetOrSet(ctx context.Context, key string, target any, ttl time.Duration, loader func() (any, error)) error { + err := m.Get(ctx, key, target) + if err == nil { + return nil + } + val, err := loader() + if err != nil { + return err + } + if err := m.Set(ctx, key, val, ttl); err != nil { + return err + } + b, _ := json.Marshal(val) + return json.Unmarshal(b, target) +} + +func TestTokenCache_GetSetInvalidate(t *testing.T) { + ctx := core.NewContext(context.Background()) + mockCache := newMockCacheService() + core.Provide[contracts.CacheService](ctx, mockCache) + + tokenHash := "test-token-hash" + token := &auth.CachedToken{ + ID: 123, + UserID: 456, + IsAdmin: true, + } + + // 1. Get from empty cache -> miss + _, err := auth.GetCachedToken(ctx, tokenHash) + if err == nil { + t.Fatal("expected cache miss for un-cached token") + } + + // 2. Set to cache + auth.SetCachedToken(ctx, tokenHash, token) + + // 3. Get from cache -> hit + cached, err := auth.GetCachedToken(ctx, tokenHash) + if err != nil { + t.Fatalf("GetCachedToken() failed: %v", err) + } + if cached.ID != token.ID || cached.UserID != token.UserID || cached.IsAdmin != token.IsAdmin { + t.Fatalf("expected cached token %+v, got %+v", token, cached) + } + + // 4. Invalidate cache + auth.InvalidateCachedToken(ctx, tokenHash) + + // 5. Get from cache -> miss + _, err = auth.GetCachedToken(ctx, tokenHash) + if err == nil { + t.Fatal("expected cache miss after invalidation") + } +} + +func TestUserCache_GetSetInvalidate(t *testing.T) { + ctx := core.NewContext(context.Background()) + mockCache := newMockCacheService() + core.Provide[contracts.CacheService](ctx, mockCache) + + userID := uint64(789) + user := &contracts.UserDTO{ + ID: userID, + Username: "testuser", + Email: "test@example.com", + } + + // 1. Get from empty cache -> miss + _, err := auth.GetCachedUser(ctx, userID) + if err == nil { + t.Fatal("expected cache miss for un-cached user") + } + + // 2. Set to cache + auth.SetCachedUser(ctx, userID, user) + + // 3. Get from cache -> hit + cached, err := auth.GetCachedUser(ctx, userID) + if err != nil { + t.Fatalf("GetCachedUser() failed: %v", err) + } + if cached.ID != user.ID || cached.Username != user.Username { + t.Fatalf("expected cached user %+v, got %+v", user, cached) + } + + // 4. Invalidate cache + auth.InvalidateCachedUser(ctx, userID) + + // 5. Get from cache -> miss + _, err = auth.GetCachedUser(ctx, userID) + if err == nil { + t.Fatal("expected cache miss after invalidation") + } +} diff --git a/backend/plugins/domain/auth/config.go b/backend/plugins/domain/auth/config.go new file mode 100644 index 00000000..c01b416e --- /dev/null +++ b/backend/plugins/domain/auth/config.go @@ -0,0 +1,14 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package auth + +// SessionConfig defines the session configuration declared by the auth plugin. +type SessionConfig struct { + SessionCookieName string `config:"session_cookie_name" env:"APP_SESSION_COOKIE_NAME" default:"wavelet_session"` + SessionSecret string `config:"session_secret" env:"APP_SESSION_SECRET" secret:"true"` + SessionDomain string `config:"session_domain" env:"APP_SESSION_DOMAIN"` + SessionAge int `config:"session_age" env:"APP_SESSION_AGE" default:"86400"` + SessionHTTPOnly bool `config:"session_http_only" env:"APP_SESSION_HTTP_ONLY" default:"true"` + SessionSecure bool `config:"session_secure" env:"APP_SESSION_SECURE"` +} diff --git a/backend/plugins/domain/auth/constants.go b/backend/plugins/domain/auth/constants.go new file mode 100644 index 00000000..f49dd81f --- /dev/null +++ b/backend/plugins/domain/auth/constants.go @@ -0,0 +1,39 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "time" +) + +// Session and Context Keys +const ( + UserNameKey = "username" + UserIDKey = "user_id" + UserObjKey = "user_obj" + TokenAuthKey = "token_auth" // 标记当前请求是否通过 Access Token 鉴权 + TokenAdminKey = "token_admin" // Access Token 本身是否具有管理员权限 + SessionTokenKey = "oauth_session_token" //nolint:gosec // false positive: this is a session key, not hardcoded credentials + PasswordHashKey = "password_hash" + SystemUsername = "system" +) + +// OAuth State Cache Keys and Expirations +const ( + OAuthStateCacheKeyFormat = "oauth:state:%s" + OAuthStateCacheKeyExpiration = 10 * time.Minute + oauthStateLimitKeyFormat = "oauth:state:limit:%s" + oauthStateLimitMax = 10 +) + +// OAuth Purpose Constants +const ( + OAuthPurposeLogin = "login" + OAuthPurposeBind = "bind" +) + +// Auth Source Types +const ( + AuthSourceTypeOIDC = "oidc" +) diff --git a/backend/plugins/domain/auth/errs.go b/backend/plugins/domain/auth/errs.go new file mode 100644 index 00000000..3d63894e --- /dev/null +++ b/backend/plugins/domain/auth/errs.go @@ -0,0 +1,54 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package auth + +// OAuth and Auth error messages +const ( + errInvalidState = "非法登录请求" + errIDTokenVerifyFailed = "ID Token 验证失败" //nolint:gosec // false positive: this is an error message, not hardcoded credentials + errIDTokenVerifyFailedFormat = "%s: %w" + errNonceMismatch = "nonce 不匹配,可能存在重放攻击" + errNoActiveAuthSource = "未配置可用认证源" + errServerAddressMissing = "服务器地址 (server_address) 未配置或配置为空,请在后台系统设置中配置后再试" + errAuthSourceRequired = "认证源不能为空" + errDiscoveryURLRequired = "OIDC 认证源必须配置 Discovery URL" + errUsernameGenerateFailed = "无法生成可用用户名" + errUsernameFromSourceFailed = "无法从认证源获取用户名" + errAuthSourceDisabled = "认证源未启用" + errInvalidExternalAccountBindingID = "绑定记录 ID 无效" + ErrTokenAuthNotAllowed = "该端点不允许使用访问令牌进行身份验证" //nolint:gosec // false positive: this is an error message, not hardcoded credentials + errOAuthStateRateLimited = "请求授权过于频繁,请稍后重试" + errAuthSourceNameRequired = "认证源名称不能为空" + errAuthSourceNameInvalid = "认证源名称格式不正确" + errAuthSourceTypeUnsupported = "不支持的认证源类型" + errAuthSourceDiscoveryURLRequired = "Discovery URL 不能为空" + //nolint:gosec // error message, not hardcoded credentials + errAuthSourceClientCredentialsRequired = "启用认证源时必须配置 Client ID 和 Client Secret" + errAuthSourceIDRequired = "认证源 ID 不能为空" + errUserIDRequired = "用户 ID 不能为空" + errExternalAccountBindingIncomplete = "外部帐号绑定信息不完整" + errExternalAccountAlreadyBoundToAnother = "该外部帐号已被其他用户绑定" + errExternalAccountBindingIDRequired = "外部帐号绑定记录 ID 不能为空" + errAdminRequired = "无权访问" + //nolint:gosec // error message, not hardcoded credentials + errTokenAdminRequired = "令牌无管理员权限" + errBannedAccount = "账号已被封禁" + errUnAuthorized = "未登录" +) + +// Service 层与鉴权中间件内部错误文案(保持与重构前逐字一致) +const ( + errUserNotInContext = "auth: user not found in context" + errEmptyToken = "auth: empty token" //nolint:gosec // false positive: this is an error message, not hardcoded credentials + errSystemUserTokenNotAllowed = "auth: system user token not allowed" //nolint:gosec // false positive: this is an error message, not hardcoded credentials + errUnauthorizedInternal = "unauthorized" + errSystemUserLoginNotAllowed = "system user is not allowed to login" +) + +// OAuth 回调会话校验错误文案(保持与重构前逐字一致) +const ( + errInvalidSessionContext = "invalid session context" + errSessionMismatchForOAuth = "session mismatch for oauth state" + errUserContextMismatch = "user context mismatch for oauth binding" +) diff --git a/backend/plugins/domain/auth/handlers.go b/backend/plugins/domain/auth/handlers.go new file mode 100644 index 00000000..c7a8f2a4 --- /dev/null +++ b/backend/plugins/domain/auth/handlers.go @@ -0,0 +1,500 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "Wavelet/core/contracts" + "Wavelet/pkg/ginutil" + "Wavelet/pkg/idgen" + "Wavelet/pkg/logger" + "Wavelet/pkg/response" + "context" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "gorm.io/gorm" +) + +// GetLoginSources 获取可用登录源列表 +func GetLoginSources(c *gin.Context) { + c.JSON(http.StatusOK, response.OK(activeLoginSources(c.Request.Context()))) +} + +// GetLoginURL 获取登录授权地址 +func GetLoginURL(c *gin.Context) { + ctx := c.Request.Context() + if !isOIDCLoginEnabled(ctx) { + response.AbortBadRequest(c, errAuthSourceDisabled) + return + } + + source, err := resolveAuthSource(ctx, c.Query("source")) + if err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + + if !source.IsActive { + response.AbortBadRequest(c, errAuthSourceDisabled) + return + } + + session := sessions.Default(c) + token, isNew := ensureSessionToken(session) + if isNew { + if err := session.Save(); err != nil { + response.AbortInternal(c, err.Error()) + return + } + } + + userID := GetUserIDFromSession(session) + sessionHash := hashSessionToken(token) + if err := reserveOAuthStateSlot(ctx, sessionHash); err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + + state := uuid.NewString() + payloadValue, err := encodeOAuthStatePayload(oauthStatePayload{ + SourceName: source.Name, + Purpose: OAuthPurposeLogin, + UserID: userID, + SessionHash: sessionHash, + }) + if err != nil { + response.AbortInternal(c, err.Error()) + return + } + stateKey := fmt.Sprintf(OAuthStateCacheKeyFormat, state) + if cache := getCache(ctx); cache != nil { + if err := cache.Set(ctx, stateKey, payloadValue, OAuthStateCacheKeyExpiration); err != nil { + response.AbortInternal(c, err.Error()) + return + } + } + + authorizeURL, err := buildAuthorizeURL(c.Request.Context(), source, state) + if err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + c.JSON(http.StatusOK, response.OK(OAuthAuthorizeResponse{AuthorizeURL: authorizeURL})) +} + +func buildAuthorizeURL(ctx context.Context, source *AuthSource, state string) (string, error) { + redirectURL, err := getFrontendLoginRedirectURL(ctx) + if err != nil { + return "", err + } + authConfig, verifier, err := buildOAuthConfig(ctx, source, redirectURL) + if err != nil { + return "", err + } + if verifier != nil { + return authConfig.AuthCodeURL(state, oidc.Nonce(state)), nil + } + return authConfig.AuthCodeURL(state), nil +} + +func reserveOAuthStateSlot(ctx context.Context, sessionHash string) error { + if sessionHash == "" { + return nil + } + cache := getCache(ctx) + if cache == nil { + return nil + } + key := fmt.Sprintf(oauthStateLimitKeyFormat, sessionHash) + var count int + _ = cache.Get(ctx, key, &count) + count++ + _ = cache.Set(ctx, key, count, OAuthStateCacheKeyExpiration) + if count > oauthStateLimitMax { + return errors.New(errOAuthStateRateLimited) + } + return nil +} + +// Authorize 发起指定认证源授权 +func Authorize(c *gin.Context) { + ctx := c.Request.Context() + if !isOIDCLoginEnabled(ctx) { + response.AbortBadRequest(c, errAuthSourceDisabled) + return + } + + source, err := resolveAuthSource(ctx, c.Param("source")) + if err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + + if !source.IsActive { + response.AbortBadRequest(c, errAuthSourceDisabled) + return + } + purpose := strings.ToLower(strings.TrimSpace(c.Query("purpose"))) + if purpose != OAuthPurposeBind { + purpose = OAuthPurposeLogin + } + + session := sessions.Default(c) + userID := GetUserIDFromSession(session) + if purpose == OAuthPurposeBind && userID == 0 { + response.AbortUnauthorized(c, errUnAuthorized) + return + } + + token, isNew := ensureSessionToken(session) + if isNew { + if err := session.Save(); err != nil { + response.AbortInternal(c, err.Error()) + return + } + } + + sessionHash := hashSessionToken(token) + if err := reserveOAuthStateSlot(ctx, sessionHash); err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + + state := uuid.NewString() + payloadValue, err := encodeOAuthStatePayload(oauthStatePayload{ + SourceName: source.Name, + Purpose: purpose, + UserID: userID, + SessionHash: sessionHash, + }) + if err != nil { + response.AbortInternal(c, err.Error()) + return + } + stateKey := fmt.Sprintf(OAuthStateCacheKeyFormat, state) + if cache := getCache(ctx); cache != nil { + if err := cache.Set(ctx, stateKey, payloadValue, OAuthStateCacheKeyExpiration); err != nil { + response.AbortInternal(c, err.Error()) + return + } + } + + authorizeURL, err := buildAuthorizeURL(c.Request.Context(), source, state) + if err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + c.JSON(http.StatusOK, response.OK(OAuthAuthorizeResponse{AuthorizeURL: authorizeURL})) +} + +// Callback OAuth 回调处理 +func Callback(c *gin.Context) { + var req CallbackRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + + ctx := c.Request.Context() + stateKey := fmt.Sprintf(OAuthStateCacheKeyFormat, req.State) + var payloadRaw string + cache := getCache(ctx) + if cache == nil { + response.AbortBadRequest(c, errInvalidState) + return + } + if err := cache.Get(ctx, stateKey, &payloadRaw); err != nil { + response.AbortBadRequest(c, errInvalidState) + return + } + _ = cache.Delete(ctx, stateKey) + + payload, err := decodeOAuthStatePayload(payloadRaw) + if err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + + session := sessions.Default(c) + currentUserID := GetUserIDFromSession(session) + + if payload.Purpose == OAuthPurposeBind && currentUserID == 0 { + response.AbortUnauthorized(c, errUnAuthorized) + return + } + + token, ok := session.Get(SessionTokenKey).(string) + if !ok || token == "" { + response.AbortBadRequest(c, errInvalidSessionContext) + return + } + + if hashSessionToken(token) != payload.SessionHash { + response.AbortBadRequest(c, errSessionMismatchForOAuth) + return + } + + if payload.Purpose == OAuthPurposeBind && currentUserID != payload.UserID { + response.AbortBadRequest(c, errUserContextMismatch) + return + } + + if !isOIDCLoginEnabled(ctx) { + response.AbortBadRequest(c, errAuthSourceDisabled) + return + } + + source, err := resolveAuthSource(ctx, payload.SourceName) + if err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + + if !source.IsActive { + response.AbortBadRequest(c, errAuthSourceDisabled) + return + } + + redirectURL, err := getFrontendLoginRedirectURL(ctx) + if err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + + userInfo, err := buildOAuthUserInfo(ctx, source, req.Code, req.State, redirectURL) + if err != nil { + response.AbortInternal(c, err.Error()) + return + } + if err := normalizeOAuthUserInfo(userInfo); err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + if userInfo.Sub == "" { + userInfo.Sub = userInfo.Username + } + + if payload.Purpose == OAuthPurposeBind { + handleCallbackBind(ctx, c, source, userInfo) + return + } + + handleCallbackLogin(ctx, c, source, userInfo) +} + +func handleCallbackBind(ctx context.Context, c *gin.Context, source *AuthSource, userInfo *contracts.OAuthUserInfoDTO) { + userID := GetUserIDFromContext(c) + if userID == 0 { + response.AbortUnauthorized(c, errUnAuthorized) + return + } + user, err := GetUserByID(ctx, userID) + if err != nil { + response.AbortInternal(c, err.Error()) + return + } + if err := BindExternalAccount(ctx, &ExternalAccount{ + AuthSourceID: source.ID, + UserID: user.ID, + ExternalID: userInfo.Sub, + ExternalUsername: userInfo.Username, + Email: userInfo.Email, + }); err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + user.LastLoginAt = time.Now() + _ = TouchUserLastLogin(ctx, user.ID, user.LastLoginAt) + c.JSON(http.StatusOK, response.OK(buildCallbackResult(user, "bound"))) +} + +func handleCallbackLogin(ctx context.Context, c *gin.Context, source *AuthSource, userInfo *contracts.OAuthUserInfoDTO) { + var user *contracts.UserDTO + + account, err := FindExternalAccount(ctx, source.ID, userInfo.Sub) + switch { + case err == nil: + loaded, loadErr := GetUserByID(ctx, account.UserID) + if loadErr != nil { + response.AbortInternal(c, loadErr.Error()) + return + } + user = loaded + case errors.Is(err, gorm.ErrRecordNotFound): + newUser, ok := handleCallbackRegister(ctx, c, source, userInfo) + if !ok { + return + } + user = &newUser + default: + response.AbortInternal(c, err.Error()) + return + } + + user.LastLoginAt = time.Now() + _ = TouchUserLastLogin(ctx, user.ID, user.LastLoginAt) + if err := SetLoginSession(ctx, c, user); err != nil { + response.AbortInternal(c, err.Error()) + return + } + + SetCachedUser(ctx, user.ID, user) + + c.JSON(http.StatusOK, response.OK(buildCallbackResult(user, "logged_in"))) +} + +func uniqueUsername(ctx context.Context, base string) (string, error) { + base = strings.TrimSpace(base) + if base == "" { + base = "user" + } + + existingUsernames, err := ListSimilarUsernames(ctx, base) + if err != nil { + return "", err + } + + exists := make(map[string]bool, len(existingUsernames)) + for _, u := range existingUsernames { + exists[strings.ToLower(u)] = true + } + + if !exists[strings.ToLower(base)] { + return base, nil + } + + for i := 1; i <= 1000; i++ { + candidate := fmt.Sprintf("%s-%d", base, i) + if !exists[strings.ToLower(candidate)] { + return candidate, nil + } + } + + return "", errors.New(errUsernameGenerateFailed) +} + +func handleCallbackRegister(ctx context.Context, c *gin.Context, source *AuthSource, userInfo *contracts.OAuthUserInfoDTO) (contracts.UserDTO, bool) { + registrationEnabled := true + val, cfgErr := GetSystemConfigValue(ctx, "registration_enabled") + if cfgErr == nil && val != "" { + if b, err := strconv.ParseBool(val); err == nil { + registrationEnabled = b + } + } + + if !registrationEnabled { + c.JSON(http.StatusOK, response.OK(buildCallbackResult(nil, "need_bind"))) + return contracts.UserDTO{}, false + } + + username, uniqueErr := uniqueUsername(ctx, userInfo.Username) + if uniqueErr != nil { + response.AbortInternal(c, uniqueErr.Error()) + return contracts.UserDTO{}, false + } + userInfo.Username = username + + now := time.Now() + user := contracts.UserDTO{ + ID: idgen.NextUint64ID(), + Username: userInfo.Username, + Nickname: userInfo.Name, + Email: userInfo.Email, + AvatarURL: userInfo.AvatarURL, + IsActive: userInfo.Active, + LastLoginAt: now, + CreatedAt: now, + UpdatedAt: now, + } + + if err := InsertUser(ctx, &user); err != nil { + response.AbortInternal(c, err.Error()) + return contracts.UserDTO{}, false + } + + if err := BindExternalAccount(ctx, &ExternalAccount{ + AuthSourceID: source.ID, + UserID: user.ID, + ExternalID: userInfo.Sub, + ExternalUsername: userInfo.Username, + Email: userInfo.Email, + }); err != nil { + response.AbortBadRequest(c, err.Error()) + return contracts.UserDTO{}, false + } + logger.InfoF(ctx, "[LoginAudit] successful OAuth registration via source: %s, external ID: %s, user: %s, ID: %d, IP: %s", source.Name, userInfo.Sub, user.Username, user.ID, c.ClientIP()) + + return user, true +} + +// UserInfo 获取当前登录用户信息 +func UserInfo(c *gin.Context) { + user, _ := ginutil.GetFromContext[*contracts.UserDTO](c, contracts.AuthUserObjKey) + session := sessions.Default(c) + needChange := session.Get("need_change_password") == true || (user != nil && user.NeedChangePassword) + + c.JSON( + http.StatusOK, + response.OK(BuildBasicUserInfo(user, needChange)), + ) +} + +// Logout 退出登录 +func Logout(c *gin.Context) { + session := sessions.Default(c) + userID := session.Get(UserIDKey) + username := session.Get(UserNameKey) + if userID != nil { + logger.InfoF(c.Request.Context(), "[LoginAudit] user logged out: %v, ID: %v, IP: %s", username, userID, c.ClientIP()) + if id := ParseUserID(userID); id > 0 { + InvalidateCachedUser(c.Request.Context(), id) + } + } + session.Options(GetSessionOptions(-1)) + session.Clear() + if err := session.Save(); err != nil { + response.AbortInternal(c, err.Error()) + return + } + c.JSON(http.StatusOK, response.OKNil()) +} + +// ListExternalAccounts 获取当前用户的外部帐号绑定列表 +func ListExternalAccounts(c *gin.Context) { + userID := GetUserIDFromContext(c) + accounts, err := ListExternalAccountsByUserID(c.Request.Context(), userID) + if err != nil { + response.AbortInternal(c, err.Error()) + return + } + c.JSON(http.StatusOK, response.OK(accounts)) +} + +// DeleteExternalAccount 解除外部帐号绑定 +func DeleteExternalAccount(c *gin.Context) { + userID := GetUserIDFromContext(c) + if userID == 0 { + response.AbortUnauthorized(c, errUnAuthorized) + return + } + rawID := strings.TrimSpace(c.Param("id")) + id, err := strconv.ParseUint(rawID, 10, 64) + if err != nil || id == 0 { + response.AbortBadRequest(c, errInvalidExternalAccountBindingID) + return + } + if err := UnbindExternalAccount(c.Request.Context(), id, userID); err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + c.JSON(http.StatusOK, response.OKNil()) +} diff --git a/backend/plugins/domain/auth/middleware.go b/backend/plugins/domain/auth/middleware.go new file mode 100644 index 00000000..ea93a667 --- /dev/null +++ b/backend/plugins/domain/auth/middleware.go @@ -0,0 +1,197 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "Wavelet/core/contracts" + "Wavelet/core/extpoints" + "Wavelet/pkg/ginutil" + "Wavelet/pkg/response" + "Wavelet/pkg/trace" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + + "github.com/gin-gonic/gin" +) + +// whitelist holds the no-auth route patterns. They are registered during Apply and +// matched on every request, so PathWhitelist parses them once up front. +var whitelist = extpoints.NewPathWhitelist() + +// RegisterWhitelist registers route patterns that bypass mandatory authentication. +func RegisterWhitelist(patterns ...string) { + whitelist.Add(patterns...) +} + +// IsWhitelisted checks if the specified path matches the auth whitelist. +func IsWhitelisted(path string) bool { + return whitelist.Match(path) +} + +func hashToken(token string) string { + h := sha256.New() + h.Write([]byte(token)) + return hex.EncodeToString(h.Sum(nil)) +} + +// currentUserIDFromRequestContext 是接入层向 Service 层暴露的登录态桥接。 +// +// Session 读取必须依赖 *gin.Context,而 Service 层禁止 import gin, +// 因此该类型断言收敛在本(接入层)文件中。ok 为 false 表示 ctx 不是 *gin.Context。 +func currentUserIDFromRequestContext(ctx context.Context) (uint64, bool) { + ginCtx, ok := ctx.(*gin.Context) + if !ok { + return 0, false + } + return GetUserIDFromContext(ginCtx), true +} + +func getUserByToken(ctx context.Context, tokenStr string) (*contracts.UserDTO, *CachedToken, error) { + tokenHash := hashToken(tokenStr) + tokenRecord, err := GetCachedToken(ctx, tokenHash) + if err != nil || tokenRecord == nil { + tokenRecord, err = GetAccessTokenByHash(ctx, tokenHash) + if err != nil { + return nil, nil, err + } + SetCachedToken(ctx, tokenHash, tokenRecord) + } + + user, err := GetCachedUser(ctx, tokenRecord.UserID) + if err != nil || user == nil || !user.IsActive { + user, err = GetActiveUserByID(ctx, tokenRecord.UserID) + if err != nil { + return nil, nil, err + } + SetCachedUser(ctx, tokenRecord.UserID, user) + } + + return user, tokenRecord, nil +} + +// GetUserFromRequest 从请求中获取当前用户(优先 Access Token,其次 Session) +func GetUserFromRequest(c *gin.Context) (*contracts.UserDTO, error) { + ctx := c.Request.Context() + var tokenStr string + + tokenFromQuery := c.Query("token") + if tokenFromQuery != "" { + tokenStr = tokenFromQuery + } else { + authHeader := c.GetHeader("Authorization") + if len(authHeader) > 7 && authHeader[:7] == "Bearer " { + tokenStr = authHeader[7:] + } + } + + // 优先使用 Access Token 鉴权 + if tokenStr != "" { + if user, tokenRecord, err := getUserByToken(ctx, tokenStr); err == nil { + if user.Username == SystemUsername { + return nil, errors.New(errSystemUserLoginNotAllowed) + } + ginutil.SetToContext(c, contracts.AuthTokenAuthKey, true) + ginutil.SetToContext(c, contracts.AuthTokenAdminKey, tokenRecord.IsAdmin) + return user, nil + } + } + + // 降级使用 Session 鉴权 + userID := GetUserIDFromContext(c) + if userID <= 0 { + return nil, errors.New(errUnauthorizedInternal) + } + + user, err := GetCachedUser(ctx, userID) + if err != nil || user == nil || !user.IsActive { + user, err = GetActiveUserByID(ctx, userID) + if err != nil { + return nil, err + } + SetCachedUser(ctx, userID, user) + } + + ginutil.SetToContext(c, contracts.AuthTokenAuthKey, false) + ginutil.SetToContext(c, contracts.AuthTokenAdminKey, false) + + if user.Username == "system" { + return nil, errors.New(errSystemUserLoginNotAllowed) + } + + return user, nil +} + +// LoginRequired 返回登录鉴权中间件,校验 Access Token 或 Session +func LoginRequired() gin.HandlerFunc { + return func(c *gin.Context) { + if IsWhitelisted(c.Request.URL.Path) { + c.Next() + return + } + + _, span := trace.Start(c.Request.Context(), "LoginRequired") + defer span.End() + + user, err := GetUserFromRequest(c) + if err != nil { + response.AbortUnauthorized(c, errUnAuthorized) + return + } + + LogForAudit(c.Request.Context(), user, c) + ginutil.SetToContext(c, contracts.AuthUserObjKey, user) + c.Next() + } +} + +// AdminRequired 校验管理员权限(支持 Session 和 Token 鉴权) +func AdminRequired() gin.HandlerFunc { + return func(c *gin.Context) { + _, span := trace.Start(c.Request.Context(), "AdminRequired") + defer span.End() + + user, err := GetUserFromRequest(c) + if err != nil { + response.AbortUnauthorized(c, errUnAuthorized) + return + } + + isTokenAuth, _ := ginutil.GetFromContext[bool](c, contracts.AuthTokenAuthKey) + isTokenAdmin, _ := ginutil.GetFromContext[bool](c, contracts.AuthTokenAdminKey) + + // 如果是通过 Token 鉴权,要求该 Token 具备管理员权限或者用户本身为管理员 + if isTokenAuth && !isTokenAdmin && !user.IsAdmin { + response.AbortNotFound(c, errTokenAdminRequired) + return + } + + // 如果是通过 Session 鉴权,直接检查用户的 is_admin 属性 + if !isTokenAuth && !user.IsAdmin { + response.AbortNotFound(c, errAdminRequired) + return + } + + LogForAudit(c.Request.Context(), user, c) + ginutil.SetToContext(c, contracts.AuthUserObjKey, user) + c.Next() + } +} + +// LoginAdminRequired is an alias for AdminRequired. +func LoginAdminRequired() gin.HandlerFunc { + return AdminRequired() +} + +// DisallowTokenAuth 拒绝使用 Access Token 进行身份验证的请求访问该端点 +func DisallowTokenAuth() gin.HandlerFunc { + return func(c *gin.Context) { + if tokenAuth, _ := ginutil.GetFromContext[bool](c, contracts.AuthTokenAuthKey); tokenAuth { + response.AbortForbidden(c, ErrTokenAuthNotAllowed) + return + } + c.Next() + } +} diff --git a/backend/plugins/domain/auth/migrations/postgres/00001_initial.sql b/backend/plugins/domain/auth/migrations/postgres/00001_initial.sql new file mode 100644 index 00000000..3992a274 --- /dev/null +++ b/backend/plugins/domain/auth/migrations/postgres/00001_initial.sql @@ -0,0 +1,53 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS w_auth_sources ( + id BIGINT PRIMARY KEY, + name VARCHAR(80) NOT NULL UNIQUE, + type VARCHAR(20) NOT NULL, + display_name VARCHAR(100), + is_active BOOLEAN NOT NULL DEFAULT FALSE, + client_id VARCHAR(255), + client_secret VARCHAR(1024), + openid_discovery_url VARCHAR(1024), + scopes VARCHAR(255), + icon_url VARCHAR(1024), + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_w_auth_sources_is_active ON w_auth_sources (is_active); + +CREATE TABLE IF NOT EXISTS w_external_accounts ( + id BIGINT PRIMARY KEY, + auth_source_id BIGINT, + user_id BIGINT NOT NULL, + external_id VARCHAR(255) NOT NULL, + external_username VARCHAR(255), + email VARCHAR(255), + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_w_external_accounts_auth_source_id ON w_external_accounts (auth_source_id); +CREATE INDEX IF NOT EXISTS idx_w_external_accounts_user_id ON w_external_accounts (user_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_w_external_accounts_source_external ON w_external_accounts (auth_source_id, external_id); + +CREATE TABLE IF NOT EXISTS w_access_tokens ( + id BIGINT PRIMARY KEY, + user_id BIGINT NOT NULL, + token_hash VARCHAR(64) NOT NULL UNIQUE, + name VARCHAR(128) NOT NULL, + masked_token VARCHAR(64) NOT NULL DEFAULT '', + description VARCHAR(255), + is_admin BOOLEAN DEFAULT FALSE, + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_w_access_tokens_user_id ON w_access_tokens (user_id); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS w_access_tokens; +DROP TABLE IF EXISTS w_external_accounts; +DROP TABLE IF EXISTS w_auth_sources; +-- +goose StatementEnd diff --git a/backend/plugins/domain/auth/migrations/sqlite/00001_initial.sql b/backend/plugins/domain/auth/migrations/sqlite/00001_initial.sql new file mode 100644 index 00000000..a353bc96 --- /dev/null +++ b/backend/plugins/domain/auth/migrations/sqlite/00001_initial.sql @@ -0,0 +1,53 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS w_auth_sources ( + id BIGINT PRIMARY KEY, + name VARCHAR(80) NOT NULL UNIQUE, + type VARCHAR(20) NOT NULL, + display_name VARCHAR(100), + is_active BOOLEAN NOT NULL DEFAULT 0, + client_id VARCHAR(255), + client_secret VARCHAR(1024), + openid_discovery_url VARCHAR(1024), + scopes VARCHAR(255), + icon_url VARCHAR(1024), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_w_auth_sources_is_active ON w_auth_sources (is_active); + +CREATE TABLE IF NOT EXISTS w_external_accounts ( + id BIGINT PRIMARY KEY, + auth_source_id BIGINT, + user_id BIGINT NOT NULL, + external_id VARCHAR(255) NOT NULL, + external_username VARCHAR(255), + email VARCHAR(255), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_w_external_accounts_auth_source_id ON w_external_accounts (auth_source_id); +CREATE INDEX IF NOT EXISTS idx_w_external_accounts_user_id ON w_external_accounts (user_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_w_external_accounts_source_external ON w_external_accounts (auth_source_id, external_id); + +CREATE TABLE IF NOT EXISTS w_access_tokens ( + id BIGINT PRIMARY KEY, + user_id BIGINT NOT NULL, + token_hash VARCHAR(64) NOT NULL UNIQUE, + name VARCHAR(128) NOT NULL, + masked_token VARCHAR(64) NOT NULL DEFAULT '', + description VARCHAR(255), + is_admin BOOLEAN DEFAULT 0, + expires_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_w_access_tokens_user_id ON w_access_tokens (user_id); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS w_access_tokens; +DROP TABLE IF EXISTS w_external_accounts; +DROP TABLE IF EXISTS w_auth_sources; +-- +goose StatementEnd diff --git a/backend/plugins/domain/auth/models.go b/backend/plugins/domain/auth/models.go new file mode 100644 index 00000000..74289f2f --- /dev/null +++ b/backend/plugins/domain/auth/models.go @@ -0,0 +1,241 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "Wavelet/core/contracts" + "encoding/json" + "errors" + "regexp" + "strconv" + "strings" + "time" +) + +var authSourceNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,79}$`) + +// AuthSource 认证源实体 +// +//nolint:revive // auth.AuthSource is standard domain entity name +type AuthSource struct { + ID uint64 `json:"id" gorm:"primaryKey"` + Name string `json:"name" gorm:"uniqueIndex;size:80;not null"` + Type string `json:"type" gorm:"size:20;not null"` + DisplayName string `json:"display_name" gorm:"size:100"` + IsActive bool `json:"is_active" gorm:"index;not null;default:false"` + ClientID string `json:"client_id" gorm:"size:255"` + ClientSecret string `json:"-" gorm:"size:1024"` + OpenIDDiscoveryURL string `json:"openid_discovery_url" gorm:"column:openid_discovery_url;size:1024"` + Scopes string `json:"scopes" gorm:"size:255"` + IconURL string `json:"icon_url" gorm:"size:1024"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ClientSecretConfigured bool `json:"client_secret_configured" gorm:"-"` +} + +// TableName 表名 +func (AuthSource) TableName() string { + return "w_auth_sources" +} + +// Normalize 对认证源字段进行标准化处理 +func (source *AuthSource) Normalize() { + source.Type = strings.ToLower(strings.TrimSpace(source.Type)) + source.Name = strings.TrimSpace(source.Name) + source.DisplayName = strings.TrimSpace(source.DisplayName) + source.ClientID = strings.TrimSpace(source.ClientID) + source.ClientSecret = strings.TrimSpace(source.ClientSecret) + source.OpenIDDiscoveryURL = strings.TrimSpace(source.OpenIDDiscoveryURL) + source.Scopes = strings.TrimSpace(source.Scopes) + source.IconURL = strings.TrimSpace(source.IconURL) + if source.DisplayName == "" { + source.DisplayName = source.Name + } + if source.Type == AuthSourceTypeOIDC && source.Scopes == "" { + source.Scopes = "openid profile email" + } +} + +// Validate 校验认证源字段合法性 +func (source *AuthSource) Validate() error { + source.Normalize() + if source.Name == "" { + return errors.New(errAuthSourceNameRequired) + } + if !authSourceNamePattern.MatchString(source.Name) { + return errors.New(errAuthSourceNameInvalid) + } + if source.Type != AuthSourceTypeOIDC { + return errors.New(errAuthSourceTypeUnsupported) + } + if source.OpenIDDiscoveryURL == "" { + //nolint:staticcheck // descriptive error constant + return errors.New(errAuthSourceDiscoveryURLRequired) + } + if source.IsActive && (source.ClientID == "" || source.ClientSecret == "") { + return errors.New(errAuthSourceClientCredentialsRequired) + } + return nil +} + +// Sanitize 脱敏处理,将 ClientSecret 清空并设置 ClientSecretConfigured 标志 +func (source *AuthSource) Sanitize() { + source.ClientSecretConfigured = source.ClientSecret != "" + source.ClientSecret = "" +} + +// ExternalAccount 外部账号绑定实体 +type ExternalAccount struct { + ID uint64 `json:"id" gorm:"primaryKey"` + AuthSourceID uint64 `json:"auth_source_id" gorm:"uniqueIndex:idx_external_accounts_source_external,priority:1;index"` + UserID uint64 `json:"user_id" gorm:"index;not null"` + ExternalID string `json:"external_id" gorm:"uniqueIndex:idx_external_accounts_source_external,priority:2;size:255;not null"` + ExternalUsername string `json:"external_username" gorm:"size:255"` + Email string `json:"email" gorm:"size:255"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TableName 表名 +func (ExternalAccount) TableName() string { + return "w_external_accounts" +} + +// ExternalAccountView 外部帐号绑定视图(脱敏展示用) +type ExternalAccountView struct { + ID uint64 `json:"id"` + AuthSourceID uint64 `json:"auth_source_id"` + AuthSourceName string `json:"auth_source_name"` + AuthSourceType string `json:"auth_source_type"` + AuthSourceLabel string `json:"auth_source_label"` + ExternalUsername string `json:"external_username"` + Email string `json:"email"` + CreatedAt time.Time `json:"created_at"` +} + +// AuthSourceView 登录源展示信息 +// +//nolint:revive // auth.AuthSourceView is standard domain presentation struct +type AuthSourceView struct { + ID uint64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + DisplayName string `json:"display_name"` + IsActive bool `json:"is_active"` + IconURL string `json:"icon_url"` + ClientSecretConfigured bool `json:"client_secret_configured"` +} + +// OAuthAuthorizeResponse 授权 URL 响应 +type OAuthAuthorizeResponse struct { + AuthorizeURL string `json:"authorize_url"` +} + +// OAuthCallbackResult 回调处理结果 +type OAuthCallbackResult struct { + Status string `json:"status"` + User *BasicUserInfo `json:"user,omitempty"` +} + +// CallbackRequest OAuth 回调请求参数 +type CallbackRequest struct { + State string `json:"state" binding:"required"` + Code string `json:"code" binding:"required"` +} + +// BasicUserInfo 用户基本信息结构体 +type BasicUserInfo struct { + ID uint64 `json:"id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Email string `json:"email"` + AvatarURL string `json:"avatar_url"` + IsAdmin bool `json:"is_admin"` + NeedChangePassword bool `json:"need_change_password"` + Bio string `json:"bio"` + Phone string `json:"phone"` + Gender string `json:"gender"` + Website string `json:"website"` + Location string `json:"location"` +} + +// BuildBasicUserInfo 将 UserDTO 转换为 BasicUserInfo +func BuildBasicUserInfo(user *contracts.UserDTO, needChange bool) BasicUserInfo { + if user == nil { + return BasicUserInfo{} + } + return BasicUserInfo{ + ID: user.ID, + Username: user.Username, + Nickname: user.Nickname, + Email: user.Email, + AvatarURL: user.AvatarURL, + IsAdmin: user.IsAdmin, + NeedChangePassword: needChange || user.NeedChangePassword, + Bio: user.Bio, + Phone: user.Phone, + Gender: user.Gender, + Website: user.Website, + Location: user.Location, + } +} + +type oauthStatePayload struct { + SourceName string `json:"source_name"` + Purpose string `json:"purpose"` + UserID uint64 `json:"user_id,omitempty"` + SessionHash string `json:"session_hash"` +} + +func encodeOAuthStatePayload(payload oauthStatePayload) (string, error) { + data, err := json.Marshal(payload) + if err != nil { + return "", err + } + return string(data), nil +} + +func decodeOAuthStatePayload(value string) (oauthStatePayload, error) { + var payload oauthStatePayload + if err := json.Unmarshal([]byte(value), &payload); err != nil { + return oauthStatePayload{}, err + } + return payload, nil +} + +type loginRequiredAuditLog struct { + UserID uint64 `json:"user_id"` + Username string `json:"username"` + ClientIP string `json:"client_ip"` + Method string `json:"method"` + Path string `json:"path"` + RequestURI string `json:"request_uri"` + UserAgent string `json:"user_agent"` + Referer string `json:"referer"` +} + +// ParseUserID parses a string or float64 user ID representation. +func ParseUserID(v any) uint64 { + switch val := v.(type) { + case uint64: + return val + case int64: + if val > 0 { + return uint64(val) + } + case int: + if val > 0 { + return uint64(val) + } + case float64: + if val > 0 { + return uint64(val) + } + case string: + if id, err := strconv.ParseUint(val, 10, 64); err == nil { + return id + } + } + return 0 +} diff --git a/backend/plugins/domain/auth/plugin.go b/backend/plugins/domain/auth/plugin.go new file mode 100644 index 00000000..2e98a15e --- /dev/null +++ b/backend/plugins/domain/auth/plugin.go @@ -0,0 +1,185 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package auth provides the authentication, OAuth, session management, and access token domain plugin for Cordis. +package auth + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "Wavelet/core/extpoints" + "context" + "embed" + "reflect" +) + +//go:embed migrations/*/*.sql +var authMigrations embed.FS + +// Option configures the auth plugin. +type Option func(*Plugin) + +// WithAuthService sets a custom AuthService implementation. +func WithAuthService(svc contracts.AuthService) Option { + return func(p *Plugin) { + p.authSvc = svc + } +} + +// WithAuthRegistry sets a custom AuthRegistry implementation. +func WithAuthRegistry(reg contracts.AuthRegistry) Option { + return func(p *Plugin) { + p.authRegistry = reg + } +} + +// Plugin implements core.Plugin to provide authentication and OAuth domain services. +type Plugin struct { + authSvc contracts.AuthService + authRegistry contracts.AuthRegistry +} + +// New creates a new auth domain plugin. +func New(opts ...Option) *Plugin { + p := &Plugin{} + for _, opt := range opts { + if opt != nil { + opt(p) + } + } + return p +} + +// Name returns the unique identifier for the auth domain plugin. +func (p *Plugin) Name() string { + return "auth" +} + +// Inject declares required dependencies for the auth domain plugin. +func (p *Plugin) Inject() []reflect.Type { + return []reflect.Type{ + reflect.TypeFor[contracts.DBService](), + reflect.TypeFor[contracts.CacheService](), + } +} + +// Manifest returns the plugin metadata. +func (p *Plugin) Manifest() core.Manifest { + return core.Manifest{ + Name: "auth", + Version: "1.0.0", + Description: "Authentication, OAuth, Session and Passkey domain plugin", + Author: "Wavelet Team", + } +} + +// DeclareConfig declares configuration bindings for the auth plugin. +func (p *Plugin) DeclareConfig() []core.ConfigBinding { + return []core.ConfigBinding{ + {Prefix: "app", Target: &SessionConfig{}}, + } +} + +// Apply registers the auth migrations, services, routes, and settings into the Context. +func (p *Plugin) Apply(ctx *core.Context) error { + var cfg SessionConfig + if err := ctx.Config().Bind("app", &cfg); err == nil { + SetSessionConfig(cfg) + } + + // 0. Bind DBService & CacheService from Context + if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil { + setDBService(db) + } else { + core.When[contracts.DBService](ctx, func(db contracts.DBService) { + setDBService(db) + }) + } + if cache, err := core.Inject[contracts.CacheService](ctx); err == nil && cache != nil { + setCacheService(cache) + } else { + core.When[contracts.CacheService](ctx, func(cache contracts.CacheService) { + setCacheService(cache) + }) + } + ctx.OnDispose(func() error { + setDBService(nil) + setCacheService(nil) + return nil + }) + + // 1. Register migrations + ctx.Migrations().Register("auth", authMigrations) + + // 2. Initialize and provide AuthService & AuthRegistry + if p.authSvc == nil { + p.authSvc = newAuthService() + } + if p.authRegistry == nil { + p.authRegistry = newAuthRegistry() + } + + core.Provide[contracts.AuthService](ctx, p.authSvc) + core.Provide[contracts.AuthRegistry](ctx, p.authRegistry) + + // 2.1 Register Public / Auth Whitelist Endpoints + publicEndpoints := []string{ + "/api/v1/oauth/sources", + "/api/v1/oauth/login", + "/api/v1/oauth/*/authorize", + "/api/v1/oauth/:source/authorize", + "/api/v1/oauth/callback", + "/api/v1/user/login", + "/api/v1/user/register", + "/api/v1/user/send-email-code", + "/api/v1/cap/challenge", + "/api/v1/cap/redeem", + "/healthz", + "/metrics", + } + RegisterWhitelist(publicEndpoints...) + ctx.Router().RegisterWhitelist(publicEndpoints...) + + // 3. Register HTTP Routes + oauthGroup := ctx.Router().Group("/api/v1/oauth") + { + oauthGroup.GET("/sources", GetLoginSources) + oauthGroup.GET("/login", GetLoginURL) + oauthGroup.GET("/:source/authorize", Authorize) + oauthGroup.GET("/logout", Logout) + oauthGroup.POST("/callback", Callback) + oauthGroup.GET("/user-info", LoginRequired(), UserInfo) + oauthGroup.GET("/external-accounts", LoginRequired(), ListExternalAccounts) + oauthGroup.POST("/external-accounts/:id/delete", LoginRequired(), DeleteExternalAccount) + } + ctx.Router().GET("/api/v1/user-info", LoginRequired(), UserInfo) + + // 4. Register Settings Schemas + ctx.Settings().Register(extpoints.SettingSchema{ + Key: "auth.session_age", + Default: 86400 * 7, + Description: "Default session lifetime in seconds", + Type: "integer", + Category: "security", + }) + ctx.Settings().Register(extpoints.SettingSchema{ + Key: "auth.login_rate_limit_max_attempts", + Default: 5, + Description: "Max login failure attempts before temporary IP lock", + Type: "integer", + Category: "security", + }) + + // 5. Register Event Listeners for domain events + ctx.Events().On(contracts.EventTopicUserStatusChanged, func(c context.Context, e contracts.UserStatusChangedEvent) error { + InvalidateCachedUser(c, e.UserID) + return nil + }) + + ctx.Events().On(contracts.EventTopicUserDeleted, func(c context.Context, e contracts.UserDeletedEvent) error { + InvalidateCachedUser(c, e.TargetUserID) + return nil + }) + + return nil +} diff --git a/backend/plugins/domain/auth/plugin_test.go b/backend/plugins/domain/auth/plugin_test.go new file mode 100644 index 00000000..2c5b52b8 --- /dev/null +++ b/backend/plugins/domain/auth/plugin_test.go @@ -0,0 +1,155 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package auth_test + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "Wavelet/plugins/domain/auth" + "context" + "crypto/sha256" + "encoding/hex" + "path/filepath" + "testing" + "time" + + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +type mockDBService struct { + db *gorm.DB +} + +func (m *mockDBService) GORM() *gorm.DB { + return m.db +} + +func (m *mockDBService) DB(ctx context.Context) *gorm.DB { + return m.db.WithContext(ctx) +} + +func (m *mockDBService) Named(_ string) *gorm.DB { + return m.db +} + +type testUser struct { + ID uint64 `gorm:"primaryKey"` + Username string + IsActive bool + LastLoginAt time.Time +} + +func (testUser) TableName() string { return "w_users" } + +type testAccessToken struct { + ID uint64 `gorm:"primaryKey"` + UserID uint64 + TokenHash string + Name string + IsAdmin bool +} + +func (testAccessToken) TableName() string { return "w_access_tokens" } + +func hashToken(token string) string { + h := sha256.New() + h.Write([]byte(token)) + return hex.EncodeToString(h.Sum(nil)) +} + +func setupTestDB(t *testing.T) *gorm.DB { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "auth_test.db") + testDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + require.NoError(t, err) + + require.NoError(t, testDB.AutoMigrate( + &testUser{}, + &testAccessToken{}, + &auth.AuthSource{}, + &auth.ExternalAccount{}, + )) + + return testDB +} + +type mockProvider struct{} + +func (m *mockProvider) Name() string { return "custom" } +func (m *mockProvider) GetAuthURL(state string) string { + return "https://custom.com/auth?state=" + state +} + +func (m *mockProvider) ExchangeCode(ctx context.Context, code string) (*contracts.OAuthUserInfoDTO, error) { + return &contracts.OAuthUserInfoDTO{ + ID: 555, + Username: "custom_user", + Email: "custom@example.com", + }, nil +} + +func TestAuthPluginUnit(t *testing.T) { + ctx := core.NewContext(context.Background()) + testDB := setupTestDB(t) + core.Provide[contracts.DBService](ctx, &mockDBService{db: testDB}) + + p := auth.New() + assert.Equal(t, "auth", p.Name()) + assert.Equal(t, "1.0.0", p.Manifest().Version) + require.NoError(t, p.Apply(ctx)) + + // Test AuthService injection + authSvc, err := core.Inject[contracts.AuthService](ctx) + require.NoError(t, err) + assert.NotNil(t, authSvc.RequireAuthMiddleware()) + assert.NotNil(t, authSvc.RequireAdminMiddleware()) + + // Test AuthRegistry injection + authReg, err := core.Inject[contracts.AuthRegistry](ctx) + require.NoError(t, err) + authReg.RegisterOAuthProvider("custom", &mockProvider{}) + prov, ok := authReg.GetOAuthProvider("custom") + require.True(t, ok) + assert.Equal(t, "custom", prov.Name()) + + // Test User Token Verification with dummy token + user := testUser{ + ID: 101, + Username: "token_user", + IsActive: true, + } + require.NoError(t, testDB.Create(&user).Error) + + tokenStr := "test-secret-token-123456" + tokenHash := hashToken(tokenStr) + tokenRecord := testAccessToken{ + ID: 201, + UserID: user.ID, + TokenHash: tokenHash, + Name: "test-token", + IsAdmin: false, + } + require.NoError(t, testDB.Create(&tokenRecord).Error) + + userDTO, err := authSvc.VerifyToken(context.Background(), tokenStr) + require.NoError(t, err) + assert.Equal(t, user.ID, userDTO.ID) + assert.Equal(t, "token_user", userDTO.Username) + + // Empty token fails + _, err = authSvc.VerifyToken(context.Background(), "") + assert.Error(t, err) + + // Revoke sessions + require.NoError(t, authSvc.RevokeUserSessions(context.Background(), user.ID)) + + // GetCurrentUser from context + userCtx := context.WithValue(context.Background(), contracts.AuthUserObjKey, userDTO) + current, err := authSvc.GetCurrentUser(userCtx) + require.NoError(t, err) + assert.Equal(t, user.ID, current.ID) +} diff --git a/backend/plugins/domain/auth/provider_cache.go b/backend/plugins/domain/auth/provider_cache.go new file mode 100644 index 00000000..86465b7b --- /dev/null +++ b/backend/plugins/domain/auth/provider_cache.go @@ -0,0 +1,81 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "context" + "net/http" + "sync" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" + "golang.org/x/sync/singleflight" +) + +// oidcProviderCache 进程级 OIDC provider 缓存。 +type oidcProviderCache struct { + mu sync.RWMutex + entries map[string]*oidc.Provider // key: normalized issuer URL + sfGroup singleflight.Group +} + +// globalOIDCProviderCache 是包级单例缓存,与进程同生命周期。 +var globalOIDCProviderCache = &oidcProviderCache{ + entries: make(map[string]*oidc.Provider), +} + +// discoveryContext 从请求 ctx 提取 HTTP 客户端,并绑定到不可取消的 Background ctx。 +func discoveryContext(ctx context.Context) context.Context { + bg := context.Background() + if client, ok := ctx.Value(oauth2.HTTPClient).(*http.Client); ok && client != nil { + bg = oidc.ClientContext(bg, client) + } + return bg +} + +// get 返回缓存的 provider;若无则通过 oidc.NewProvider 获取并写入缓存。 +func (c *oidcProviderCache) get(ctx context.Context, issuer string) (*oidc.Provider, error) { + c.mu.RLock() + if p, ok := c.entries[issuer]; ok { + c.mu.RUnlock() + return p, nil + } + c.mu.RUnlock() + + discCtx := discoveryContext(ctx) + v, err, _ := c.sfGroup.Do(issuer, func() (any, error) { + c.mu.RLock() + if p, ok := c.entries[issuer]; ok { + c.mu.RUnlock() + return p, nil + } + c.mu.RUnlock() + + p, err := oidc.NewProvider(discCtx, issuer) + if err != nil { + return nil, err + } + + c.mu.Lock() + c.entries[issuer] = p + c.mu.Unlock() + return p, nil + }) + if err != nil { + return nil, err + } + return v.(*oidc.Provider), nil //nolint:forcetypeassert +} + +// invalidate 从缓存中移除指定 issuer 对应的 provider。 +func (c *oidcProviderCache) invalidate(issuer string) { + c.mu.Lock() + delete(c.entries, issuer) + c.mu.Unlock() +} + +// InvalidateOIDCProviderCache 从进程级缓存中清除指定 issuer 的 provider 条目。 +func InvalidateOIDCProviderCache(issuer string) { + globalOIDCProviderCache.invalidate(issuer) +} diff --git a/backend/plugins/domain/auth/repository.go b/backend/plugins/domain/auth/repository.go new file mode 100644 index 00000000..c245e924 --- /dev/null +++ b/backend/plugins/domain/auth/repository.go @@ -0,0 +1,215 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "Wavelet/pkg/util" + "context" + "sync" + "time" + + "gorm.io/gorm" +) + +var ( + dbMu sync.RWMutex + dbSvc contracts.DBService + cacheMu sync.RWMutex + cacheSvc contracts.CacheService +) + +func setDBService(s contracts.DBService) { + dbMu.Lock() + defer dbMu.Unlock() + dbSvc = s +} + +func setCacheService(s contracts.CacheService) { + cacheMu.Lock() + defer cacheMu.Unlock() + cacheSvc = s +} + +func getDB(ctx context.Context) *gorm.DB { + if c, ok := ctx.(*core.Context); ok && c != nil { + if s, err := core.Inject[contracts.DBService](c); err == nil && s != nil { + return s.DB(ctx) + } + } + dbMu.RLock() + s := dbSvc + dbMu.RUnlock() + if s != nil { + return s.DB(ctx) + } + return nil +} + +func getCache(ctx context.Context) contracts.CacheService { + if c, ok := ctx.(*core.Context); ok && c != nil { + if s, err := core.Inject[contracts.CacheService](c); err == nil && s != nil { + return s + } + } + cacheMu.RLock() + s := cacheSvc + cacheMu.RUnlock() + return s +} + +// GetAccessTokenByHash 按令牌哈希读取访问令牌记录(仅取鉴权所需字段) +func GetAccessTokenByHash(ctx context.Context, tokenHash string) (*CachedToken, error) { + var row struct { + ID uint64 + UserID uint64 + IsAdmin bool + } + if err := getDB(ctx).Table("w_access_tokens").Where("token_hash = ?", tokenHash).First(&row).Error; err != nil { + return nil, err + } + return &CachedToken{ + ID: row.ID, + UserID: row.UserID, + IsAdmin: row.IsAdmin, + }, nil +} + +// GetActiveUserByID 读取仍处于启用状态的用户 +func GetActiveUserByID(ctx context.Context, userID uint64) (*contracts.UserDTO, error) { + var user contracts.UserDTO + if err := getDB(ctx).Table("w_users").Where("id = ? AND is_active = ?", userID, true).First(&user).Error; err != nil { + return nil, err + } + return &user, nil +} + +// GetUserByID 按 ID 读取用户(不限制启用状态) +func GetUserByID(ctx context.Context, userID uint64) (*contracts.UserDTO, error) { + var user contracts.UserDTO + if err := getDB(ctx).Table("w_users").Where("id = ?", userID).First(&user).Error; err != nil { + return nil, err + } + return &user, nil +} + +// InsertUser 新建用户记录 +func InsertUser(ctx context.Context, user *contracts.UserDTO) error { + return getDB(ctx).Table("w_users").Create(user).Error +} + +// TouchUserLastLogin 刷新用户最后登录时间 +func TouchUserLastLogin(ctx context.Context, userID uint64, at time.Time) error { + return getDB(ctx).Table("w_users").Where("id = ?", userID).Update("last_login_at", at).Error +} + +// ListSimilarUsernames 查询与基础用户名相同或带 `-序号` 后缀的用户名(用于用户名去重) +func ListSimilarUsernames(ctx context.Context, base string) ([]string, error) { + var existingUsernames []string + if err := getDB(ctx).Table("w_users"). + Where("username = ? OR username LIKE ? ESCAPE '\\'", base, util.EscapeLike(base)+"-%"). + Pluck("username", &existingUsernames).Error; err != nil { + return nil, err + } + return existingUsernames, nil +} + +// GetSystemConfigValue 读取系统配置项原始值 +func GetSystemConfigValue(ctx context.Context, key string) (string, error) { + var val string + if err := getDB(ctx).Table("w_system_configs").Where("key = ?", key).Pluck("value", &val).Error; err != nil { + return "", err + } + return val, nil +} + +// ListAllAuthSources 获取全部认证源(含未启用),按 ID 升序 +func ListAllAuthSources(ctx context.Context) ([]AuthSource, error) { + var sources []AuthSource + if err := getDB(ctx).Order("id ASC").Find(&sources).Error; err != nil { + return nil, err + } + return sources, nil +} + +// GetAuthSourceByID 根据 ID 获取认证源 +func GetAuthSourceByID(ctx context.Context, id uint64) (*AuthSource, error) { + var src AuthSource + if err := getDB(ctx).First(&src, id).Error; err != nil { + return nil, err + } + return &src, nil +} + +// GetAuthSourceByName 根据名称获取认证源 +func GetAuthSourceByName(ctx context.Context, name string) (*AuthSource, error) { + var src AuthSource + if err := getDB(ctx).Where("name = ?", name).First(&src).Error; err != nil { + return nil, err + } + return &src, nil +} + +// ListActiveAuthSources 获取所有启用的认证源 +func ListActiveAuthSources(ctx context.Context) ([]AuthSource, error) { + var sources []AuthSource + if err := getDB(ctx).Where("is_active = ?", true).Order("id ASC").Find(&sources).Error; err != nil { + return nil, err + } + return sources, nil +} + +// CreateAuthSourceRecord 新建认证源记录 +func CreateAuthSourceRecord(ctx context.Context, source *AuthSource) error { + return getDB(ctx).Create(source).Error +} + +// SaveAuthSourceRecord 全量保存认证源记录 +func SaveAuthSourceRecord(ctx context.Context, source *AuthSource) error { + return getDB(ctx).Save(source).Error +} + +// DeleteAuthSourceRecord 删除认证源记录 +func DeleteAuthSourceRecord(ctx context.Context, source *AuthSource) error { + return getDB(ctx).Delete(source).Error +} + +// GetActiveAuthSourcesCached 获取所有启用的认证源(带缓存或直接查询) +func GetActiveAuthSourcesCached(ctx context.Context) ([]AuthSource, error) { + return ListActiveAuthSources(ctx) +} + +// GetAuthSourceByNameCached 根据名称获取认证源(带缓存或直接查询) +func GetAuthSourceByNameCached(ctx context.Context, name string) (*AuthSource, error) { + return GetAuthSourceByName(ctx, name) +} + +// FindExternalAccount 查询指定认证源的外部账号绑定 +func FindExternalAccount(ctx context.Context, authSourceID uint64, externalID string) (*ExternalAccount, error) { + var account ExternalAccount + if err := getDB(ctx).Where("auth_source_id = ? AND external_id = ?", authSourceID, externalID).First(&account).Error; err != nil { + return nil, err + } + return &account, nil +} + +// BindExternalAccount 绑定外部账号 +func BindExternalAccount(ctx context.Context, account *ExternalAccount) error { + return getDB(ctx).Create(account).Error +} + +// ListExternalAccountsByUserID 获取用户绑定的所有外部账号 +func ListExternalAccountsByUserID(ctx context.Context, userID uint64) ([]ExternalAccount, error) { + var accounts []ExternalAccount + if err := getDB(ctx).Where("user_id = ?", userID).Find(&accounts).Error; err != nil { + return nil, err + } + return accounts, nil +} + +// UnbindExternalAccount 解绑外部账号 +func UnbindExternalAccount(ctx context.Context, id, userID uint64) error { + return getDB(ctx).Where("id = ? AND user_id = ?", id, userID).Delete(&ExternalAccount{}).Error +} diff --git a/backend/plugins/domain/auth/service.go b/backend/plugins/domain/auth/service.go new file mode 100644 index 00000000..e26b488f --- /dev/null +++ b/backend/plugins/domain/auth/service.go @@ -0,0 +1,262 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "Wavelet/core/contracts" + "context" + "errors" + "sync" +) + +type authServiceImpl struct{} + +func newAuthService() contracts.AuthService { + return &authServiceImpl{} +} + +func (s *authServiceImpl) RequireAuthMiddleware() any { + return LoginRequired() +} + +func (s *authServiceImpl) RequireAdminMiddleware() any { + return AdminRequired() +} + +// GetCurrentUser 从 context 中读取登录用户。 +// +// 中间件通过 gin 的 c.Set(contracts.AuthUserObjKey, user) 写入登录态; +// *gin.Context 自身实现了 context.Context,且其 Value(key) 对 string 类型 key +// 等价于 c.Get(key)(未命中时再回落到 Request.Context().Value), +// 因此这里无需感知 gin 即可读取同一份登录态。 +func (s *authServiceImpl) GetCurrentUser(ctx context.Context) (*contracts.UserDTO, error) { + if v := ctx.Value(contracts.AuthUserObjKey); v != nil { + if u, ok := v.(*contracts.UserDTO); ok && u != nil { + return u, nil + } + } + + return nil, errors.New(errUserNotInContext) +} + +func (s *authServiceImpl) VerifyToken(ctx context.Context, token string) (*contracts.UserDTO, error) { + if token == "" { + return nil, errors.New(errEmptyToken) + } + + tokenHash := hashToken(token) + tokenRecord, err := GetCachedToken(ctx, tokenHash) + if err != nil { + tokenRecord, err = GetAccessTokenByHash(ctx, tokenHash) + if err != nil { + return nil, err + } + SetCachedToken(ctx, tokenHash, tokenRecord) + } + + user, err := GetCachedUser(ctx, tokenRecord.UserID) + if err != nil || user == nil || !user.IsActive { + user, err = GetActiveUserByID(ctx, tokenRecord.UserID) + if err != nil { + return nil, err + } + SetCachedUser(ctx, tokenRecord.UserID, user) + } + + if user.Username == SystemUsername { + return nil, errors.New(errSystemUserTokenNotAllowed) + } + + return user, nil +} + +func (s *authServiceImpl) CreateSession(_ context.Context, _ uint64, _ map[string]any) (string, error) { + return "", nil +} + +func (s *authServiceImpl) RevokeUserSessions(ctx context.Context, userID uint64) error { + InvalidateCachedUser(ctx, userID) + return nil +} + +// GetCurrentUserID 从请求登录态中读取用户 ID。 +// +// Session 读取依赖 gin,属于接入层职责,因此这里通过接入层桥接函数 +// currentUserIDFromRequestContext(见 middleware.go)取值,Service 层本身不感知 gin。 +func (s *authServiceImpl) GetCurrentUserID(ctx context.Context) (uint64, error) { + userID, ok := currentUserIDFromRequestContext(ctx) + if !ok { + return 0, errors.New(errUserNotInContext) + } + return userID, nil +} + +func (s *authServiceImpl) RevokeToken(ctx context.Context, tokenHash string) error { + InvalidateCachedToken(ctx, tokenHash) + return nil +} + +func (s *authServiceImpl) DisallowTokenAuthMiddleware() any { + return DisallowTokenAuth() +} + +func (s *authServiceImpl) InvalidateCachedUser(ctx context.Context, userID uint64) { + InvalidateCachedUser(ctx, userID) +} + +func (s *authServiceImpl) InvalidateCachedToken(ctx context.Context, tokenHash string) { + InvalidateCachedToken(ctx, tokenHash) +} + +func (s *authServiceImpl) ListAuthSources(ctx context.Context) ([]contracts.AuthSourceViewDTO, error) { + sources, err := ListAllAuthSources(ctx) + if err != nil { + return nil, err + } + + views := make([]contracts.AuthSourceViewDTO, len(sources)) + for i := range sources { + views[i] = contracts.AuthSourceViewDTO{ + ID: sources[i].ID, + Name: sources[i].Name, + Type: sources[i].Type, + DisplayName: sources[i].DisplayName, + IsActive: sources[i].IsActive, + IconURL: sources[i].IconURL, + ClientSecretConfigured: sources[i].ClientSecret != "", + } + } + return views, nil +} + +func (s *authServiceImpl) CreateAuthSource(ctx context.Context, source contracts.AuthSourceDTO) (*contracts.AuthSourceDTO, error) { + model := AuthSource{ + ID: source.ID, + Name: source.Name, + Type: source.Type, + DisplayName: source.DisplayName, + ClientID: source.ClientID, + ClientSecret: source.ClientSecret, + OpenIDDiscoveryURL: source.OpenIDDiscoveryURL, + Scopes: source.Scopes, + IconURL: source.IconURL, + IsActive: source.IsActive, + } + + if err := model.Validate(); err != nil { + return nil, err + } + + if err := CreateAuthSourceRecord(ctx, &model); err != nil { + return nil, err + } + + model.Sanitize() + return toAuthSourceDTO(&model), nil +} + +func (s *authServiceImpl) UpdateAuthSource(ctx context.Context, id uint64, source contracts.AuthSourceDTO) (*contracts.AuthSourceDTO, error) { + existing, err := GetAuthSourceByID(ctx, id) + if err != nil { + return nil, err + } + + existing.DisplayName = source.DisplayName + existing.ClientID = source.ClientID + if source.ClientSecret != "" { + existing.ClientSecret = source.ClientSecret + } + existing.OpenIDDiscoveryURL = source.OpenIDDiscoveryURL + existing.Scopes = source.Scopes + existing.IconURL = source.IconURL + + if err := existing.Validate(); err != nil { + return nil, err + } + + if err := SaveAuthSourceRecord(ctx, existing); err != nil { + return nil, err + } + + existing.Sanitize() + return toAuthSourceDTO(existing), nil +} + +func (s *authServiceImpl) DeleteAuthSource(ctx context.Context, id uint64) error { + existing, err := GetAuthSourceByID(ctx, id) + if err != nil { + return err + } + + return DeleteAuthSourceRecord(ctx, existing) +} + +func (s *authServiceImpl) ToggleAuthSource(ctx context.Context, id uint64) (*contracts.AuthSourceDTO, error) { + existing, err := GetAuthSourceByID(ctx, id) + if err != nil { + return nil, err + } + + existing.IsActive = !existing.IsActive + if err := SaveAuthSourceRecord(ctx, existing); err != nil { + return nil, err + } + + existing.Sanitize() + return toAuthSourceDTO(existing), nil +} + +func toAuthSourceDTO(s *AuthSource) *contracts.AuthSourceDTO { + if s == nil { + return nil + } + return &contracts.AuthSourceDTO{ + ID: s.ID, + Name: s.Name, + Type: s.Type, + DisplayName: s.DisplayName, + ClientID: s.ClientID, + ClientSecret: s.ClientSecret, + OpenIDDiscoveryURL: s.OpenIDDiscoveryURL, + Scopes: s.Scopes, + IconURL: s.IconURL, + IsActive: s.IsActive, + CreatedAt: s.CreatedAt, + UpdatedAt: s.UpdatedAt, + } +} + +type authRegistryImpl struct { + mu sync.RWMutex + providers map[string]contracts.OAuthProvider +} + +func newAuthRegistry() contracts.AuthRegistry { + return &authRegistryImpl{ + providers: make(map[string]contracts.OAuthProvider), + } +} + +func (r *authRegistryImpl) RegisterOAuthProvider(name string, provider contracts.OAuthProvider) { + r.mu.Lock() + defer r.mu.Unlock() + r.providers[name] = provider +} + +func (r *authRegistryImpl) GetOAuthProvider(name string) (contracts.OAuthProvider, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + p, ok := r.providers[name] + return p, ok +} + +func (r *authRegistryImpl) ListOAuthProviders() []string { + r.mu.RLock() + defer r.mu.RUnlock() + res := make([]string, 0, len(r.providers)) + for name := range r.providers { + res = append(res, name) + } + return res +} diff --git a/backend/plugins/domain/auth/service_test.go b/backend/plugins/domain/auth/service_test.go new file mode 100644 index 00000000..2431ec67 --- /dev/null +++ b/backend/plugins/domain/auth/service_test.go @@ -0,0 +1,401 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package auth_test + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "Wavelet/pkg/ginutil" + "Wavelet/pkg/response" + "Wavelet/plugins/domain/auth" + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/cookie" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +const ( + testSessionCookieName = "auth-test-session" + errUserNotInContext = "auth: user not found in context" +) + +// newTestAuthService 装配仅注册 auth 插件的 core.Context,并返回其对外契约实现。 +func newTestAuthService(t *testing.T, db *gorm.DB) contracts.AuthService { + t.Helper() + + ctx := core.NewContext(context.Background()) + if db != nil { + core.Provide[contracts.DBService](ctx, &mockDBService{db: db}) + core.Provide[contracts.CacheService](ctx, newMockCacheService()) + } + require.NoError(t, auth.New().Apply(ctx)) + + svc, err := core.Inject[contracts.AuthService](ctx) + require.NoError(t, err) + auth.ResetAuthRAMCacheForTest() + + return svc +} + +// newSessionEngine 构造一个带 Session 中间件的 gin 引擎,用于走通真实登录态链路。 +// +// response.Abort* 只把错误挂载到 gin 错误链,状态码由全局错误中间件渲染, +// 因此这里必须同时装配 response.ErrorHandlerMiddleware()。 +func newSessionEngine() *gin.Engine { + engine := gin.New() + engine.Use(response.ErrorHandlerMiddleware()) + engine.Use(sessions.Sessions(testSessionCookieName, cookie.NewStore([]byte("test-secret")))) + return engine +} + +func TestGetCurrentUserFromGinContext(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := newTestAuthService(t, nil) + user := &contracts.UserDTO{ID: 4242, Username: "ctx_user", IsActive: true} + + t.Run("gin 上下文已由中间件写入用户时返回该用户", func(t *testing.T) { + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/user-info", nil) + c.Set(contracts.AuthUserObjKey, user) + + got, err := svc.GetCurrentUser(c) + require.NoError(t, err) + assert.Same(t, user, got) + }) + + t.Run("开启 ContextWithFallback 时可从请求 context 回落读取", func(t *testing.T) { + // 说明:本项目引擎默认不开启 ContextWithFallback,此时 (*gin.Context).Value + // 等价于 c.Get,与改造前 ginutil.GetFromContext 的读取路径完全一致; + // 开启回落后还能额外读到写入 Request.Context() 的登录态。 + reqCtx := context.WithValue(context.Background(), contracts.AuthUserObjKey, user) //nolint:staticcheck // 模拟写入请求 context 的登录态 + engine := gin.New() + engine.ContextWithFallback = true + var ( + gotUser *contracts.UserDTO + gotErr error + ) + engine.GET("/probe", func(c *gin.Context) { + gotUser, gotErr = svc.GetCurrentUser(c) + c.Status(http.StatusNoContent) + }) + + engine.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/probe", nil).WithContext(reqCtx)) + require.NoError(t, gotErr) + assert.Same(t, user, gotUser) + }) + + t.Run("未登录时报错且文案不变", func(t *testing.T) { + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/user-info", nil) + + got, err := svc.GetCurrentUser(c) + require.Error(t, err) + assert.Nil(t, got) + assert.Equal(t, errUserNotInContext, err.Error()) + }) + + t.Run("非 gin 的普通 context 仍按 Value 取值", func(t *testing.T) { + got, err := svc.GetCurrentUser(context.WithValue(context.Background(), contracts.AuthUserObjKey, user)) //nolint:staticcheck // 与中间件写入的 key 语义一致 + require.NoError(t, err) + assert.Same(t, user, got) + + _, err = svc.GetCurrentUser(context.Background()) + require.Error(t, err) + assert.Equal(t, errUserNotInContext, err.Error()) + }) +} + +func TestGetCurrentUserID(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := newTestAuthService(t, nil) + + t.Run("gin Session 中的用户 ID 可正常读取", func(t *testing.T) { + engine := newSessionEngine() + var ( + gotUID uint64 + gotErr error + ) + engine.GET("/probe", func(c *gin.Context) { + session := sessions.Default(c) + session.Set(auth.UserIDKey, uint64(777)) + require.NoError(t, session.Save()) + + gotUID, gotErr = svc.GetCurrentUserID(c) + c.Status(http.StatusNoContent) + }) + + engine.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/probe", nil)) + require.NoError(t, gotErr) + assert.Equal(t, uint64(777), gotUID) + }) + + t.Run("gin 上下文存在但 Session 无用户时返回 0 且不报错", func(t *testing.T) { + engine := newSessionEngine() + var ( + gotUID uint64 + gotErr error + ) + engine.GET("/probe", func(c *gin.Context) { + gotUID, gotErr = svc.GetCurrentUserID(c) + c.Status(http.StatusNoContent) + }) + + engine.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/probe", nil)) + require.NoError(t, gotErr) + assert.Equal(t, uint64(0), gotUID) + }) + + t.Run("非 gin context 报错且文案不变", func(t *testing.T) { + // 即使普通 context 中已写入用户对象,该方法的 Session 语义也保持不变。 + uid, err := svc.GetCurrentUserID( + context.WithValue(context.Background(), contracts.AuthUserObjKey, &contracts.UserDTO{ID: 1}), //nolint:staticcheck // 同上 + ) + require.Error(t, err) + assert.Equal(t, uint64(0), uid) + assert.Equal(t, errUserNotInContext, err.Error()) + }) +} + +func TestLoginRequiredMiddlewarePopulatesServiceContext(t *testing.T) { + gin.SetMode(gin.TestMode) + + db := setupTestDB(t) + require.NoError(t, db.Create(&testUser{ID: 9001, Username: "session_user", IsActive: true}).Error) + require.NoError(t, db.Create(&testUser{ID: 9002, Username: "token_user", IsActive: true}).Error) + + tokenStr := "integration-secret-token" + require.NoError(t, db.Create(&testAccessToken{ + ID: 9101, + UserID: 9002, + TokenHash: hashToken(tokenStr), + Name: "integration", + IsAdmin: false, + }).Error) + + svc := newTestAuthService(t, db) + + t.Run("Session 鉴权链路上 GetCurrentUser 与 GetCurrentUserID 一致", func(t *testing.T) { + engine := newSessionEngine() + engine.Use(func(c *gin.Context) { + session := sessions.Default(c) + session.Set(auth.UserIDKey, uint64(9001)) + require.NoError(t, session.Save()) + c.Next() + }) + + var ( + gotUser *contracts.UserDTO + userErr error + gotUID uint64 + uidErr error + ) + engine.GET("/protected", auth.LoginRequired(), func(c *gin.Context) { + gotUser, userErr = svc.GetCurrentUser(c) + gotUID, uidErr = svc.GetCurrentUserID(c) + c.Status(http.StatusNoContent) + }) + + recorder := httptest.NewRecorder() + engine.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/protected", nil)) + require.Equal(t, http.StatusNoContent, recorder.Code) + + require.NoError(t, userErr) + require.NotNil(t, gotUser) + assert.Equal(t, uint64(9001), gotUser.ID) + assert.Equal(t, "session_user", gotUser.Username) + + require.NoError(t, uidErr) + assert.Equal(t, uint64(9001), gotUID) + }) + + t.Run("Access Token 鉴权链路上 GetCurrentUser 可用", func(t *testing.T) { + engine := newSessionEngine() + var ( + gotUser *contracts.UserDTO + userErr error + ) + engine.GET("/protected", auth.LoginRequired(), func(c *gin.Context) { + gotUser, userErr = svc.GetCurrentUser(c) + c.Status(http.StatusNoContent) + }) + + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + req.Header.Set("Authorization", "Bearer "+tokenStr) + recorder := httptest.NewRecorder() + engine.ServeHTTP(recorder, req) + require.Equal(t, http.StatusNoContent, recorder.Code) + + require.NoError(t, userErr) + require.NotNil(t, gotUser) + assert.Equal(t, uint64(9002), gotUser.ID) + assert.Equal(t, "token_user", gotUser.Username) + }) + + t.Run("未登录请求被中间件拒绝", func(t *testing.T) { + engine := newSessionEngine() + engine.GET("/protected", auth.LoginRequired(), func(c *gin.Context) { + c.Status(http.StatusNoContent) + }) + + recorder := httptest.NewRecorder() + engine.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/protected", nil)) + assert.Equal(t, http.StatusUnauthorized, recorder.Code) + }) +} + +// legacyGetCurrentUser 逐字复刻改造前 Service 层的取值实现 +// (*gin.Context 类型断言 + ginutil.GetFromContext + ctx.Value 回落), +// 用于与新实现做 differential 等价性校验。 +func legacyGetCurrentUser(ctx context.Context) (*contracts.UserDTO, error) { + if ginCtx, ok := ctx.(*gin.Context); ok { + if u, ok := ginutil.GetFromContext[*contracts.UserDTO](ginCtx, contracts.AuthUserObjKey); ok && u != nil { + return u, nil + } + } + + if v := ctx.Value(contracts.AuthUserObjKey); v != nil { + if u, ok := v.(*contracts.UserDTO); ok && u != nil { + return u, nil + } + } + + return nil, errors.New(errUserNotInContext) +} + +// legacyGetCurrentUserID 逐字复刻改造前 Service 层基于 gin Session 的实现。 +func legacyGetCurrentUserID(ctx context.Context) (uint64, error) { + if ginCtx, ok := ctx.(*gin.Context); ok { + return auth.GetUserIDFromContext(ginCtx), nil + } + + return 0, errors.New(errUserNotInContext) +} + +func errText(err error) string { + if err == nil { + return "" + } + return err.Error() +} + +// assertLoginStateParity 断言新实现与改造前实现在同一 ctx 上返回完全一致的结果与错误文案。 +func assertLoginStateParity(t *testing.T, svc contracts.AuthService, ctx context.Context) { + t.Helper() + + wantUser, wantUserErr := legacyGetCurrentUser(ctx) + gotUser, gotUserErr := svc.GetCurrentUser(ctx) + if (wantUser == nil) != (gotUser == nil) { + t.Fatalf("GetCurrentUser nil-ness mismatch: want %v, got %v", wantUser, gotUser) + } + if wantUser != nil { + assert.Same(t, wantUser, gotUser) + } + assert.Equal(t, errText(wantUserErr), errText(gotUserErr)) + + wantUID, wantUIDErr := legacyGetCurrentUserID(ctx) + gotUID, gotUIDErr := svc.GetCurrentUserID(ctx) + assert.Equal(t, wantUID, gotUID) + assert.Equal(t, errText(wantUIDErr), errText(gotUIDErr)) +} + +func TestLoginStateContextParityWithLegacyImplementation(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := newTestAuthService(t, nil) + user := &contracts.UserDTO{ID: 5150, Username: "parity_user", IsActive: true} + + t.Run("gin 上下文各分支", func(t *testing.T) { + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/user-info", nil) + assertLoginStateParity(t, svc, c) + + c.Set(contracts.AuthUserObjKey, user) + assertLoginStateParity(t, svc, c) + + c.Set(contracts.AuthUserObjKey, "not-a-user-dto") + assertLoginStateParity(t, svc, c) + + var typedNil *contracts.UserDTO + c.Set(contracts.AuthUserObjKey, typedNil) + assertLoginStateParity(t, svc, c) + }) + + t.Run("普通 context 各分支", func(t *testing.T) { + assertLoginStateParity(t, svc, context.Background()) + assertLoginStateParity(t, svc, context.WithValue(context.Background(), contracts.AuthUserObjKey, user)) + assertLoginStateParity(t, svc, context.WithValue(context.Background(), contracts.AuthUserObjKey, "nope")) + }) + + t.Run("Session 登录态各分支", func(t *testing.T) { + cases := []struct { + name string + userID any + }{ + {name: "无用户", userID: nil}, + {name: "uint64 用户 ID", userID: uint64(3301)}, + {name: "float64 用户 ID", userID: float64(3302)}, + {name: "string 用户 ID", userID: "3303"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + engine := newSessionEngine() + engine.GET("/probe", func(c *gin.Context) { + if tc.userID != nil { + session := sessions.Default(c) + session.Set(auth.UserIDKey, tc.userID) + require.NoError(t, session.Save()) + } + assertLoginStateParity(t, svc, c) + }) + + engine.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/probe", nil)) + }) + } + }) +} + +func TestAuthWhitelistMiddleware(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx := core.NewContext(context.Background()) + p := auth.New() + require.NoError(t, p.Apply(ctx)) + + svc, err := core.Inject[contracts.AuthService](ctx) + require.NoError(t, err) + + mw, ok := svc.RequireAuthMiddleware().(gin.HandlerFunc) + require.True(t, ok) + + engine := newSessionEngine() + engine.Use(mw) + engine.POST("/api/v1/user/login", func(c *gin.Context) { + c.JSON(http.StatusOK, response.OK("login-ok")) + }) + engine.GET("/api/v1/secret-profile", func(c *gin.Context) { + c.JSON(http.StatusOK, response.OK("profile-ok")) + }) + + // 1. Whitelisted route /api/v1/user/login passes through without auth + w1 := httptest.NewRecorder() + req1, _ := http.NewRequest(http.MethodPost, "/api/v1/user/login", nil) + engine.ServeHTTP(w1, req1) + assert.Equal(t, http.StatusOK, w1.Code) + + // 2. Non-whitelisted route /api/v1/secret-profile gets 401 Unauthorized + w2 := httptest.NewRecorder() + req2, _ := http.NewRequest(http.MethodGet, "/api/v1/secret-profile", nil) + engine.ServeHTTP(w2, req2) + assert.Equal(t, http.StatusUnauthorized, w2.Code) +} diff --git a/backend/plugins/domain/auth/session.go b/backend/plugins/domain/auth/session.go new file mode 100644 index 00000000..97141413 --- /dev/null +++ b/backend/plugins/domain/auth/session.go @@ -0,0 +1,169 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "Wavelet/core/contracts" + "context" + "crypto/sha256" + "encoding/hex" + "net/http" + "strconv" + "strings" + "sync" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + gsessions "github.com/gorilla/sessions" +) + +var ( + sessConfigMu sync.RWMutex + sessConfig = SessionConfig{ + SessionCookieName: "wavelet_session", + SessionAge: 86400, + SessionHTTPOnly: true, + } +) + +// SetSessionConfig updates the active session configuration. +func SetSessionConfig(cfg SessionConfig) { + sessConfigMu.Lock() + defer sessConfigMu.Unlock() + sessConfig = cfg +} + +// GetSessionConfig returns the active session configuration. +func GetSessionConfig() SessionConfig { + sessConfigMu.RLock() + defer sessConfigMu.RUnlock() + return sessConfig +} + +// GetSessionOptions 根据配置构建 Session 选项 +func GetSessionOptions(maxAge int) sessions.Options { + cfg := GetSessionConfig() + return sessions.Options{ + Path: "/", + Domain: cfg.SessionDomain, + MaxAge: maxAge, + HttpOnly: cfg.SessionHTTPOnly, + Secure: cfg.SessionSecure, + SameSite: http.SameSiteLaxMode, + } +} + +// StripCookieMaxAgeAndExpires 从 Set-Cookie 响应头中移除 Max-Age 和 Expires,从而使其成为浏览器会话 Cookie +func StripCookieMaxAgeAndExpires(header http.Header, cookieName string) { + headers := header["Set-Cookie"] + if len(headers) == 0 { + return + } + + newHeaders := make([]string, 0, len(headers)) + for _, h := range headers { + if strings.HasPrefix(h, cookieName+"=") { + parts := strings.Split(h, ";") + newParts := make([]string, 0, len(parts)) + for _, p := range parts { + trimmed := strings.TrimSpace(p) + lower := strings.ToLower(trimmed) + if strings.HasPrefix(lower, "max-age=") || strings.HasPrefix(lower, "expires=") { + continue + } + newParts = append(newParts, p) + } + newHeaders = append(newHeaders, strings.Join(newParts, ";")) + } else { + newHeaders = append(newHeaders, h) + } + } + header["Set-Cookie"] = newHeaders +} + +// GetUserIDFromSession 从 Session 中提取用户 ID +func GetUserIDFromSession(s sessions.Session) uint64 { + val := s.Get(UserIDKey) + return ParseUserID(val) +} + +// GetUserIDFromContext 从 Gin Context 的 Session 中提取用户 ID +func GetUserIDFromContext(c *gin.Context) (uid uint64) { + defer func() { + _ = recover() + }() + session := sessions.Default(c) + return GetUserIDFromSession(session) +} + +func ensureSessionToken(s sessions.Session) (string, bool) { + token, ok := s.Get(SessionTokenKey).(string) + if !ok || token == "" { + token = uuid.NewString() + s.Set(SessionTokenKey, token) + return token, true + } + return token, false +} + +func hashSessionToken(token string) string { + h := sha256.New() + h.Write([]byte(token)) + return hex.EncodeToString(h.Sum(nil)) +} + +func rotateSessionID(s sessions.Session) { + if inner, ok := s.(interface{ Session() *gsessions.Session }); ok { + if sess := inner.Session(); sess != nil { + sess.ID = "" + } + } +} + +// SetLoginSession writes the authenticated user into a freshly rotated session. +func SetLoginSession(ctx context.Context, c *gin.Context, user *contracts.UserDTO, extras ...map[string]any) error { + session := sessions.Default(c) + session.Clear() + rotateSessionID(session) + + session.Set(UserIDKey, user.ID) + session.Set(UserNameKey, user.Username) + if len(extras) > 0 { + for key, value := range extras[0] { + session.Set(key, value) + } + } + + // 根据系统配置动态设置 Session 过期时间 + cfg := GetSessionConfig() + maxAge := cfg.SessionAge + isSessionCookie := false + + val, err := GetSystemConfigValue(ctx, "login_session_ttl_hours") + if err == nil && val != "" { + if ttlHours, err := strconv.Atoi(val); err == nil { + switch { + case ttlHours == -1: + // 永不过期,设置为 10 年 + maxAge = 10 * 365 * 24 * 3600 + case ttlHours > 0: + maxAge = ttlHours * 3600 + case ttlHours == 0: + isSessionCookie = true + } + } + } + session.Options(GetSessionOptions(maxAge)) + + if err := session.Save(); err != nil { + return err + } + + if isSessionCookie { + StripCookieMaxAgeAndExpires(c.Writer.Header(), cfg.SessionCookieName) + } + + return nil +} diff --git a/backend/plugins/domain/auth_dependency_ordering_test.go b/backend/plugins/domain/auth_dependency_ordering_test.go new file mode 100644 index 00000000..5e4df016 --- /dev/null +++ b/backend/plugins/domain/auth_dependency_ordering_test.go @@ -0,0 +1,219 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package domain_test + +import ( + "context" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "Wavelet/core" + "Wavelet/core/contracts" + "Wavelet/core/extpoints" + "Wavelet/plugins/domain/admin" + "Wavelet/plugins/domain/message_gateway" + "Wavelet/plugins/domain/user" +) + +// The kernel only gates a plugin's Apply on the services it DECLARES in Inject, +// so a plugin that consumes contracts.AuthService without declaring it can be +// mounted before auth exists. Those plugins fall back to a pass-through +// middleware, which silently un-guards their routes. + +func sentinelLogin(c *gin.Context) { c.Next() } +func sentinelAdmin(c *gin.Context) { c.Next() } +func sentinelNoToken(c *gin.Context) { c.Next() } + +type stubAuthService struct{ contracts.AuthService } + +func (stubAuthService) RequireAuthMiddleware() any { return gin.HandlerFunc(sentinelLogin) } +func (stubAuthService) RequireAdminMiddleware() any { return gin.HandlerFunc(sentinelAdmin) } +func (stubAuthService) DisallowTokenAuthMiddleware() any { return gin.HandlerFunc(sentinelNoToken) } + +type stubDBService struct{ contracts.DBService } + +// providerPlugin publishes a contract into the container at Apply time, the way +// the real infra and domain plugins do. +type providerPlugin struct { + name string + provide func(*core.Context) error +} + +func (p providerPlugin) Name() string { return p.name } + +func (p providerPlugin) Apply(ctx *core.Context) error { return p.provide(ctx) } + +func dbProvider() core.Plugin { + return providerPlugin{name: "stub-database", provide: func(ctx *core.Context) error { + core.Provide[contracts.DBService](ctx, stubDBService{}) + return nil + }} +} + +func authProvider() core.Plugin { + return providerPlugin{name: "stub-auth", provide: func(ctx *core.Context) error { + core.Provide[contracts.AuthService](ctx, stubAuthService{}) + return nil + }} +} + +func findRoute(routes []extpoints.RouteDefinition, method, path string) (extpoints.RouteDefinition, bool) { + for _, rd := range routes { + if rd.Method == method && rd.Path == path { + return rd, true + } + } + return extpoints.RouteDefinition{}, false +} + +// codePointer resolves the function code pointer of a registered handler so +// identity can be compared without depending on gin internals. +func codePointer(handler any) uintptr { + switch fn := handler.(type) { + case gin.HandlerFunc: + return reflect.ValueOf(fn).Pointer() + case func(*gin.Context): + return reflect.ValueOf(fn).Pointer() + default: + return 0 + } +} + +func assertsMiddleware(t *testing.T, routes []extpoints.RouteDefinition, method, path string, want func(*gin.Context)) { + t.Helper() + + rd, ok := findRoute(routes, method, path) + if !ok { + t.Fatalf("route %s %s was never registered", method, path) + } + wantPtr := codePointer(gin.HandlerFunc(want)) + for _, h := range rd.Handlers { + if codePointer(h) == wantPtr { + return + } + } + for _, m := range rd.Middlewares { + if codePointer(m) == wantPtr { + return + } + } + t.Errorf("route %s %s is not guarded by the auth middleware: handlers=%d middlewares=%d", + method, path, len(rd.Handlers), len(rd.Middlewares)) +} + +// TestRoutesMountedBeforeAuthServiceAreGuarded 回归:cmd/app.go 把 user/message_gateway +// 注册在 auth 之前,若插件未在 Inject 中声明 contracts.AuthService,reconcile 会先 +// Apply 它们,导致鉴权中间件退化为透传闭包,路由完全不受保护。 +func TestRoutesMountedBeforeAuthServiceAreGuarded(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx := core.NewContext(context.Background()) + + // Registration order mirrors cmd/app.go: the auth provider comes last. + app := core.NewApp( + core.WithContext(ctx), + core.WithPlugins( + dbProvider(), + user.New(), + message_gateway.New(), + authProvider(), + ), + ) + require.NoError(t, app.ApplyPlugins()) + + routes := ctx.Router().Routes() + + assertsMiddleware(t, routes, "POST", "/api/v1/user/change-password", sentinelLogin) + assertsMiddleware(t, routes, "PUT", "/api/v1/user/profile", sentinelLogin) + assertsMiddleware(t, routes, "GET", "/api/v1/user/access-tokens", sentinelLogin) + assertsMiddleware(t, routes, "GET", "/api/v1/user/access-tokens", sentinelNoToken) + assertsMiddleware(t, routes, "GET", "/api/v1/message-gateway/channels", sentinelLogin) + assertsMiddleware(t, routes, "GET", "/api/v1/admin/message-gateway/channels", sentinelAdmin) +} + +// TestAuthConsumersDeclareAuthDependency 是同一缺陷的架构面:声明依赖是内核排序的唯一 +// 依据,漏声明会让正确性取决于注册表顺序。 +func TestAuthConsumersDeclareAuthDependency(t *testing.T) { + want := reflect.TypeFor[contracts.AuthService]() + for _, tc := range []struct { + name string + deps []reflect.Type + }{ + {"user", user.New().Inject()}, + {"message_gateway", message_gateway.New().Inject()}, + } { + t.Run(tc.name, func(t *testing.T) { + assert.Contains(t, tc.deps, want, + "%s resolves contracts.AuthService in Apply, so it must declare it in Inject", tc.name) + }) + } +} + +// firstGuard returns the outermost middleware registered for the first route +// matching method and path prefix — the auth guard the plugin resolved at Apply. +func firstGuard(t *testing.T, routes []extpoints.RouteDefinition, method, prefix string) gin.HandlerFunc { + t.Helper() + + for _, rd := range routes { + if rd.Method != method || !strings.HasPrefix(rd.Path, prefix) { + continue + } + for _, candidate := range rd.Middlewares { + if mw, ok := candidate.(gin.HandlerFunc); ok { + return mw + } + } + for _, candidate := range rd.Handlers { + if mw, ok := candidate.(gin.HandlerFunc); ok { + return mw + } + } + t.Fatalf("route %s %s has no inspectable guard", method, rd.Path) + } + t.Fatalf("no route registered for %s %s*", method, prefix) + return nil +} + +// TestAuthGuardFailsClosed 回归:鉴权服务无法解析时兜底必须是拒绝。旧实现兜底为 +// c.Next(),所以任何装配缺失——例如 admin 的 OnDispose 调用 service.ResetServices +// 把全局 authService 置 nil——都会让路由以“已登录”的姿态直达业务处理函数。 +func TestAuthGuardFailsClosed(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + apply func(*core.Context) error + method string + prefix string + }{ + {"user", user.New().Apply, http.MethodPost, "/api/v1/user/change-password"}, + {"message_gateway", message_gateway.New().Apply, http.MethodGet, "/api/v1/message-gateway"}, + {"admin", admin.New().Apply, http.MethodGet, "/api/v1/admin"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctx := core.NewContext(context.Background()) + require.NoError(t, tc.apply(ctx)) + + guard := firstGuard(t, ctx.Router().Routes(), tc.method, tc.prefix) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(tc.method, tc.prefix, nil) + guard(c) + + assert.True(t, c.IsAborted(), + "%s guard must reject the request when contracts.AuthService is unavailable", tc.name) + assert.NotEmpty(t, c.Errors, + "%s guard must record why the request was rejected", tc.name) + }) + } +} diff --git a/backend/plugins/domain/cap/errs.go b/backend/plugins/domain/cap/errs.go new file mode 100644 index 00000000..58fbb7d3 --- /dev/null +++ b/backend/plugins/domain/cap/errs.go @@ -0,0 +1,24 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package cap 提供人机验证中间件 +package cap + +// HTTP 响应错误文案 +const ( + errCapTokenMissing = "验证码验证失败,缺少验证码凭证" //nolint:gosec // false positive: this is an error message, not hardcoded credentials + errCapTokenInvalidOrExpired = "验证码校验失败或已过期,请重试" //nolint:gosec // false positive: this is an error message, not hardcoded credentials + errCapNotConfigured = "captcha is not configured" + errChallengeGenerateFailed = "生成验证难题失败,请稍后再试" + errInvalidRequestParams = "无效的参数" + errSolutionVerifyFailed = "校验验证解答失败,请稍后再试" +) + +// Redeem 结果码,属于 redeem 响应 JSON 的对外契约取值,禁止改写取值 +const ( + redeemErrInvalidToken = "invalid_token" + redeemErrNonceStoreFailed = "nonce_store_error" + redeemErrAlreadyRedeemed = "already_redeemed" + redeemErrSettingsLoad = "settings_load_error" + redeemErrTokenStoreFailed = "token_store_error" //nolint:gosec // error code, not hardcoded credentials +) diff --git a/backend/plugins/domain/cap/handlers.go b/backend/plugins/domain/cap/handlers.go new file mode 100644 index 00000000..3be401c9 --- /dev/null +++ b/backend/plugins/domain/cap/handlers.go @@ -0,0 +1,87 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package cap + +import ( + "Wavelet/pkg/logger" + "Wavelet/pkg/response" + "net/http" + + "github.com/gin-gonic/gin" +) + +// Challenge 生成 PoW 人机验证难题 +// @Summary 生成人机验证难题 +// @Description 客户端获取 PoW 难题和签名的 JWT Token,并在后台计算。 +// @Tags cap +// @Accept json +// @Produce json +// @Param request body challengeRequest false "可选范围限制参数" +// @Success 200 {object} response.Any{data=cap.ChallengeResponse} "成功返回 PoW 难题" +// @Failure 500 {object} response.Any "内部服务错误" +// @Router /api/cap/challenge [post] +func Challenge(c *gin.Context) { + var req challengeRequest + _ = c.ShouldBind(&req) // 允许不传 body,默认使用 login scope + + if req.Scope == "" { + req.Scope = "login" + } + + mgr := GetDefaultManager() + if mgr == nil { + response.AbortInternal(c, errCapNotConfigured) + return + } + resp, err := mgr.Generate(c.Request.Context(), req.Scope) + if err != nil { + logger.ErrorF(c.Request.Context(), "Generate cap challenge failed: %v", err) + response.AbortInternal(c, errChallengeGenerateFailed) + return + } + + c.JSON(http.StatusOK, response.OK(resp)) +} + +// Redeem 提交 PoW 解答并兑换一次性凭证 Token +// @Summary 校验人机验证解答 +// @Description 提交 PoW 解答进行核销,成功后返回一次性 X-Cap-Token 凭证 +// @Tags cap +// @Accept json +// @Produce json +// @Param request body redeemRequest true "难题 Token 与解答 solutions 数组" +// @Success 200 {object} response.Any{data=cap.RedeemResponse} "核销成功,返回 X-Cap-Token" +// @Failure 400 {object} response.Any "参数错误或核销失败" +// @Failure 500 {object} response.Any "内部服务错误" +// @Router /api/cap/redeem [post] +func Redeem(c *gin.Context) { + var req redeemRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortBadRequest(c, errInvalidRequestParams) + return + } + + if req.Scope == "" { + req.Scope = "login" + } + + mgr := GetDefaultManager() + if mgr == nil { + response.AbortInternal(c, errCapNotConfigured) + return + } + resp, err := mgr.Redeem(c.Request.Context(), req.Token, req.Solutions, req.Scope) + if err != nil { + logger.ErrorF(c.Request.Context(), "Redeem cap solutions failed: %v", err) + response.AbortInternal(c, errSolutionVerifyFailed) + return + } + + if !resp.Success { + response.AbortBadRequest(c, resp.Error) + return + } + + c.JSON(http.StatusOK, response.OK(resp)) +} diff --git a/backend/plugins/domain/cap/middleware.go b/backend/plugins/domain/cap/middleware.go new file mode 100644 index 00000000..643f9e06 --- /dev/null +++ b/backend/plugins/domain/cap/middleware.go @@ -0,0 +1,38 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package cap + +import ( + "Wavelet/pkg/response" + + "github.com/gin-gonic/gin" +) + +// VerifyMiddleware returns a Gin middleware that checks and consumes the X-Cap-Token header. +func VerifyMiddleware(mgr *Manager, scope string) gin.HandlerFunc { + return func(c *gin.Context) { + if !ProtectionEnabled(c.Request.Context()) { + c.Next() + return + } + if mgr == nil { + response.AbortUnauthorized(c, errCapTokenInvalidOrExpired) + return + } + + token := c.GetHeader("X-Cap-Token") + if token == "" { + response.AbortUnauthorized(c, errCapTokenMissing) + return + } + + valid, err := mgr.VerifyToken(c.Request.Context(), token, scope) + if err != nil || !valid { + response.AbortUnauthorized(c, errCapTokenInvalidOrExpired) + return + } + + c.Next() + } +} diff --git a/backend/plugins/domain/cap/models.go b/backend/plugins/domain/cap/models.go new file mode 100644 index 00000000..645a85ef --- /dev/null +++ b/backend/plugins/domain/cap/models.go @@ -0,0 +1,37 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package cap + +import ( + "Wavelet/plugins/domain/cap/pow" +) + +// ChallengeResponse is a local type alias for the pow.ChallengeResponse struct +type ChallengeResponse = pow.ChallengeResponse + +// challengeRequest is the CAPTCHA challenge request payload. +type challengeRequest struct { + Scope string `json:"scope" form:"scope"` +} + +// redeemRequest is the CAPTCHA redeem request payload. +type redeemRequest struct { + Token string `json:"token" binding:"required"` + Solutions []int `json:"solutions" binding:"required"` + Scope string `json:"scope" form:"scope"` +} + +// RedeemResponse is returned to the client on redeem. +type RedeemResponse struct { + Success bool `json:"success"` + Token string `json:"token,omitempty"` + Expires int64 `json:"expires,omitempty"` + Error string `json:"error,omitempty"` +} + +// configRecord maps the columns selected from the system config table. +type configRecord struct { + Key string `gorm:"column:key"` + Value string `gorm:"column:value"` +} diff --git a/backend/plugins/domain/cap/plugin.go b/backend/plugins/domain/cap/plugin.go new file mode 100644 index 00000000..ae047a78 --- /dev/null +++ b/backend/plugins/domain/cap/plugin.go @@ -0,0 +1,105 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package cap provides the proof-of-work (PoW) CAPTCHA verification domain plugin for Cordis. +package cap + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "Wavelet/core/extpoints" + "reflect" +) + +// Plugin implements core.Plugin to provide CAPTCHA generation, validation, and route protection. +type Plugin struct{} + +// New creates a new cap domain plugin. +func New() *Plugin { + return &Plugin{} +} + +// Name returns the unique identifier for the cap domain plugin. +func (p *Plugin) Name() string { + return "cap" +} + +// Inject declares required dependencies for the cap domain plugin. +func (p *Plugin) Inject() []reflect.Type { + return []reflect.Type{ + reflect.TypeFor[contracts.DBService](), + } +} + +// Manifest returns the plugin metadata. +func (p *Plugin) Manifest() core.Manifest { + return core.Manifest{ + Name: "cap", + Version: "1.0.0", + Description: "Proof-of-work CAPTCHA challenge and verification domain plugin", + Author: "Wavelet Team", + } +} + +type capAppConfig struct { + SessionSecret string `config:"session_secret" env:"APP_SESSION_SECRET" secret:"true"` +} + +// DeclareConfig declares configuration bindings for the cap plugin. +func (p *Plugin) DeclareConfig() []core.ConfigBinding { + return []core.ConfigBinding{ + {Prefix: "app", Target: &capAppConfig{}}, + } +} + +// Apply registers the cap routes and settings into the Context. +func (p *Plugin) Apply(ctx *core.Context) error { + var cfg capAppConfig + if err := ctx.Config().Bind("app", &cfg); err == nil && cfg.SessionSecret != "" { + SetSecret([]byte(cfg.SessionSecret)) + } + + // 0. Bind DBService from Context + if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil { + setDBService(db) + } else { + core.When[contracts.DBService](ctx, func(db contracts.DBService) { + setDBService(db) + }) + } + ctx.OnDispose(func() error { + setDBService(nil) + return nil + }) + + // Listen to system config changed events to invalidate cached settings + ctx.Events().On(contracts.EventTopicConfigChanged, func(_ any) { + InvalidateRuntimeSettings() + }) + + // Register HTTP Routes + capGroup := ctx.Router().Group("/api/v1/cap") + { + capGroup.GET("/challenge", Challenge) + capGroup.POST("/challenge", Challenge) + capGroup.POST("/redeem", Redeem) + } + + // Register Settings Schemas + ctx.Settings().Register(extpoints.SettingSchema{ + Key: "cap.login_enabled", + Default: false, + Description: "Whether to require CAPTCHA verification for user login", + Type: "boolean", + Category: "security", + }) + ctx.Settings().Register(extpoints.SettingSchema{ + Key: "cap.challenge_count", + Default: 1, + Description: "Number of PoW puzzle challenges to solve", + Type: "integer", + Category: "security", + }) + + return nil +} diff --git a/backend/plugins/domain/cap/pow/cap.go b/backend/plugins/domain/cap/pow/cap.go new file mode 100644 index 00000000..dc57355c --- /dev/null +++ b/backend/plugins/domain/cap/pow/cap.go @@ -0,0 +1,259 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package pow provides proof-of-work challenge generation and verification. +package pow + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "strconv" + "strings" + "time" +) + +const ( + jwtHeaderB64 = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + jwtPartsCount = 3 // JWT 三段结构 + defaultChallengeCount = 50 // 默认 PoW 难题数 + defaultChallengeSize = 32 // 默认盐值长度 + defaultDifficulty = 4 // 默认难度 + defaultNonceLength = 25 // 随机 Nonce 字节长度 + defaultExpires = 10 * time.Minute // 默认过期时间 +) + +// ChallengeConfig holds parameters for the PoW challenge +type ChallengeConfig struct { + Count int // Number of puzzles (c) + Size int // Salt length (s) + Difficulty int // Difficulty prefix length (d) + Expires time.Duration // Challenge TTL +} + +// ChallengeResponse is returned to the client +type ChallengeResponse struct { + Challenge struct { + C int `json:"c"` + S int `json:"s"` + D int `json:"d"` + } `json:"challenge"` + Token string `json:"token"` + Expires int64 `json:"expires"` // ms timestamp +} + +// ChallengePayload represents the signed JWT payload +type ChallengePayload struct { + Nonce string `json:"n"` + Count int `json:"c"` + Size int `json:"s"` + Difficulty int `json:"d"` + Expires int64 `json:"exp"` // ms timestamp + IssuedAt int64 `json:"iat"` // ms timestamp + Scope string `json:"sk,omitempty"` +} + +// RedeemRequest payload sent by client +type RedeemRequest struct { + Token string `json:"token"` + Solutions []int `json:"solutions"` +} + +// RedeemResponse returned to client after verification +type RedeemResponse struct { + Success bool `json:"success"` + Token string `json:"token,omitempty"` + Expires int64 `json:"expires,omitempty"` + Error string `json:"error,omitempty"` +} + +func b64urlEncode(data []byte) string { + return base64.RawURLEncoding.EncodeToString(data) +} + +func b64urlDecode(str string) ([]byte, error) { + return base64.RawURLEncoding.DecodeString(str) +} + +// RandomHex generates a cryptographically secure random hexadecimal string of the specified byte length. +func RandomHex(byteLen int) string { + bytes := make([]byte, byteLen) + if _, err := rand.Read(bytes); err != nil { + panic(err) + } + return hex.EncodeToString(bytes) +} + +func jwtSign(payload, secret []byte) string { + body := b64urlEncode(payload) + sigInput := jwtHeaderB64 + "." + body + + mac := hmac.New(sha256.New, secret) + mac.Write([]byte(sigInput)) + sig := mac.Sum(nil) + + return sigInput + "." + b64urlEncode(sig) +} + +func jwtVerify(token string, secret []byte) ([]byte, error) { + parts := strings.Split(token, ".") + if len(parts) != jwtPartsCount { + return nil, errors.New(errInvalidTokenFormat) + } + if parts[0] != jwtHeaderB64 { + return nil, errors.New(errInvalidHeader) + } + + sigInput := parts[0] + "." + parts[1] + mac := hmac.New(sha256.New, secret) + mac.Write([]byte(sigInput)) + expectedSig := mac.Sum(nil) + + actualSig, err := b64urlDecode(parts[2]) + if err != nil { + return nil, err + } + + if !hmac.Equal(expectedSig, actualSig) { + return nil, errors.New(errSignatureMismatch) + } + + payload, err := b64urlDecode(parts[1]) + if err != nil { + return nil, err + } + + return payload, nil +} + +// JwtSigHex extracts the signature part of a JWT token and returns it as a hexadecimal string. +func JwtSigHex(token string) string { + parts := strings.Split(token, ".") + if len(parts) != jwtPartsCount { + return "" + } + sigBytes, err := b64urlDecode(parts[2]) + if err != nil { + return "" + } + return hex.EncodeToString(sigBytes) +} + +// GenerateChallenge produces a new challenge and signed token +func GenerateChallenge(secret []byte, conf ChallengeConfig, scope string) (*ChallengeResponse, error) { + if conf.Count <= 0 { + conf.Count = defaultChallengeCount + } + if conf.Size <= 0 { + conf.Size = defaultChallengeSize + } + if conf.Difficulty <= 0 { + conf.Difficulty = defaultDifficulty + } + if conf.Expires <= 0 { + conf.Expires = defaultExpires + } + + now := time.Now().UnixNano() / int64(time.Millisecond) + expires := now + int64(conf.Expires/time.Millisecond) + + payload := ChallengePayload{ + Nonce: RandomHex(defaultNonceLength), + Count: conf.Count, + Size: conf.Size, + Difficulty: conf.Difficulty, + Expires: expires, + IssuedAt: now, + Scope: scope, + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + token := jwtSign(payloadBytes, secret) + + resp := &ChallengeResponse{ + Token: token, + Expires: expires, + } + resp.Challenge.C = conf.Count + resp.Challenge.S = conf.Size + resp.Challenge.D = conf.Difficulty + + return resp, nil +} + +// VerifyChallengeSolutions verifies client submitted solutions +func VerifyChallengeSolutions(token string, solutions []int, secret []byte, expectedScope string) (*ChallengePayload, error) { + payloadBytes, err := jwtVerify(token, secret) + if err != nil { + return nil, errors.New(errInvalidToken) + } + + var payload ChallengePayload + if err := json.Unmarshal(payloadBytes, &payload); err != nil { + return nil, errors.New(errInvalidToken) + } + + if expectedScope != "" && payload.Scope != expectedScope { + return nil, errors.New(errScopeMismatch) + } + + now := time.Now().UnixNano() / int64(time.Millisecond) + if payload.Expires < now { + return nil, errors.New(errExpired) + } + + if len(solutions) != payload.Count { + return nil, errors.New(errInvalidSolutions) + } + + tokenFnv := fnv1a(token) + for i := 0; i < payload.Count; i++ { + idxStr := strconv.Itoa(i + 1) + saltSeed := fnv1aResume(tokenFnv, idxStr) + targetSeed := fnv1aResume(saltSeed, "d") + salt := prngFromHash(saltSeed, payload.Size) + target := prngFromHash(targetSeed, payload.Difficulty) + + hashInput := salt + strconv.Itoa(solutions[i]) + hashBytes := sha256.Sum256([]byte(hashInput)) + hashHex := hex.EncodeToString(hashBytes[:]) + + if !strings.HasPrefix(hashHex, target) { + return nil, errors.New(errInvalidSolution) + } + } + + return &payload, nil +} + +// Solve is a utility function to solve a challenge (mainly used for tests and reference implementation) +func Solve(token string, count, size, difficulty int) []int { + solutions := make([]int, count) + tokenFnv := fnv1a(token) + for i := 0; i < count; i++ { + idxStr := strconv.Itoa(i + 1) + saltSeed := fnv1aResume(tokenFnv, idxStr) + targetSeed := fnv1aResume(saltSeed, "d") + salt := prngFromHash(saltSeed, size) + target := prngFromHash(targetSeed, difficulty) + + for nonce := 0; nonce < 1000000; nonce++ { + hashInput := salt + strconv.Itoa(nonce) + hashBytes := sha256.Sum256([]byte(hashInput)) + hashHex := hex.EncodeToString(hashBytes[:]) + if strings.HasPrefix(hashHex, target) { + solutions[i] = nonce + break + } + } + } + return solutions +} diff --git a/backend/plugins/domain/cap/pow/errs.go b/backend/plugins/domain/cap/pow/errs.go new file mode 100644 index 00000000..3433e42b --- /dev/null +++ b/backend/plugins/domain/cap/pow/errs.go @@ -0,0 +1,15 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package pow + +const ( + errInvalidTokenFormat = "invalid token format" + errInvalidHeader = "invalid header" + errSignatureMismatch = "signature mismatch" + errInvalidToken = "invalid_token" + errScopeMismatch = "scope_mismatch" + errExpired = "expired" + errInvalidSolutions = "invalid_solutions" + errInvalidSolution = "invalid_solution" +) diff --git a/backend/plugins/domain/cap/pow/pow_test.go b/backend/plugins/domain/cap/pow/pow_test.go new file mode 100644 index 00000000..1277c12b --- /dev/null +++ b/backend/plugins/domain/cap/pow/pow_test.go @@ -0,0 +1,111 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package pow + +import ( + "context" + "testing" + "time" +) + +func TestPowChallengeFlow(t *testing.T) { + secret := []byte("test-secret-key-1234567890123456") + conf := ChallengeConfig{ + Count: 2, + Size: 16, + Difficulty: 1, + Expires: 1 * time.Minute, + } + scope := "login" + + resp, err := GenerateChallenge(secret, conf, scope) + if err != nil { + t.Fatalf("GenerateChallenge failed: %v", err) + } + if resp.Token == "" { + t.Fatal("expected non-empty token") + } + if resp.Challenge.C != 2 { + t.Fatalf("expected count 2, got %d", resp.Challenge.C) + } + + sigHex := JwtSigHex(resp.Token) + if sigHex == "" { + t.Fatal("expected non-empty sigHex") + } + + solutions := Solve(resp.Token, resp.Challenge.C, resp.Challenge.S, resp.Challenge.D) + if len(solutions) != 2 { + t.Fatalf("expected 2 solutions, got %d", len(solutions)) + } + + payload, err := VerifyChallengeSolutions(resp.Token, solutions, secret, scope) + if err != nil { + t.Fatalf("VerifyChallengeSolutions failed: %v", err) + } + if payload.Scope != scope { + t.Fatalf("expected scope %s, got %s", scope, payload.Scope) + } + + // Scope mismatch test + _, err = VerifyChallengeSolutions(resp.Token, solutions, secret, "other_scope") + if err == nil { + t.Fatal("expected scope mismatch error") + } + + // Invalid solutions test + _, err = VerifyChallengeSolutions(resp.Token, []int{9999999, 9999999}, secret, scope) + if err == nil { + t.Fatal("expected invalid solution error") + } + + // Invalid token test + _, err = VerifyChallengeSolutions("invalid.jwt.token", solutions, secret, scope) + if err == nil { + t.Fatal("expected invalid token error") + } +} + +func TestMemoryStore(t *testing.T) { + ctx := context.Background() + store := NewMemoryStore(100 * time.Millisecond) + + // Set and Get + err := store.Set(ctx, "k1", "v1", 200*time.Millisecond) + if err != nil { + t.Fatalf("Set failed: %v", err) + } + val, ok, err := store.Get(ctx, "k1") + if err != nil || !ok || val != "v1" { + t.Fatalf("Get failed: val=%s, ok=%v, err=%v", val, ok, err) + } + + // SetNX + set, err := store.SetNX(ctx, "k1", "v2", 200*time.Millisecond) + if err != nil || set { + t.Fatalf("SetNX should have failed because key exists: set=%v, err=%v", set, err) + } + + set, err = store.SetNX(ctx, "k2", "v2", 200*time.Millisecond) + if err != nil || !set { + t.Fatalf("SetNX should have succeeded: set=%v, err=%v", set, err) + } + + // GetAndDelete + val, ok, err = store.GetAndDelete(ctx, "k2") + if err != nil || !ok || val != "v2" { + t.Fatalf("GetAndDelete failed: val=%s, ok=%v, err=%v", val, ok, err) + } + _, ok, _ = store.Get(ctx, "k2") + if ok { + t.Fatal("k2 should be deleted") + } + + // Delete + _ = store.Delete(ctx, "k1") + _, ok, _ = store.Get(ctx, "k1") + if ok { + t.Fatal("k1 should be deleted") + } +} diff --git a/backend/plugins/domain/cap/pow/prng.go b/backend/plugins/domain/cap/pow/prng.go new file mode 100644 index 00000000..5f8e424a --- /dev/null +++ b/backend/plugins/domain/cap/pow/prng.go @@ -0,0 +1,54 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package pow + +import ( + "fmt" + "strings" +) + +// fnv1a returns the 32-bit FNV-1a hash of a string +// +// FNV-1a 算法位移常量 +func fnv1a(str string) uint32 { + var hash uint32 = 2166136261 + for i := 0; i < len(str); i++ { + hash ^= uint32(str[i]) + hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24) + } + return hash +} + +// fnv1aResume resumes FNV-1a hashing from a given state +// +// FNV-1a 算法位移常量 +func fnv1aResume(state uint32, str string) uint32 { + h := state + for i := 0; i < len(str); i++ { + h ^= uint32(str[i]) + h += (hashShift(h)) + } + return h +} + +// hashShift computes FNV-1a mix additions +func hashShift(h uint32) uint32 { + return (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24) +} + +// prngFromHash generates a hex string of specified length using an initial hash state +// +// xorshift 算法位移常量 +func prngFromHash(initialHash uint32, length int) string { + state := initialHash + var result strings.Builder + for result.Len() < length { + state ^= state << 13 + state ^= state >> 17 + state ^= state << 5 + hexStr := fmt.Sprintf("%08x", state) + result.WriteString(hexStr) + } + return result.String()[:length] +} diff --git a/backend/plugins/domain/cap/pow/store.go b/backend/plugins/domain/cap/pow/store.go new file mode 100644 index 00000000..4606b546 --- /dev/null +++ b/backend/plugins/domain/cap/pow/store.go @@ -0,0 +1,188 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package pow + +import ( + "Wavelet/pkg/util" + "context" + "errors" + "sync" + "time" + + "github.com/redis/go-redis/v9" +) + +// Store defines the storage interface for challenge nonces and verification tokens +type Store interface { + Get(ctx context.Context, key string) (string, bool, error) + Set(ctx context.Context, key, val string, ttl time.Duration) error + Delete(ctx context.Context, key string) error + // SetNX atomically sets key=val with the given TTL only when the key does not + // exist yet. It returns true when the key was actually written (i.e. this + // caller "won" the race), and false when the key already existed. + SetNX(ctx context.Context, key, val string, ttl time.Duration) (bool, error) + // GetAndDelete atomically retrieves the value of key and removes it in a + // single operation. Returns ("", false, nil) when the key does not exist. + GetAndDelete(ctx context.Context, key string) (string, bool, error) +} + +type memoryItem struct { + value string + expiresAt time.Time +} + +// MemoryStore is a thread-safe in-memory implementation of Store +type MemoryStore struct { + items map[string]memoryItem + mu sync.Mutex // unified write-lock; promotes to exclusive for all ops +} + +// NewMemoryStore creates and initializes a new MemoryStore +func NewMemoryStore(cleanupInterval time.Duration) *MemoryStore { + store := &MemoryStore{ + items: make(map[string]memoryItem), + } + if cleanupInterval > 0 { + util.Go(func() { store.startCleanupLoop(cleanupInterval) }) + } + return store +} + +// Get 从 MemoryStore 获取指定 key 的值 +func (s *MemoryStore) Get(_ context.Context, key string) (string, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.getLocked(key) +} + +// getLocked is the internal helper – caller must hold s.mu. +func (s *MemoryStore) getLocked(key string) (string, bool, error) { + item, found := s.items[key] + if !found { + return "", false, nil + } + if time.Now().After(item.expiresAt) { + delete(s.items, key) + return "", false, nil + } + return item.value, true, nil +} + +// Set 向 MemoryStore 写入指定 key 的值 +func (s *MemoryStore) Set(_ context.Context, key, val string, ttl time.Duration) error { + s.mu.Lock() + defer s.mu.Unlock() + s.items[key] = memoryItem{ + value: val, + expiresAt: time.Now().Add(ttl), + } + return nil +} + +// Delete 从 MemoryStore 删除指定 key +func (s *MemoryStore) Delete(_ context.Context, key string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.items, key) + return nil +} + +// SetNX atomically sets key only when it is absent (or expired). +// Returns true if the key was written by this call. +func (s *MemoryStore) SetNX(_ context.Context, key, val string, ttl time.Duration) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + + _, exists, _ := s.getLocked(key) + if exists { + return false, nil + } + s.items[key] = memoryItem{ + value: val, + expiresAt: time.Now().Add(ttl), + } + return true, nil +} + +// GetAndDelete atomically retrieves and removes key in one critical section. +func (s *MemoryStore) GetAndDelete(_ context.Context, key string) (string, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + + val, exists, err := s.getLocked(key) + if err != nil || !exists { + return "", false, err + } + delete(s.items, key) + return val, true, nil +} + +func (s *MemoryStore) startCleanupLoop(interval time.Duration) { + ticker := time.NewTicker(interval) + for range ticker.C { + s.cleanupExpired() + } +} + +func (s *MemoryStore) cleanupExpired() { + now := time.Now() + s.mu.Lock() + defer s.mu.Unlock() + for k, v := range s.items { + if now.After(v.expiresAt) { + delete(s.items, k) + } + } +} + +// RedisStore is a GORM-compatible/standalone Redis-backed implementation of Store +type RedisStore struct { + client redis.UniversalClient +} + +// NewRedisStore creates a new RedisStore wrapping a redis.UniversalClient +func NewRedisStore(client redis.UniversalClient) *RedisStore { + return &RedisStore{ + client: client, + } +} + +// Get 从 RedisStore 获取指定 key 的值 +func (s *RedisStore) Get(ctx context.Context, key string) (string, bool, error) { + val, err := s.client.Get(ctx, key).Result() + if errors.Is(err, redis.Nil) { + return "", false, nil + } + if err != nil { + return "", false, err + } + return val, true, nil +} + +// Set 向 RedisStore 写入指定 key 的值 +func (s *RedisStore) Set(ctx context.Context, key, val string, ttl time.Duration) error { + return s.client.Set(ctx, key, val, ttl).Err() +} + +// Delete 从 RedisStore 删除指定 key +func (s *RedisStore) Delete(ctx context.Context, key string) error { + return s.client.Del(ctx, key).Err() +} + +// SetNX wraps Redis SET NX – returns true only when the key was newly created. +func (s *RedisStore) SetNX(ctx context.Context, key, val string, ttl time.Duration) (bool, error) { + return s.client.SetNX(ctx, key, val, ttl).Result() +} + +// GetAndDelete wraps Redis GETDEL (available since Redis 6.2). +func (s *RedisStore) GetAndDelete(ctx context.Context, key string) (string, bool, error) { + val, err := s.client.GetDel(ctx, key).Result() + if errors.Is(err, redis.Nil) { + return "", false, nil + } + if err != nil { + return "", false, err + } + return val, true, nil +} diff --git a/backend/plugins/domain/cap/repository.go b/backend/plugins/domain/cap/repository.go new file mode 100644 index 00000000..4caa0040 --- /dev/null +++ b/backend/plugins/domain/cap/repository.go @@ -0,0 +1,58 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package cap + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "context" + "sync" + + "gorm.io/gorm" +) + +var ( + dbMu sync.RWMutex + dbSvc contracts.DBService +) + +// setDBService caches the DBService contract used by the persistence layer. +func setDBService(s contracts.DBService) { + dbMu.Lock() + defer dbMu.Unlock() + dbSvc = s +} + +// getDB resolves a GORM handle, preferring the *core.Context when supplied by callers. +func getDB(ctx context.Context) *gorm.DB { + if c, ok := ctx.(*core.Context); ok && c != nil { + if s, err := core.Inject[contracts.DBService](c); err == nil && s != nil { + return s.DB(ctx) + } + } + dbMu.RLock() + s := dbSvc + dbMu.RUnlock() + if s != nil { + return s.DB(ctx) + } + return nil +} + +// loadRuntimeSettings reads the CAPTCHA owned rows from the system config table. +func loadRuntimeSettings(ctx context.Context) (RuntimeSettings, error) { + var records []configRecord + db := getDB(ctx) + if db == nil { + return parseRuntimeSettings(nil), nil + } + if err := db.Table("w_system_configs").Where("key IN ?", runtimeConfigKeys).Find(&records).Error; err != nil { + return RuntimeSettings{}, err + } + configs := make(map[string]string, len(records)) + for _, r := range records { + configs[r.Key] = r.Value + } + return parseRuntimeSettings(configs), nil +} diff --git a/backend/plugins/domain/cap/runtime_settings.go b/backend/plugins/domain/cap/runtime_settings.go new file mode 100644 index 00000000..ebb6fd3e --- /dev/null +++ b/backend/plugins/domain/cap/runtime_settings.go @@ -0,0 +1,185 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package cap + +import ( + "context" + "errors" + "strconv" + "sync/atomic" + "time" + + "golang.org/x/sync/singleflight" +) + +const ( + defaultChallengeCount = 1 + defaultChallengeSize = 32 + defaultChallengeDifficulty = 4 + defaultChallengeTTL = 10 * time.Minute + defaultTokenTTL = 20 * time.Minute +) + +// RuntimeSettings is the parsed CAPTCHA runtime configuration loaded from system_configs. +type RuntimeSettings struct { + LoginEnabled bool + ChallengeCount int + ChallengeSize int + ChallengeDifficulty int + ChallengeTTL time.Duration + TokenTTL time.Duration +} + +// CAP 动态配置键常量 +const ( + ConfigKeyCapLoginEnabled = "cap_login_enabled" + ConfigKeyCapChallengeCount = "cap_challenge_count" + ConfigKeyCapChallengeSize = "cap_challenge_size" + ConfigKeyCapChallengeDifficulty = "cap_challenge_difficulty" + ConfigKeyCapChallengeTTL = "cap_challenge_ttl" + // ConfigKeyCapTokenTTL 验证码 Token 过期时间键 + // #nosec G101 + ConfigKeyCapTokenTTL = "cap_token_ttl" +) + +var runtimeConfigKeys = []string{ + ConfigKeyCapLoginEnabled, + ConfigKeyCapChallengeCount, + ConfigKeyCapChallengeSize, + ConfigKeyCapChallengeDifficulty, + ConfigKeyCapChallengeTTL, + ConfigKeyCapTokenTTL, +} + +var runtimeConfigKeySet = func() map[string]struct{} { + set := make(map[string]struct{}, len(runtimeConfigKeys)) + for _, key := range runtimeConfigKeys { + set[key] = struct{}{} + } + return set +}() + +type runtimeSettingsStore struct { + snapshot atomic.Pointer[RuntimeSettings] + loadGroup singleflight.Group +} + +var settingsStore = &runtimeSettingsStore{} + +// IsRuntimeConfigKey reports whether a system config key affects CAPTCHA runtime settings. +func IsRuntimeConfigKey(key string) bool { + _, ok := runtimeConfigKeySet[key] + return ok +} + +// CurrentSettings returns the cached CAPTCHA runtime settings snapshot. +func CurrentSettings(ctx context.Context) (RuntimeSettings, error) { + return settingsStore.current(ctx) +} + +// ProtectionEnabled reports whether CAPTCHA verification is required for protected routes. +func ProtectionEnabled(ctx context.Context) bool { + settings, err := CurrentSettings(ctx) + if err != nil { + return false + } + return settings.LoginEnabled +} + +// InvalidateRuntimeSettings drops the in-process CAPTCHA settings snapshot. +func InvalidateRuntimeSettings() { + settingsStore.snapshot.Store(nil) +} + +// ResetRuntimeSettingsForTest clears the CAPTCHA runtime snapshot. +func ResetRuntimeSettingsForTest() { + InvalidateRuntimeSettings() +} + +// InstallTestRuntimeSettings installs a fixed snapshot for unit tests. +func InstallTestRuntimeSettings(settings RuntimeSettings) func() { + snapshot := settings + settingsStore.snapshot.Store(&snapshot) + return InvalidateRuntimeSettings +} + +func (s *runtimeSettingsStore) current(ctx context.Context) (RuntimeSettings, error) { + s.ensureInvalidationListener() + + if snapshot := s.snapshot.Load(); snapshot != nil { + return *snapshot, nil + } + + loaded, err, _ := s.loadGroup.Do("cap-runtime-settings", func() (any, error) { + if snapshot := s.snapshot.Load(); snapshot != nil { + return *snapshot, nil + } + + settings, loadErr := loadRuntimeSettings(ctx) + if loadErr != nil { + return RuntimeSettings{}, loadErr + } + + s.snapshot.Store(&settings) + return settings, nil + }) + if err != nil { + return RuntimeSettings{}, err + } + + settings, ok := loaded.(RuntimeSettings) + if !ok { + return RuntimeSettings{}, errors.New("cap runtime settings loader returned unexpected type") + } + return settings, nil +} + +func parseRuntimeSettings(configs map[string]string) RuntimeSettings { + settings := RuntimeSettings{ + ChallengeCount: defaultChallengeCount, + ChallengeSize: defaultChallengeSize, + ChallengeDifficulty: defaultChallengeDifficulty, + ChallengeTTL: defaultChallengeTTL, + TokenTTL: defaultTokenTTL, + } + + if len(configs) == 0 { + return settings + } + + if val, ok := configs[ConfigKeyCapLoginEnabled]; ok { + if enabled, err := strconv.ParseBool(val); err == nil { + settings.LoginEnabled = enabled + } + } + if val, ok := configs[ConfigKeyCapChallengeCount]; ok { + if count, err := strconv.Atoi(val); err == nil && count > 0 { + settings.ChallengeCount = count + } + } + if val, ok := configs[ConfigKeyCapChallengeSize]; ok { + if size, err := strconv.Atoi(val); err == nil && size > 0 { + settings.ChallengeSize = size + } + } + if val, ok := configs[ConfigKeyCapChallengeDifficulty]; ok { + if diff, err := strconv.Atoi(val); err == nil && diff > 0 { + settings.ChallengeDifficulty = diff + } + } + if val, ok := configs[ConfigKeyCapChallengeTTL]; ok { + if ttlSeconds, err := strconv.Atoi(val); err == nil && ttlSeconds > 0 { + settings.ChallengeTTL = time.Duration(ttlSeconds) * time.Second + } + } + if val, ok := configs[ConfigKeyCapTokenTTL]; ok { + if ttlSeconds, err := strconv.Atoi(val); err == nil && ttlSeconds > 0 { + settings.TokenTTL = time.Duration(ttlSeconds) * time.Second + } + } + + return settings +} + +func (s *runtimeSettingsStore) ensureInvalidationListener() {} diff --git a/backend/plugins/domain/cap/service.go b/backend/plugins/domain/cap/service.go new file mode 100644 index 00000000..b062fffe --- /dev/null +++ b/backend/plugins/domain/cap/service.go @@ -0,0 +1,182 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package cap provides CAPTCHA and proof-of-work (PoW) verification services. +package cap + +import ( + "Wavelet/plugins/domain/cap/pow" + "context" + "crypto/sha256" + "encoding/hex" + "strconv" + "strings" + "sync" + "time" +) + +const ( + redeemTokenIDLength = 8 // 兑换 Token ID 字节长度 + redeemVerTokenLength = 15 // 兑换验证 Token 字节长度 + tokenPartsCount = 2 // 兑换 Token 由两部分组成 + valuePartsCount = 2 // 存储值由 scope 和过期时间组成 +) + +// Manager orchestrates challenge generation and solution validation. +type Manager struct { + secret []byte + store pow.Store +} + +// NewManager creates a new CAPTCHA Manager. +func NewManager(secret []byte, store pow.Store) *Manager { + return &Manager{ + secret: secret, + store: store, + } +} + +// Generate creates a challenge response. +func (m *Manager) Generate(ctx context.Context, scope string) (*pow.ChallengeResponse, error) { + settings, err := CurrentSettings(ctx) + if err != nil { + return nil, err + } + + challengeConfig := pow.ChallengeConfig{ + Count: settings.ChallengeCount, + Size: settings.ChallengeSize, + Difficulty: settings.ChallengeDifficulty, + Expires: settings.ChallengeTTL, + } + return pow.GenerateChallenge(m.secret, challengeConfig, scope) +} + +// Redeem verifies PoW solutions and returns a one-time redeem token. +func (m *Manager) Redeem(ctx context.Context, token string, solutions []int, scope string) (*RedeemResponse, error) { + sigHex := pow.JwtSigHex(token) + if sigHex == "" { + return &RedeemResponse{Success: false, Error: redeemErrInvalidToken}, nil + } + + nonceKey := "cap:nonce:" + sigHex + + payload, err := pow.VerifyChallengeSolutions(token, solutions, m.secret, scope) + if err != nil { + return &RedeemResponse{Success: false, Error: err.Error()}, nil //nolint:nilerr // validation errors are returned as response, not system errors + } + + now := time.Now().UnixNano() / int64(time.Millisecond) + nonceTTL := time.Duration(payload.Expires-now) * time.Millisecond + if nonceTTL < time.Second { + nonceTTL = time.Second + } + + set, err := m.store.SetNX(ctx, nonceKey, "1", nonceTTL) + if err != nil { + return &RedeemResponse{Success: false, Error: redeemErrNonceStoreFailed}, err + } + if !set { + return &RedeemResponse{Success: false, Error: redeemErrAlreadyRedeemed}, nil + } + + settings, err := CurrentSettings(ctx) + if err != nil { + return &RedeemResponse{Success: false, Error: redeemErrSettingsLoad}, err + } + + id := pow.RandomHex(redeemTokenIDLength) + verToken := pow.RandomHex(redeemVerTokenLength) + verHashBytes := sha256.Sum256([]byte(verToken)) + verHashHex := hex.EncodeToString(verHashBytes[:]) + + tokenKey := "cap:token:" + id + ":" + verHashHex + tokenExpires := time.Now().Add(settings.TokenTTL) + storeVal := strconv.FormatInt(tokenExpires.UnixNano(), 10) + "|" + scope + + if err := m.store.Set(ctx, tokenKey, storeVal, settings.TokenTTL); err != nil { + return &RedeemResponse{Success: false, Error: redeemErrTokenStoreFailed}, err + } + + return &RedeemResponse{ + Success: true, + Token: id + ":" + verToken, + Expires: tokenExpires.UnixNano() / int64(time.Millisecond), + }, nil +} + +// VerifyToken validates and consumes the redeem token (single-use). +func (m *Manager) VerifyToken(ctx context.Context, token, expectedScope string) (bool, error) { + if token == "" { + return false, nil + } + parts := strings.Split(token, ":") + if len(parts) != tokenPartsCount { + return false, nil + } + id := parts[0] + verToken := parts[1] + + verHashBytes := sha256.Sum256([]byte(verToken)) + verHashHex := hex.EncodeToString(verHashBytes[:]) + + tokenKey := "cap:token:" + id + ":" + verHashHex + + val, exists, err := sGetAndDelete(ctx, m.store, tokenKey) + if err != nil { + return false, err + } + if !exists { + return false, nil + } + + valParts := strings.Split(val, "|") + if len(valParts) != valuePartsCount { + return false, nil + } + + expNano, err := strconv.ParseInt(valParts[0], 10, 64) + if err != nil { + return false, nil //nolint:nilerr // invalid format is treated as validation failure + } + tokenScope := valParts[1] + + if expectedScope != "" && tokenScope != expectedScope { + return false, nil + } + + if time.Now().UnixNano() > expNano { + return false, nil + } + + return true, nil +} + +func sGetAndDelete(ctx context.Context, store pow.Store, key string) (string, bool, error) { + if store == nil { + return "", false, nil + } + return store.GetAndDelete(ctx, key) +} + +var ( + defaultManagerMu sync.RWMutex + defaultManager *Manager +) + +// SetSecret sets the shared secret used by the default manager. +func SetSecret(secret []byte) { + defaultManagerMu.Lock() + defer defaultManagerMu.Unlock() + if len(secret) > 0 { + store := pow.NewMemoryStore(1 * time.Minute) + defaultManager = NewManager(secret, store) + } +} + +// GetDefaultManager yields the global singleton CAPTCHA manager. +func GetDefaultManager() *Manager { + defaultManagerMu.RLock() + defer defaultManagerMu.RUnlock() + return defaultManager +} diff --git a/backend/plugins/domain/domain_test.go b/backend/plugins/domain/domain_test.go new file mode 100644 index 00000000..9f3f1b6a --- /dev/null +++ b/backend/plugins/domain/domain_test.go @@ -0,0 +1,474 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package domain_test + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "Wavelet/pkg/idgen" + "Wavelet/plugins/domain/admin" + "Wavelet/plugins/domain/auth" + "Wavelet/plugins/domain/message_gateway" + "Wavelet/plugins/domain/risk_control" + "Wavelet/plugins/domain/user" + "Wavelet/plugins/infra/cache" + "Wavelet/plugins/infra/logger" + "Wavelet/plugins/infra/storage" + "context" + "io/fs" + "path/filepath" + "testing" + + "github.com/alicebob/miniredis/v2" + "github.com/glebarez/sqlite" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + + db "Wavelet/plugins/infra/database" +) + +func setupTestDB(t *testing.T) *gorm.DB { + t.Helper() + _ = idgen.Init(1) + dbPath := filepath.Join(t.TempDir(), "domain_test.db") + testDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + require.NoError(t, err) + + require.NoError(t, testDB.AutoMigrate( + &user.User{}, + &user.AccessToken{}, + &auth.AuthSource{}, + &auth.ExternalAccount{}, + &message_gateway.MessageChannel{}, + &message_gateway.MessageBinding{}, + &message_gateway.MessagePairingCode{}, + &admin.SystemConfig{}, + &message_gateway.PushChannel{}, + &message_gateway.PushEvent{}, + &message_gateway.PushHistory{}, + )) + + db.SetDB(testDB) + return testDB +} + +type mockOAuthProvider struct { + name string +} + +func (m *mockOAuthProvider) Name() string { + return m.name +} + +func (m *mockOAuthProvider) GetAuthURL(state string) string { + return "https://auth.example.com/auth?state=" + state +} + +func (m *mockOAuthProvider) ExchangeCode(ctx context.Context, code string) (*contracts.OAuthUserInfoDTO, error) { + return &contracts.OAuthUserInfoDTO{ + ID: 1001, + Username: "mock_user", + Email: "mock@example.com", + Active: true, + }, nil +} + +func TestAuthPlugin(t *testing.T) { + ctx := core.NewContext(context.Background()) + ctx.Config().SetSource(core.NewMapSource(nil)) + require.NoError(t, ctx.Config().Resolve()) + testDB := setupTestDB(t) + + require.NoError(t, db.New(db.WithDB(testDB)).Apply(ctx)) + require.NoError(t, cache.New().Apply(ctx)) + require.NoError(t, logger.New().Apply(ctx)) + + p := auth.New() + assert.Equal(t, "auth", p.Name()) + assert.Equal(t, "auth", p.Manifest().Name) + require.NoError(t, p.Apply(ctx)) + + // 1. Verify migrations registered + entry, ok := ctx.Migrations().Get("auth") + require.True(t, ok) + assert.Equal(t, "auth", entry.PluginID) + entries, err := fs.ReadDir(entry.FS, entry.Dir) + require.NoError(t, err) + assert.NotEmpty(t, entries) + + // 2. Verify AuthService + authSvc, err := core.Inject[contracts.AuthService](ctx) + require.NoError(t, err) + require.NotNil(t, authSvc) + assert.NotNil(t, authSvc.RequireAuthMiddleware()) + assert.NotNil(t, authSvc.RequireAdminMiddleware()) + + // 3. Verify AuthRegistry + authReg, err := core.Inject[contracts.AuthRegistry](ctx) + require.NoError(t, err) + require.NotNil(t, authReg) + + mockProv := &mockOAuthProvider{name: "github"} + authReg.RegisterOAuthProvider("github", mockProv) + retrieved, ok := authReg.GetOAuthProvider("github") + require.True(t, ok) + assert.Equal(t, "github", retrieved.Name()) + assert.Contains(t, authReg.ListOAuthProviders(), "github") + + // 4. Verify Routes + routes := ctx.Router().Routes() + var hasSources, hasLogin, hasUserInfo bool + for _, r := range routes { + if r.Path == "/api/v1/oauth/sources" { + hasSources = true + } + if r.Path == "/api/v1/oauth/login" { + hasLogin = true + } + if r.Path == "/api/v1/user-info" { + hasUserInfo = true + } + } + assert.True(t, hasSources) + assert.True(t, hasLogin) + assert.True(t, hasUserInfo) + + // 5. Verify Settings + schema, ok := ctx.Settings().Get("auth.session_age") + require.True(t, ok) + assert.Equal(t, 86400*7, schema.Default) +} + +func TestUserPlugin(t *testing.T) { + ctx := core.NewContext(context.Background()) + ctx.Config().SetSource(core.NewMapSource(nil)) + require.NoError(t, ctx.Config().Resolve()) + testDB := setupTestDB(t) + + require.NoError(t, db.New(db.WithDB(testDB)).Apply(ctx)) + require.NoError(t, cache.New().Apply(ctx)) + require.NoError(t, logger.New().Apply(ctx)) + require.NoError(t, auth.New().Apply(ctx)) + + p := user.New() + assert.Equal(t, "user", p.Name()) + assert.Equal(t, "user", p.Manifest().Name) + require.NoError(t, p.Apply(ctx)) + + // 1. Verify migrations + entry, ok := ctx.Migrations().Get("user") + require.True(t, ok) + assert.Equal(t, "user", entry.PluginID) + + // 2. Verify UserService + userSvc, err := core.Inject[contracts.UserService](ctx) + require.NoError(t, err) + require.NotNil(t, userSvc) + + testCtx := context.Background() + + // 3. Create user + created, err := userSvc.CreateUser(testCtx, contracts.CreateUserRequest{ + Username: "bob", + Password: "SecurePassword123!", + Nickname: "Bob Builder", + Email: "bob@example.com", + IsAdmin: false, + }) + require.NoError(t, err) + require.NotNil(t, created) + assert.Equal(t, "bob", created.Username) + assert.Equal(t, "Bob Builder", created.Nickname) + assert.Equal(t, "bob@example.com", created.Email) + assert.False(t, created.IsAdmin) + + // 4. Query user + byID, err := userSvc.GetUserByID(testCtx, created.ID) + require.NoError(t, err) + assert.Equal(t, "bob", byID.Username) + + byUsername, err := userSvc.GetUserByUsername(testCtx, "bob") + require.NoError(t, err) + assert.Equal(t, created.ID, byUsername.ID) + + byEmail, err := userSvc.GetUserByEmail(testCtx, "bob@example.com") + require.NoError(t, err) + assert.Equal(t, created.ID, byEmail.ID) + + // 5. Password verification and update + assert.True(t, userSvc.VerifyPassword(testCtx, created.ID, "SecurePassword123!")) + assert.False(t, userSvc.VerifyPassword(testCtx, created.ID, "WrongPass")) + + require.NoError(t, userSvc.UpdatePassword(testCtx, created.ID, "SecurePassword123!", "NewSecurePassword456!")) + assert.True(t, userSvc.VerifyPassword(testCtx, created.ID, "NewSecurePassword456!")) + + // 6. Update Profile + newBio := "I build things" + newPhone := "13800138000" + updated, err := userSvc.UpdateProfile(testCtx, created.ID, contracts.UpdateUserProfileRequest{ + Bio: &newBio, + Phone: &newPhone, + }) + require.NoError(t, err) + assert.Equal(t, newBio, updated.Bio) + assert.Equal(t, newPhone, updated.Phone) + + // 7. Update Last Login + require.NoError(t, userSvc.UpdateLastLogin(testCtx, created.ID, "127.0.0.1")) + + // 8. Admin operations: SetUserActive, SetUserAdmin, ListUsers + require.NoError(t, userSvc.SetUserAdmin(testCtx, created.ID, true)) + reloaded, err := userSvc.GetUserByID(testCtx, created.ID) + require.NoError(t, err) + assert.True(t, reloaded.IsAdmin) + + require.NoError(t, userSvc.SetUserActive(testCtx, created.ID, false)) + reloadedBanned, err := userSvc.GetUserByID(testCtx, created.ID) + require.NoError(t, err) + assert.False(t, reloadedBanned.IsActive) + + list, total, err := userSvc.ListUsers(testCtx, 1, 10, "bob") + require.NoError(t, err) + assert.Equal(t, int64(1), total) + assert.Len(t, list, 1) + assert.Equal(t, "bob", list[0].Username) + + // 9. Tasks & Schedules + taskDef, ok := ctx.Tasks().Get("user:send_email_code") + require.True(t, ok) + assert.Equal(t, 3, taskDef.Retry) + + // 10. Settings + sReg, ok := ctx.Settings().Get("user.registration_enabled") + require.True(t, ok) + assert.Equal(t, true, sReg.Default) +} + +func TestMessageGatewayPlugin(t *testing.T) { + ctx := core.NewContext(context.Background()) + ctx.Config().SetSource(core.NewMapSource(nil)) + require.NoError(t, ctx.Config().Resolve()) + testDB := setupTestDB(t) + + require.NoError(t, db.New(db.WithDB(testDB)).Apply(ctx)) + require.NoError(t, cache.New().Apply(ctx)) + require.NoError(t, logger.New().Apply(ctx)) + + p := message_gateway.New() + assert.Equal(t, "message_gateway", p.Name()) + assert.Equal(t, "message_gateway", p.Manifest().Name) + require.NoError(t, p.Apply(ctx)) + + // 1. Migrations + entry, ok := ctx.Migrations().Get("message_gateway") + require.True(t, ok) + assert.Equal(t, "message_gateway", entry.PluginID) + + // 2. Routes + routes := ctx.Router().Routes() + var hasChannels, hasBindings bool + for _, r := range routes { + if r.Path == "/api/v1/message-gateway/channels" { + hasChannels = true + } + if r.Path == "/api/v1/message-gateway/bindings" { + hasBindings = true + } + } + assert.True(t, hasChannels) + assert.True(t, hasBindings) + + // 3. Tasks & Schedules + taskDef, ok := ctx.Tasks().Get("message_gateway:push_notification") + require.True(t, ok) + assert.Equal(t, 3, taskDef.Retry) + + schedDef, ok := ctx.Schedules().Get("message_gateway:cleanup_pairing_codes") + require.True(t, ok) + assert.Equal(t, "*/10 * * * *", schedDef.Spec) + + // 4. EventBus Trigger + var receivedEvent message_gateway.PushNotificationEvent + var eventFired bool + ctx.Events().On("notification:push", func(c context.Context, e message_gateway.PushNotificationEvent) error { + eventFired = true + receivedEvent = e + return nil + }) + + err := ctx.Events().Emit(context.Background(), "notification:push", message_gateway.PushNotificationEvent{ + UserID: 99, + Channel: "telegram", + Title: "System Alert", + Content: "Disk 85% full", + }) + require.NoError(t, err) + assert.True(t, eventFired) + assert.Equal(t, uint64(99), receivedEvent.UserID) + assert.Equal(t, "telegram", receivedEvent.Channel) + assert.Equal(t, "System Alert", receivedEvent.Title) + + // 5. Settings + schema, ok := ctx.Settings().Get("message_gateway.pairing_code_expiry_minutes") + require.True(t, ok) + assert.Equal(t, 15, schema.Default) +} + +func TestRiskControlPlugin(t *testing.T) { + ctx := core.NewContext(context.Background()) + ctx.Config().SetSource(core.NewMapSource(nil)) + require.NoError(t, ctx.Config().Resolve()) + p := risk_control.New() + assert.Equal(t, "risk_control", p.Name()) + assert.Equal(t, "risk_control", p.Manifest().Name) + require.NoError(t, p.Apply(ctx)) + + // 1. Middleware registered on Router + mws := ctx.Router().Middlewares() + assert.NotEmpty(t, mws) + + // 2. Settings + schema, ok := ctx.Settings().Get("risk_control.ip_rate_limit_per_minute") + require.True(t, ok) + assert.Equal(t, 60, schema.Default) + + // 3. Disposal cleanup + require.NoError(t, ctx.Dispose()) +} + +func TestAdminPlugin(t *testing.T) { + ctx := core.NewContext(context.Background()) + ctx.Config().SetSource(core.NewMapSource(nil)) + require.NoError(t, ctx.Config().Resolve()) + testDB := setupTestDB(t) + + require.NoError(t, db.New(db.WithDB(testDB)).Apply(ctx)) + require.NoError(t, cache.New().Apply(ctx)) + require.NoError(t, logger.New().Apply(ctx)) + + p := admin.New() + assert.Equal(t, "admin", p.Name()) + assert.Equal(t, "admin", p.Manifest().Name) + require.NoError(t, p.Apply(ctx)) + + // 1. Admin Routes + routes := ctx.Router().Routes() + var hasStatus, hasDBOverview, hasUsers, hasTasks, hasConfigs bool + for _, r := range routes { + if r.Path == "/api/v1/admin/status" { + hasStatus = true + } + if r.Path == "/api/v1/admin/db-manage/overview" { + hasDBOverview = true + } + if r.Path == "/api/v1/admin/users" { + hasUsers = true + } + if r.Path == "/api/v1/admin/tasks/types" { + hasTasks = true + } + if r.Path == "/api/v1/admin/system-configs" { + hasConfigs = true + } + } + assert.True(t, hasStatus) + assert.True(t, hasDBOverview) + assert.True(t, hasUsers) + assert.True(t, hasTasks) + assert.True(t, hasConfigs) + + // 2. Task & Schedule + _, ok := ctx.Tasks().Get("admin:system_cleanup") + require.True(t, ok) + sched, ok := ctx.Schedules().Get("admin:system_cleanup") + require.True(t, ok) + assert.Equal(t, "0 4 * * *", sched.Spec) + + // 3. Settings + schema, ok := ctx.Settings().Get("admin.system_cleanup_cron") + require.True(t, ok) + assert.Equal(t, "0 4 * * *", schema.Default) +} + +func TestAllDomainPluginsCombined(t *testing.T) { + mr, err := miniredis.Run() + require.NoError(t, err) + defer mr.Close() + + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + defer func() { _ = rdb.Close() }() + + ctx := core.NewContext(context.Background()) + ctx.Config().SetSource(core.NewMapSource(map[string]any{ + "redis": map[string]any{ + "enabled": true, + "addrs": []string{mr.Addr()}, + }, + })) + require.NoError(t, ctx.Config().Resolve()) + testDB := setupTestDB(t) + + // Apply Infra plugins + require.NoError(t, db.New(db.WithDB(testDB)).Apply(ctx)) + require.NoError(t, cache.New(cache.WithRedis(rdb)).Apply(ctx)) + require.NoError(t, logger.New().Apply(ctx)) + require.NoError(t, storage.New().Apply(ctx)) + + // Apply Domain plugins + require.NoError(t, auth.New().Apply(ctx)) + require.NoError(t, user.New().Apply(ctx)) + require.NoError(t, message_gateway.New().Apply(ctx)) + require.NoError(t, risk_control.New().Apply(ctx)) + require.NoError(t, admin.New().Apply(ctx)) + + // Verify cross-plugin service injection via Using3 + var resolved bool + err = core.Using3(ctx, func(authSvc contracts.AuthService, userSvc contracts.UserService, authReg contracts.AuthRegistry) { + resolved = true + assert.NotNil(t, authSvc) + assert.NotNil(t, userSvc) + assert.NotNil(t, authReg) + }) + require.NoError(t, err) + assert.True(t, resolved) + + // Verify all migration entries + allMigrations := ctx.Migrations().Entries() + assert.GreaterOrEqual(t, len(allMigrations), 3) + + // Verify total routes registered + allRoutes := ctx.Router().Routes() + assert.GreaterOrEqual(t, len(allRoutes), 20) + + // Verify total tasks registered + allTasks := ctx.Tasks().Tasks() + assert.GreaterOrEqual(t, len(allTasks), 4) + for _, task := range allTasks { + dto := task.ToDTO() + assert.NotEmpty(t, dto.Type, "task %s should have type", task.Pattern) + assert.NotEmpty(t, dto.AsynqTask, "task %s should have asynq_task", task.Pattern) + assert.NotEmpty(t, dto.Name, "task %s should have name", task.Pattern) + } + + // Verify total schedules registered + allSchedules := ctx.Schedules().Schedules() + assert.GreaterOrEqual(t, len(allSchedules), 2) + + // 每个调度指向的任务类型都必须已注册 Handler,否则触发时会投递到无人处理的 + // 任务类型,预期的清理逻辑静默失效。 + for _, sched := range allSchedules { + _, ok := ctx.Tasks().Get(sched.TaskType) + assert.Truef(t, ok, "schedule %q dispatches to task %q, which is never registered", + sched.Spec, sched.TaskType) + } + + // Verify total settings schemas registered + allSettings := ctx.Settings().Schemas() + assert.GreaterOrEqual(t, len(allSettings), 7) + + // Clean shutdown + require.NoError(t, ctx.Dispose()) +} diff --git a/backend/plugins/domain/message_gateway/channels/qq/adapter.go b/backend/plugins/domain/message_gateway/channels/qq/adapter.go new file mode 100644 index 00000000..828f7d4d --- /dev/null +++ b/backend/plugins/domain/message_gateway/channels/qq/adapter.go @@ -0,0 +1,163 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package qq implements the official QQ Bot C2C adapter. +package qq + +import ( + "Wavelet/pkg/logger" + "Wavelet/pkg/util" + "Wavelet/plugins/domain/message_gateway/model" + "Wavelet/plugins/domain/message_gateway/service" + "context" + "fmt" + "strings" + "sync" + "time" + + "github.com/tencent-connect/botgo" + "github.com/tencent-connect/botgo/dto" + "github.com/tencent-connect/botgo/event" + "github.com/tencent-connect/botgo/openapi" + "github.com/tencent-connect/botgo/token" + "golang.org/x/oauth2" +) + +// qqEvent is a testable inbound envelope. +type qqEvent struct { + Kind string + UserID string + Text string + MessageID string +} + +// Adapter is an official QQ Bot C2C channel. +type Adapter struct { + cfg model.ChannelConfig + onInbound service.Handler + api openapi.OpenAPI + tokenSrc oauth2.TokenSource + cancel context.CancelFunc + mu sync.Mutex + disconnected bool +} + +// New constructs a QQ adapter. +func New(cfg model.ChannelConfig, onInbound service.Handler) (service.Channel, error) { + if strings.TrimSpace(cfg.Credentials["app_id"]) == "" || strings.TrimSpace(cfg.Credentials["app_secret"]) == "" { + return nil, fmt.Errorf("qq: app_id and app_secret are required") + } + return &Adapter{cfg: cfg, onInbound: onInbound}, nil +} + +// Type returns qq. +func (a *Adapter) Type() string { return model.ChannelTypeQQ } + +// Capabilities reports C2C text/media support. +func (a *Adapter) Capabilities() model.Capability { + return model.Capability{Text: true, Image: true, File: true, Reply: true} +} + +// Connect starts the official WebSocket session (C2C intent). +func (a *Adapter) Connect(ctx context.Context) error { + credentials := &token.QQBotCredentials{ + AppID: a.cfg.Credentials["app_id"], + AppSecret: a.cfg.Credentials["app_secret"], + } + tokSrc := token.NewQQBotTokenSource(credentials) + runCtx, cancel := context.WithCancel(ctx) + if err := token.StartRefreshAccessToken(runCtx, tokSrc); err != nil { + cancel() + return fmt.Errorf("qq: refresh token: %w", err) + } + + var api openapi.OpenAPI + const apiTimeout = 5 * time.Second + if strings.EqualFold(strings.TrimSpace(a.cfg.Extra["sandbox"]), "true") { + api = botgo.NewSandboxOpenAPI(credentials.AppID, tokSrc).WithTimeout(apiTimeout) + } else { + api = botgo.NewOpenAPI(credentials.AppID, tokSrc).WithTimeout(apiTimeout) + } + + wsAP, err := api.WS(ctx, nil, "") + if err != nil { + cancel() + return fmt.Errorf("qq: websocket ap: %w", err) + } + + intent := event.RegisterHandlers(event.C2CMessageEventHandler(func(_ *dto.WSPayload, data *dto.WSC2CMessageData) error { + authorID := "" + if data != nil && data.Author != nil { + authorID = data.Author.ID + } + text := "" + id := "" + if data != nil { + text = data.Content + id = data.ID + } + a.handleEvent(runCtx, qqEvent{Kind: "c2c", UserID: authorID, Text: text, MessageID: id}) + return nil + })) + + a.mu.Lock() + a.api = api + a.tokenSrc = tokSrc + a.cancel = cancel + a.disconnected = false + a.mu.Unlock() + + util.Go(func() { + if err := botgo.NewSessionManager().Start(wsAP, tokSrc, &intent); err != nil { + logger.ErrorF(runCtx, "qq session stopped: %v", err) + } + }) + return nil +} + +// Disconnect stops token refresh and drops further inbound events. +func (a *Adapter) Disconnect(_ context.Context) error { + a.mu.Lock() + defer a.mu.Unlock() + a.disconnected = true + if a.cancel != nil { + a.cancel() + a.cancel = nil + } + return nil +} + +// Send posts a C2C text reply. +func (a *Adapter) Send(ctx context.Context, to model.Recipient, msg model.OutboundMessage) error { + a.mu.Lock() + api := a.api + a.mu.Unlock() + if api == nil { + return fmt.Errorf("qq: not connected") + } + _, err := api.PostC2CMessage(ctx, to.PlatformUserID, &dto.MessageToCreate{ + Content: msg.Text, + MsgID: msg.ReplyToID, + }) + return err +} + +func (a *Adapter) handleEvent(ctx context.Context, ev qqEvent) { + if ev.Kind != "c2c" { + return + } + a.mu.Lock() + disconnected := a.disconnected + a.mu.Unlock() + if disconnected || a.onInbound == nil { + return + } + _ = a.onInbound(ctx, model.InboundMessage{ + ChannelID: a.cfg.ID, + ChannelType: model.ChannelTypeQQ, + PlatformUserID: ev.UserID, + ChatID: ev.UserID, + MessageID: ev.MessageID, + Text: ev.Text, + }) +} diff --git a/backend/plugins/domain/message_gateway/channels/qq/adapter_test.go b/backend/plugins/domain/message_gateway/channels/qq/adapter_test.go new file mode 100644 index 00000000..fe4b9f62 --- /dev/null +++ b/backend/plugins/domain/message_gateway/channels/qq/adapter_test.go @@ -0,0 +1,41 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package qq + +import ( + "Wavelet/plugins/domain/message_gateway/model" + "context" + "testing" +) + +func TestHandleEvent_DropsNonC2C(t *testing.T) { + var got int + a := &Adapter{onInbound: func(ctx context.Context, msg model.InboundMessage) error { + got++ + return nil + }} + a.handleEvent(context.Background(), 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 model.InboundMessage + a := &Adapter{cfg: model.ChannelConfig{ID: 3}, onInbound: func(ctx context.Context, msg model.InboundMessage) error { + got = msg + return nil + }} + a.handleEvent(context.Background(), 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) + } +} + +func TestNew_RequiresCreds(t *testing.T) { + _, err := New(model.ChannelConfig{}, nil) + if err == nil { + t.Fatal("expected error") + } +} diff --git a/backend/plugins/domain/message_gateway/channels/telegram/adapter.go b/backend/plugins/domain/message_gateway/channels/telegram/adapter.go new file mode 100644 index 00000000..eb044e87 --- /dev/null +++ b/backend/plugins/domain/message_gateway/channels/telegram/adapter.go @@ -0,0 +1,181 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package telegram implements the Telegram private-chat adapter. +package telegram + +import ( + "Wavelet/pkg/logger" + "Wavelet/pkg/util" + "Wavelet/plugins/domain/message_gateway/model" + "Wavelet/plugins/domain/message_gateway/service" + "context" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + tele "gopkg.in/telebot.v4" +) + +// Adapter is a Telegram private-chat channel. +type Adapter struct { + cfg model.ChannelConfig + onInbound service.Handler + bot *tele.Bot +} + +// New constructs a Telegram adapter. Call service.Register from the runner. +func New(cfg model.ChannelConfig, onInbound service.Handler) (service.Channel, error) { + if strings.TrimSpace(cfg.Credentials["bot_token"]) == "" { + return nil, fmt.Errorf("telegram: bot_token is required") + } + return &Adapter{cfg: cfg, onInbound: onInbound}, nil +} + +// Type returns telegram. +func (a *Adapter) Type() string { return model.ChannelTypeTelegram } + +// Capabilities reports private-chat media support. +func (a *Adapter) Capabilities() model.Capability { + return model.Capability{Text: true, Image: true, File: true, Reply: true} +} + +// longPollWindow is how long Telegram may hold a getUpdates call open before +// returning empty. telebot converts it with int(timeout / time.Second), so a +// bare integer here would mean nanoseconds, send timeout=0 and turn the poller +// into a tight loop against the Bot API. +const longPollWindow = 10 * time.Second + +// buildTeleSettings assembles the telebot settings. +func buildTeleSettings(cfg model.ChannelConfig) tele.Settings { + pref := tele.Settings{ + Token: cfg.Credentials["bot_token"], + Poller: &tele.LongPoller{Timeout: longPollWindow}, + } + if base := strings.TrimSpace(cfg.Extra["base_url"]); base != "" { + pref.URL = strings.TrimSuffix(base, "/") + } + return pref +} + +// Connect starts long polling. +func (a *Adapter) Connect(ctx context.Context) error { + bot, err := tele.NewBot(buildTeleSettings(a.cfg)) + if err != nil { + return fmt.Errorf("telegram: new bot: %w", err) + } + a.bot = bot + bot.Handle(tele.OnText, func(c tele.Context) error { + a.handleTeleMessage(ctx, c.Message()) + return nil + }) + bot.Handle(tele.OnPhoto, func(c tele.Context) error { + a.handleTeleMessage(ctx, c.Message()) + return nil + }) + bot.Handle(tele.OnDocument, func(c tele.Context) error { + a.handleTeleMessage(ctx, c.Message()) + return nil + }) + util.Go(func() { + bot.Start() + }) + util.Go(func() { + <-ctx.Done() + bot.Stop() + }) + return nil +} + +// Disconnect stops the bot. +func (a *Adapter) Disconnect(_ context.Context) error { + if a.bot != nil { + a.bot.Stop() + } + return nil +} + +// Send replies to a private chat. +func (a *Adapter) Send(_ context.Context, to model.Recipient, msg model.OutboundMessage) error { + if a.bot == nil { + return fmt.Errorf("telegram: not connected") + } + chatID, err := strconv.ParseInt(to.ChatID, 10, 64) + if err != nil { + return fmt.Errorf("telegram: chat id: %w", err) + } + _, err = a.bot.Send(tele.ChatID(chatID), msg.Text) + return err +} + +func (a *Adapter) handleTeleMessage(ctx context.Context, m *tele.Message) { + if m == nil || m.Chat == nil || m.Chat.Type != tele.ChatPrivate { + return + } + if a.onInbound == nil { + return + } + msg := model.InboundMessage{ + ChannelID: a.cfg.ID, + ChannelType: model.ChannelTypeTelegram, + PlatformUserID: strconv.FormatInt(m.Sender.ID, 10), + ChatID: strconv.FormatInt(m.Chat.ID, 10), + MessageID: strconv.Itoa(m.ID), + Text: m.Text, + } + if m.Caption != "" && msg.Text == "" { + msg.Text = m.Caption + } + if a.bot != nil { + dir, attachments := a.downloadMedia(m) + if dir != "" { + defer func() { + if err := os.RemoveAll(dir); err != nil { + logger.WarnF(ctx, "telegram: 清理临时媒体目录 %q 失败: %v", dir, err) + } + }() + } + msg.Attachments = attachments + } + _ = a.onInbound(ctx, msg) +} + +// downloadMedia fetches message media into a scratch directory, returned so the +// caller can remove it once the inbound handler no longer needs the paths. +// An empty dir means nothing was downloaded. +func (a *Adapter) downloadMedia(m *tele.Message) (string, []model.Attachment) { + var files []*tele.File + var names []string + if m.Photo != nil { + files = append(files, m.Photo.MediaFile()) + names = append(names, "photo.jpg") + } + if m.Document != nil { + files = append(files, &m.Document.File) + name := m.Document.FileName + if name == "" { + name = "file" + } + names = append(names, name) + } + if len(files) == 0 { + return "", nil + } + dir, err := os.MkdirTemp("", "wg-tg-*") + if err != nil { + return "", []model.Attachment{{Error: err.Error()}} + } + out := make([]model.Attachment, 0, len(files)) + for i, f := range files { + path := filepath.Join(dir, names[i]) + if err := a.bot.Download(f, path); err != nil { + out = append(out, model.Attachment{FileName: names[i], Error: err.Error()}) + continue + } + out = append(out, model.Attachment{Path: path, FileName: names[i]}) + } + return dir, out +} diff --git a/backend/plugins/domain/message_gateway/channels/telegram/adapter_test.go b/backend/plugins/domain/message_gateway/channels/telegram/adapter_test.go new file mode 100644 index 00000000..cd700e05 --- /dev/null +++ b/backend/plugins/domain/message_gateway/channels/telegram/adapter_test.go @@ -0,0 +1,78 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package telegram + +import ( + "Wavelet/plugins/domain/message_gateway/model" + "context" + "testing" + "time" + + tele "gopkg.in/telebot.v4" +) + +// TestBuildTeleSettingsLongPollWindow 回归:LongPoller.Timeout 是 time.Duration, +// telebot 以 int(timeout/time.Second) 下发给 getUpdates。写成裸整数会被解释为 +// 纳秒,令 timeout=0,长轮询退化为对 Bot API 的空转轮询。 +func TestBuildTeleSettingsLongPollWindow(t *testing.T) { + pref := buildTeleSettings(model.ChannelConfig{ + Credentials: map[string]string{"bot_token": "token"}, + Extra: map[string]string{"base_url": "https://tg.example.com/api/"}, + }) + + poller, ok := pref.Poller.(*tele.LongPoller) + if !ok { + t.Fatalf("expected *tele.LongPoller, got %T", pref.Poller) + } + if got := int(poller.Timeout / time.Second); got != 10 { + t.Errorf("getUpdates would receive timeout=%d seconds, want 10", got) + } + if pref.URL != "https://tg.example.com/api" { + t.Errorf("base_url trailing slash should be trimmed, got %q", pref.URL) + } +} + +func TestHandleUpdate_DropsGroups(t *testing.T) { + var got int + a := &Adapter{onInbound: func(ctx context.Context, msg model.InboundMessage) error { + got++ + return nil + }} + a.handleTeleMessage(context.Background(), &tele.Message{ + ID: 1, + Text: "hi", + Chat: &tele.Chat{ID: -100, Type: tele.ChatGroup}, + Sender: &tele.User{ID: 1}, + }) + if got != 0 { + t.Fatalf("group must be ignored") + } +} + +func TestHandleUpdate_PrivateText(t *testing.T) { + var got model.InboundMessage + a := &Adapter{ + cfg: model.ChannelConfig{ID: 7, Type: "telegram"}, + onInbound: func(ctx context.Context, msg model.InboundMessage) error { + got = msg + return nil + }, + } + a.handleTeleMessage(context.Background(), &tele.Message{ + ID: 9, + Text: "hi", + Chat: &tele.Chat{ID: 42, Type: tele.ChatPrivate}, + Sender: &tele.User{ID: 42}, + }) + if got.Text != "hi" || got.PlatformUserID != "42" || got.ChannelID != 7 { + t.Fatalf("%+v", got) + } +} + +func TestNew_RequiresToken(t *testing.T) { + _, err := New(model.ChannelConfig{}, nil) + if err == nil { + t.Fatal("expected error") + } +} diff --git a/backend/plugins/domain/message_gateway/errs/errs.go b/backend/plugins/domain/message_gateway/errs/errs.go new file mode 100644 index 00000000..e6fa4280 --- /dev/null +++ b/backend/plugins/domain/message_gateway/errs/errs.go @@ -0,0 +1,72 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package errs defines error sentinels and user-facing error message constants +// for the message_gateway plugin. +package errs + +import "errors" + +// Sentinel errors. +var ( + ErrCodeInvalid = errors.New("invalid or expired pairing code") + ErrChannelMismatch = errors.New("pairing code does not match channel") + ErrPlatformAlreadyBound = errors.New("this platform account is already bound") + ErrBindingNotFound = errors.New("binding not found") + ErrBindingForbidden = errors.New("cannot unbind another user's binding") + ErrChannelIDRequired = errors.New("channel_id is required") + ErrChannelDisabled = errors.New("channel is not enabled") + + // ErrRecordNotFound maps GORM's missing-row sentinel at the repository boundary so + // upper layers never import gorm. Its text matches gorm.ErrRecordNotFound verbatim. + ErrRecordNotFound = errors.New("record not found") + + // ErrUnsupportedUserLookupField rejects a column name that the repository is not + // allowed to interpolate into a WHERE clause. + ErrUnsupportedUserLookupField = errors.New("unsupported user lookup field") +) + +// User-facing validation and error message constants. +const ( + ErrNameRequired = "name is required" + ErrTypeInvalid = "type must be telegram or qq" + ErrTelegramTokenRequired = "telegram bot secret is required" //nolint:gosec // user-facing validation text + ErrQQCredentialsRequired = "qq app id and secret are required" //nolint:gosec // user-facing validation text + ErrChannelNotFound = "channel not found" + ErrChannelProbeFailed = "channel probe failed" + MaskedSecret = "********" + + ErrLoginRequired = "login required" + ErrInvalidBindingID = "invalid binding id" + ErrInvalidChannelID = "invalid channel id" + ErrInvalidEventID = "invalid event id" + ErrEventNotFound = "notification event not found" + ErrValidationFailed = "validation failed" + + ErrMissingTelegramToken = "missing telegram bot token" + ErrMissingQQCredentials = "missing qq app_id or app_secret" //nolint:gosec // user-facing validation text + ErrQQTokenFetchFailed = "qq token fetch failed" //nolint:gosec // user-facing validation text + ErrQQEmptyToken = "qq returned empty access token" //nolint:gosec // user-facing validation text + ErrTelegramGetMeFailed = "telegram getMe failed" + ErrTelegramNotOK = "telegram returned ok=false" + ErrAPIBaseInvalid = "api_base must start with http:// or https://" + + ErrChannelNameExists = "channel name already exists" + ErrChannelNameRequired = "channel name is required" + ErrChannelTypeRequired = "channel type is required" + ErrEventKeyRequired = "event_key is required" + ErrEventAlreadyConfigured = "this notification event is already configured" + ErrTemplateInvalidJSON = "custom template is not a valid JSON format" + ErrEnableWithoutChannels = "cannot enable event without any push channels configured" + ErrEventKeyOrTaskType = "either event_key or task_type must be provided" + ErrUnsupportedEventKey = "unsupported built-in event key" + ErrTaskServiceUnavailable = "task service not available" + ErrUserNotFound = "user not found" + ErrNoAdminUser = "no admin user found" + + ErrPayloadRequired = "payload is required" + ErrInvalidJSONFormat = "invalid json format" + ErrParsePayloadFailed = "parse payload failed" + ErrGetPusherFailed = "get pusher failed" + ErrPusherSendFailed = "pusher.Send failed" +) diff --git a/backend/plugins/domain/message_gateway/handler/admin.go b/backend/plugins/domain/message_gateway/handler/admin.go new file mode 100644 index 00000000..f90ccacd --- /dev/null +++ b/backend/plugins/domain/message_gateway/handler/admin.go @@ -0,0 +1,139 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package handler + +import ( + "Wavelet/pkg/response" + "Wavelet/plugins/domain/message_gateway/errs" + "Wavelet/plugins/domain/message_gateway/service" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" +) + +// ListAdminChannelDefinitions returns form schemas for supported channel types. +// @Summary List message gateway channel definitions +// @Description Returns form field definitions for Telegram and QQ channels +// @Tags admin-message-gateway +// @Produce json +// @Security SessionCookie +// @Success 200 {object} response.Any{data=[]model.Definition} +// @Router /api/v1/admin/message-gateway/channels/definitions [get] +func ListAdminChannelDefinitions(c *gin.Context) { + c.JSON(http.StatusOK, response.OK(service.ListDefinitions())) +} + +// ListAdminChannels lists configured messaging channels with secrets masked. +// @Summary List message gateway channels +// @Description Returns all messaging channels; secrets are masked +// @Tags admin-message-gateway +// @Produce json +// @Security SessionCookie +// @Success 200 {object} response.Any{data=[]model.ChannelDTO} +// @Router /api/v1/admin/message-gateway/channels [get] +func ListAdminChannels(c *gin.Context) { + rows, err := service.ListChannels(c.Request.Context()) + if err != nil { + response.AbortInternal(c, err.Error()) + return + } + c.JSON(http.StatusOK, response.OK(rows)) +} + +func parseAdminChannelID(c *gin.Context) (uint64, bool) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.AbortBadRequest(c, errs.ErrInvalidChannelID) + return 0, false + } + return id, true +} + +func handleAdminChannelError(c *gin.Context, err error, fallback func(c *gin.Context, msg string)) { + if err.Error() == errs.ErrChannelNotFound { + response.AbortNotFound(c, err.Error()) + return + } + fallback(c, err.Error()) +} + +// CreateAdminChannel creates a messaging channel. +// @Summary Create message gateway channel +// @Description Creates a Telegram or QQ channel with encrypted credentials +// @Tags admin-message-gateway +// @Accept json +// @Produce json +// @Security SessionCookie +// @Param request body model.CreateChannelRequest true "create body" +// @Success 200 {object} response.Any{data=model.ChannelDTO} +// @Failure 400 {object} response.Any +// @Router /api/v1/admin/message-gateway/channels [post] +func CreateAdminChannel(c *gin.Context) { + handleJSONRequest(c, service.CreateChannel) +} + +// UpdateAdminChannel patches a messaging channel. Empty secrets keep the previous values. +// @Summary Update message gateway channel +// @Description Updates a channel; empty secrets keep the current ciphertext +// @Tags admin-message-gateway +// @Accept json +// @Produce json +// @Security SessionCookie +// @Param id path int true "channel id" +// @Param request body model.UpdateChannelRequest true "update body" +// @Success 200 {object} response.Any{data=model.ChannelDTO} +// @Failure 400 {object} response.Any +// @Failure 404 {object} response.Any +// @Router /api/v1/admin/message-gateway/channels/{id} [patch] +func UpdateAdminChannel(c *gin.Context) { + handleEntityUpdate(c, parseAdminChannelID, service.UpdateChannel, func(c *gin.Context, err error) { + handleAdminChannelError(c, err, response.AbortBadRequest) + }) +} + +// DeleteAdminChannel removes a channel and its bindings/pairing codes. +// @Summary Delete message gateway channel +// @Description Deletes a channel and cascaded bindings and pairing codes +// @Tags admin-message-gateway +// @Produce json +// @Security SessionCookie +// @Param id path int true "channel id" +// @Success 200 {object} response.Any +// @Failure 404 {object} response.Any +// @Router /api/v1/admin/message-gateway/channels/{id} [delete] +func DeleteAdminChannel(c *gin.Context) { + id, ok := parseAdminChannelID(c) + if !ok { + return + } + if err := service.DeleteChannel(c.Request.Context(), id); err != nil { + handleAdminChannelError(c, err, response.AbortInternal) + return + } + c.JSON(http.StatusOK, response.OKNil()) +} + +// TestAdminChannel probes stored credentials (Telegram getMe or QQ token). +// @Summary Test message gateway channel +// @Description Probes stored credentials without returning secrets +// @Tags admin-message-gateway +// @Produce json +// @Security SessionCookie +// @Param id path int true "channel id" +// @Success 200 {object} response.Any +// @Failure 400 {object} response.Any +// @Failure 404 {object} response.Any +// @Router /api/v1/admin/message-gateway/channels/{id}/test [post] +func TestAdminChannel(c *gin.Context) { + id, ok := parseAdminChannelID(c) + if !ok { + return + } + if err := service.ProbeChannel(c.Request.Context(), id); err != nil { + handleAdminChannelError(c, err, response.AbortBadRequest) + return + } + c.JSON(http.StatusOK, response.OKNil()) +} diff --git a/backend/plugins/domain/message_gateway/handler/handlers.go b/backend/plugins/domain/message_gateway/handler/handlers.go new file mode 100644 index 00000000..877ade86 --- /dev/null +++ b/backend/plugins/domain/message_gateway/handler/handlers.go @@ -0,0 +1,182 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package handler provides HTTP endpoints for message_gateway. +package handler + +import ( + "Wavelet/core/contracts" + "Wavelet/pkg/ginutil" + "Wavelet/pkg/response" + "Wavelet/plugins/domain/message_gateway/errs" + "Wavelet/plugins/domain/message_gateway/model" + "Wavelet/plugins/domain/message_gateway/service" + "context" + "errors" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" +) + +func currentUser(c *gin.Context) (*contracts.UserDTO, bool) { + return ginutil.GetFromContext[*contracts.UserDTO](c, contracts.AuthUserObjKey) +} + +// handleJSONRequest binds a JSON body, runs the service use case and writes the +// standard success envelope; any service error surfaces as a bad request. +func handleJSONRequest[Req any, Res any](c *gin.Context, handler func(ctx context.Context, req Req) (Res, error)) { + var req Req + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + res, err := handler(c.Request.Context(), req) + if err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + c.JSON(http.StatusOK, response.OK(res)) +} + +// handleEntityUpdate resolves a path identifier plus JSON body, runs the updater +// use case and writes the success envelope; error classification is delegated to onErr. +func handleEntityUpdate[Req any, Res any]( + c *gin.Context, + parseID func(*gin.Context) (uint64, bool), + updater func(ctx context.Context, id uint64, req Req) (Res, error), + onErr func(*gin.Context, error), +) { + id, ok := parseID(c) + if !ok { + return + } + var req Req + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + dto, err := updater(c.Request.Context(), id, req) + if err != nil { + onErr(c, err) + return + } + c.JSON(http.StatusOK, response.OK(dto)) +} + +// ListChannels lists enabled channels a user can bind. +// @Summary List enabled messaging channels +// @Description Returns enabled system bots the current user can pair with +// @Tags message-gateway +// @Produce json +// @Security SessionCookie +// @Success 200 {object} response.Any{data=[]model.PublicChannelDTO} +// @Failure 401 {object} response.Any +// @Router /api/v1/message-gateway/channels [get] +func ListChannels(c *gin.Context) { + if user, ok := currentUser(c); !ok || user == nil { + response.AbortUnauthorized(c, errs.ErrLoginRequired) + return + } + rows, err := service.ListEnabledPublicChannels(c.Request.Context()) + if err != nil { + response.AbortInternal(c, err.Error()) + return + } + c.JSON(http.StatusOK, response.OK(rows)) +} + +// ListBindings lists the current user's bot bindings. +// @Summary List message gateway bindings +// @Description Returns the current user's bound messaging channels +// @Tags message-gateway +// @Produce json +// @Security SessionCookie +// @Success 200 {object} response.Any{data=[]model.BindingDTO} +// @Failure 401 {object} response.Any +// @Router /api/v1/message-gateway/bindings [get] +func ListBindings(c *gin.Context) { + user, ok := currentUser(c) + if !ok || user == nil { + response.AbortUnauthorized(c, errs.ErrLoginRequired) + return + } + rows, err := service.ListUserBindings(c.Request.Context(), user.ID) + if err != nil { + response.AbortInternal(c, err.Error()) + return + } + c.JSON(http.StatusOK, response.OK(rows)) +} + +// BindBinding consumes a pairing code and binds the platform identity. +// @Summary Bind a messaging channel +// @Description Binds the current user to a platform identity using a one-time pairing code +// @Tags message-gateway +// @Accept json +// @Produce json +// @Security SessionCookie +// @Param request body model.BindRequest true "bind body" +// @Success 200 {object} response.Any{data=model.BindingDTO} +// @Failure 400 {object} response.Any +// @Failure 409 {object} response.Any +// @Router /api/v1/message-gateway/bindings [post] +func BindBinding(c *gin.Context) { + user, ok := currentUser(c) + if !ok || user == nil { + response.AbortUnauthorized(c, errs.ErrLoginRequired) + return + } + var req model.BindRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + dto, err := service.BindChannel(c.Request.Context(), user.ID, req) + if err != nil { + if errors.Is(err, errs.ErrPlatformAlreadyBound) { + response.AbortConflict(c, err.Error()) + return + } + response.AbortBadRequest(c, err.Error()) + return + } + c.JSON(http.StatusOK, response.OK(dto)) +} + +// UnbindBinding removes the current user's binding. +// @Summary Unbind a messaging channel +// @Description Removes a binding owned by the current user +// @Tags message-gateway +// @Produce json +// @Security SessionCookie +// @Param id path int true "binding id" +// @Success 200 {object} response.Any +// @Failure 403 {object} response.Any +// @Failure 404 {object} response.Any +// @Router /api/v1/message-gateway/bindings/{id} [delete] +func UnbindBinding(c *gin.Context) { + user, ok := currentUser(c) + if !ok || user == nil { + response.AbortUnauthorized(c, errs.ErrLoginRequired) + return + } + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.AbortBadRequest(c, errs.ErrInvalidBindingID) + return + } + if err := service.UnbindChannel(c.Request.Context(), user.ID, id); err != nil { + if errors.Is(err, errs.ErrBindingNotFound) { + response.AbortNotFound(c, err.Error()) + return + } + if errors.Is(err, errs.ErrBindingForbidden) { + response.AbortForbidden(c, err.Error()) + return + } + response.AbortInternal(c, err.Error()) + return + } + c.JSON(http.StatusOK, response.OKNil()) +} diff --git a/backend/plugins/domain/message_gateway/handler/push_channel.go b/backend/plugins/domain/message_gateway/handler/push_channel.go new file mode 100644 index 00000000..cdfe0c8f --- /dev/null +++ b/backend/plugins/domain/message_gateway/handler/push_channel.go @@ -0,0 +1,96 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package handler + +import ( + "Wavelet/pkg/response" + "Wavelet/plugins/domain/message_gateway/errs" + "Wavelet/plugins/domain/message_gateway/model" + "Wavelet/plugins/domain/message_gateway/service" + "errors" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" +) + +// ListPushChannelDefinitions returns channel definitions. +func ListPushChannelDefinitions(c *gin.Context) { + c.JSON(http.StatusOK, response.OK(model.ListPushDefinitions())) +} + +// ListPushChannels lists configured push channels. +func ListPushChannels(c *gin.Context) { + channels, err := service.ListPushChannels(c.Request.Context()) + if err != nil { + response.AbortInternal(c, err.Error()) + return + } + c.JSON(http.StatusOK, response.OK(channels)) +} + +// parsePushChannelID reads the path identifier of a push channel. +func parsePushChannelID(c *gin.Context) (uint64, bool) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.AbortBadRequest(c, errs.ErrInvalidChannelID) + return 0, false + } + return id, true +} + +// handlePushChannelNotFoundError maps a missing channel row to 404, others to fallback. +func handlePushChannelNotFoundError(c *gin.Context, err error, fallback func(c *gin.Context, msg string)) { + if errors.Is(err, errs.ErrRecordNotFound) { + response.AbortNotFound(c, errs.ErrChannelNotFound) + return + } + fallback(c, err.Error()) +} + +// CreatePushChannel creates a push channel. +func CreatePushChannel(c *gin.Context) { + handleJSONRequest(c, service.CreatePushChannel) +} + +// UpdatePushChannel updates a push channel. +func UpdatePushChannel(c *gin.Context) { + handleEntityUpdate(c, parsePushChannelID, service.UpdatePushChannel, func(c *gin.Context, err error) { + handlePushChannelNotFoundError(c, err, response.AbortInternal) + }) +} + +// DeletePushChannel deletes a push channel. +func DeletePushChannel(c *gin.Context) { + id, ok := parsePushChannelID(c) + if !ok { + return + } + + if err := service.DeletePushChannel(c.Request.Context(), id); err != nil { + handlePushChannelNotFoundError(c, err, response.AbortInternal) + return + } + c.JSON(http.StatusOK, response.OKNil()) +} + +// TestPushChannel tests connectivity of a push channel. +func TestPushChannel(c *gin.Context) { + var req model.TestPushChannelRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + + payload, err := service.PreparePushChannelTest(c.Request.Context(), req) + if err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + if err := service.EnqueuePushTask(c.Request.Context(), payload); err != nil { + response.AbortInternal(c, err.Error()) + return + } + c.JSON(http.StatusOK, response.OKNil()) +} diff --git a/backend/plugins/domain/message_gateway/handler/push_event.go b/backend/plugins/domain/message_gateway/handler/push_event.go new file mode 100644 index 00000000..d7a6213b --- /dev/null +++ b/backend/plugins/domain/message_gateway/handler/push_event.go @@ -0,0 +1,148 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package handler + +import ( + "Wavelet/pkg/response" + "Wavelet/plugins/domain/message_gateway/errs" + "Wavelet/plugins/domain/message_gateway/model" + "Wavelet/plugins/domain/message_gateway/service" + "errors" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" +) + +// ListPushEvents lists configured push events. +func ListPushEvents(c *gin.Context) { + ctx := c.Request.Context() + events, err := service.ListPushEvents(ctx) + if err != nil { + response.AbortInternal(c, err.Error()) + return + } + c.JSON(http.StatusOK, response.OK(events)) +} + +// ListBuiltInPushEvents lists system built-in push event definitions. +func ListBuiltInPushEvents(c *gin.Context) { + c.JSON(http.StatusOK, response.OK(service.GetBuiltInEvents())) +} + +// parsePushEventID reads the path identifier of a push event. +func parsePushEventID(c *gin.Context) (uint64, bool) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.AbortBadRequest(c, errs.ErrInvalidEventID) + return 0, false + } + return id, true +} + +// handlePushEventNotFoundError maps a missing event row to 404, others to fallback. +func handlePushEventNotFoundError(c *gin.Context, err error, fallback func(c *gin.Context, msg string)) { + if errors.Is(err, errs.ErrRecordNotFound) { + response.AbortNotFound(c, errs.ErrEventNotFound) + return + } + fallback(c, err.Error()) +} + +// CreatePushEvent creates a new push event configuration. +func CreatePushEvent(c *gin.Context) { + handleJSONRequest(c, service.CreatePushEvent) +} + +// DeletePushEvent deletes a push event configuration by ID. +func DeletePushEvent(c *gin.Context) { + id, ok := parsePushEventID(c) + if !ok { + return + } + + if err := service.DeletePushEvent(c.Request.Context(), id); err != nil { + handlePushEventNotFoundError(c, err, response.AbortInternal) + return + } + c.JSON(http.StatusOK, response.OKNil()) +} + +// UpdatePushEvent updates an existing push event. +func UpdatePushEvent(c *gin.Context) { + id, ok := parsePushEventID(c) + if !ok { + return + } + + var req model.UpdatePushEventRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + + if err := service.UpdatePushEvent(c.Request.Context(), id, req); err != nil { + handlePushEventNotFoundError(c, err, response.AbortBadRequest) + return + } + c.JSON(http.StatusOK, response.OKNil()) +} + +// TogglePushEvent toggles the enabled state of a push event. +func TogglePushEvent(c *gin.Context) { + id, ok := parsePushEventID(c) + if !ok { + return + } + + enabled, err := service.TogglePushEvent(c.Request.Context(), id) + if err != nil { + handlePushEventNotFoundError(c, err, response.AbortBadRequest) + return + } + c.JSON(http.StatusOK, response.OK(enabled)) +} + +// ListPushHistories returns paginated push notification delivery histories. +func ListPushHistories(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + + total, results, err := service.ListPushHistories(c.Request.Context(), model.PushHistoryListFilter{ + EventKey: c.Query("event_key"), + Status: c.Query("status"), + Page: page, + PageSize: pageSize, + }) + if err != nil { + response.AbortInternal(c, err.Error()) + return + } + + c.JSON(http.StatusOK, response.OK(map[string]any{ + "total": total, + "results": results, + })) +} + +// TestPush executes a synchronous push test using the specified config. +func TestPush(c *gin.Context) { + var req model.TestPushRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + + if err := service.RunPushTest(c.Request.Context(), req.Config, req.Target); err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + c.JSON(http.StatusOK, response.OKNil()) +} diff --git a/backend/plugins/domain/message_gateway/handler/router.go b/backend/plugins/domain/message_gateway/handler/router.go new file mode 100644 index 00000000..837505e6 --- /dev/null +++ b/backend/plugins/domain/message_gateway/handler/router.go @@ -0,0 +1,63 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package handler + +import ( + "Wavelet/core/extpoints" + + "github.com/gin-gonic/gin" +) + +// RegisterUserRoutes mounts user-facing message gateway endpoints. +func RegisterUserRoutes(r extpoints.RouterExtension, loginMW gin.HandlerFunc) { + mg := r.Group("/message-gateway", loginMW) + { + mg.GET("/channels", ListChannels) + mg.GET("/bindings", ListBindings) + mg.POST("/bindings", BindBinding) + mg.DELETE("/bindings/:id", UnbindBinding) + } +} + +// RegisterAdminRoutes mounts admin message-gateway APIs under /admin. +func RegisterAdminRoutes(adminRouter extpoints.RouterExtension, loginMW, adminMW gin.HandlerFunc) { + g := adminRouter.Group("/message-gateway", loginMW, adminMW) + { + g.GET("/channels/definitions", ListAdminChannelDefinitions) + g.GET("/channels", ListAdminChannels) + g.POST("/channels", CreateAdminChannel) + g.PATCH("/channels/:id", UpdateAdminChannel) + g.DELETE("/channels/:id", DeleteAdminChannel) + g.POST("/channels/:id/test", TestAdminChannel) + } +} + +// RegisterAdminPushRoutes mounts admin push notification APIs under /admin. +func RegisterAdminPushRoutes(adminRouter extpoints.RouterExtension, loginMW, adminMW gin.HandlerFunc) { + adminPushGroup := adminRouter.Group("/push", loginMW, adminMW) + { + events := adminPushGroup.Group("/events") + { + events.GET("", ListPushEvents) + events.GET("/builtin", ListBuiltInPushEvents) + events.POST("", CreatePushEvent) + events.PUT("/:id", UpdatePushEvent) + events.DELETE("/:id", DeletePushEvent) + events.POST("/:id/toggle", TogglePushEvent) + } + + adminPushGroup.GET("/histories", ListPushHistories) + adminPushGroup.POST("/test", TestPush) + + channels := adminPushGroup.Group("/channels") + { + channels.GET("/definitions", ListPushChannelDefinitions) + channels.GET("", ListPushChannels) + channels.POST("", CreatePushChannel) + channels.PUT("/:id", UpdatePushChannel) + channels.DELETE("/:id", DeletePushChannel) + channels.POST("/test", TestPushChannel) + } + } +} diff --git a/backend/plugins/domain/message_gateway/message_gateway_test.go b/backend/plugins/domain/message_gateway/message_gateway_test.go new file mode 100644 index 00000000..cec83794 --- /dev/null +++ b/backend/plugins/domain/message_gateway/message_gateway_test.go @@ -0,0 +1,51 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package message_gateway_test + +import ( + "Wavelet/pkg/testhelper" + "Wavelet/plugins/domain/message_gateway" + "context" + "testing" + "time" + + "gorm.io/gorm" +) + +type mockDBService struct { + db *gorm.DB +} + +func (m *mockDBService) GORM() *gorm.DB { + return m.db +} + +func (m *mockDBService) DB(ctx context.Context) *gorm.DB { + return m.db.WithContext(ctx) +} + +func (m *mockDBService) Named(_ string) *gorm.DB { + return m.db +} + +func TestUpsertPairingCode_ReusesUnexpired(t *testing.T) { + testDB, _, cleanup := testhelper.SetupTestEnvironment(t) + message_gateway.SetDBServiceForTest(&mockDBService{db: testDB}) + defer func() { + message_gateway.SetDBServiceForTest(nil) + cleanup() + }() + ctx := context.Background() + first, err := message_gateway.UpsertPairingCode(ctx, 1, "tg-1", "ABCD1234", time.Now().Add(15*time.Minute)) + if err != nil { + t.Fatal(err) + } + second, err := message_gateway.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) + } +} diff --git a/backend/plugins/domain/message_gateway/migrations/postgres/00001_initial.sql b/backend/plugins/domain/message_gateway/migrations/postgres/00001_initial.sql new file mode 100644 index 00000000..4a7dc866 --- /dev/null +++ b/backend/plugins/domain/message_gateway/migrations/postgres/00001_initial.sql @@ -0,0 +1,93 @@ +-- +goose Up +-- +goose StatementBegin +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); + +CREATE TABLE IF NOT EXISTS w_push_events ( + id BIGINT PRIMARY KEY, + event_key VARCHAR(80) NOT NULL, + name VARCHAR(100) NOT NULL, + task_type VARCHAR(100) NOT NULL DEFAULT '', + channels TEXT NOT NULL DEFAULT '', + targets TEXT NOT NULL DEFAULT '', + template TEXT NOT NULL DEFAULT '', + enabled BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS uniq_w_push_events_key ON w_push_events(event_key); +CREATE INDEX IF NOT EXISTS idx_w_push_events_enabled ON w_push_events(enabled); +CREATE INDEX IF NOT EXISTS idx_w_push_events_task_type ON w_push_events(task_type); + +CREATE TABLE IF NOT EXISTS w_push_channels ( + id BIGINT PRIMARY KEY, + name VARCHAR(80) NOT NULL, + description VARCHAR(255) NOT NULL DEFAULT '', + type VARCHAR(50) NOT NULL DEFAULT 'custom', + token VARCHAR(100) NOT NULL DEFAULT '', + url TEXT NOT NULL DEFAULT '', + other TEXT NOT NULL DEFAULT '', + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS uniq_w_push_channels_name ON w_push_channels(name); +CREATE INDEX IF NOT EXISTS idx_w_push_channels_enabled ON w_push_channels(enabled); + +CREATE TABLE IF NOT EXISTS w_push_histories ( + id BIGINT PRIMARY KEY, + event_key VARCHAR(80) NOT NULL, + channel VARCHAR(50) NOT NULL, + target VARCHAR(255) NOT NULL, + title VARCHAR(255) NOT NULL, + content TEXT NOT NULL, + level VARCHAR(20) NOT NULL, + status VARCHAR(20) NOT NULL, + error_msg TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_w_push_histories_event ON w_push_histories(event_key); +CREATE INDEX IF NOT EXISTS idx_w_push_histories_created ON w_push_histories(created_at); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS w_push_histories; +DROP TABLE IF EXISTS w_push_channels; +DROP TABLE IF EXISTS w_push_events; +DROP TABLE IF EXISTS w_message_pairing_codes; +DROP TABLE IF EXISTS w_message_bindings; +DROP TABLE IF EXISTS w_message_channels; +-- +goose StatementEnd diff --git a/backend/plugins/domain/message_gateway/migrations/sqlite/00001_initial.sql b/backend/plugins/domain/message_gateway/migrations/sqlite/00001_initial.sql new file mode 100644 index 00000000..62c22281 --- /dev/null +++ b/backend/plugins/domain/message_gateway/migrations/sqlite/00001_initial.sql @@ -0,0 +1,93 @@ +-- +goose Up +-- +goose StatementBegin +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 1, + credentials TEXT NOT NULL DEFAULT '', + extra TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME 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 DATETIME 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 DATETIME NOT NULL, + created_at DATETIME 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); + +CREATE TABLE IF NOT EXISTS w_push_events ( + id BIGINT PRIMARY KEY, + event_key VARCHAR(80) NOT NULL, + name VARCHAR(100) NOT NULL, + task_type VARCHAR(100) NOT NULL DEFAULT '', + channels TEXT NOT NULL DEFAULT '', + targets TEXT NOT NULL DEFAULT '', + template TEXT NOT NULL DEFAULT '', + enabled BOOLEAN NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS uniq_w_push_events_key ON w_push_events(event_key); +CREATE INDEX IF NOT EXISTS idx_w_push_events_enabled ON w_push_events(enabled); +CREATE INDEX IF NOT EXISTS idx_w_push_events_task_type ON w_push_events(task_type); + +CREATE TABLE IF NOT EXISTS w_push_channels ( + id BIGINT PRIMARY KEY, + name VARCHAR(80) NOT NULL, + description VARCHAR(255) NOT NULL DEFAULT '', + type VARCHAR(50) NOT NULL DEFAULT 'custom', + token VARCHAR(100) NOT NULL DEFAULT '', + url TEXT NOT NULL DEFAULT '', + other TEXT NOT NULL DEFAULT '', + enabled BOOLEAN NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS uniq_w_push_channels_name ON w_push_channels(name); +CREATE INDEX IF NOT EXISTS idx_w_push_channels_enabled ON w_push_channels(enabled); + +CREATE TABLE IF NOT EXISTS w_push_histories ( + id BIGINT PRIMARY KEY, + event_key VARCHAR(80) NOT NULL, + channel VARCHAR(50) NOT NULL, + target VARCHAR(255) NOT NULL, + title VARCHAR(255) NOT NULL, + content TEXT NOT NULL, + level VARCHAR(20) NOT NULL, + status VARCHAR(20) NOT NULL, + error_msg TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_w_push_histories_event ON w_push_histories(event_key); +CREATE INDEX IF NOT EXISTS idx_w_push_histories_created ON w_push_histories(created_at); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS w_push_histories; +DROP TABLE IF EXISTS w_push_channels; +DROP TABLE IF EXISTS w_push_events; +DROP TABLE IF EXISTS w_message_pairing_codes; +DROP TABLE IF EXISTS w_message_bindings; +DROP TABLE IF EXISTS w_message_channels; +-- +goose StatementEnd diff --git a/backend/plugins/domain/message_gateway/model/admin.go b/backend/plugins/domain/message_gateway/model/admin.go new file mode 100644 index 00000000..0a8a7daf --- /dev/null +++ b/backend/plugins/domain/message_gateway/model/admin.go @@ -0,0 +1,46 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package model + +// Field is one admin form field. +type Field struct { + Key string `json:"key"` + Type string `json:"type"` + Required bool `json:"required"` +} + +// Definition describes a channel type form. +type Definition struct { + Type string `json:"type"` + Fields []Field `json:"fields"` +} + +// ChannelDTO represents a channel for admin consumption. +type ChannelDTO struct { + ID uint64 `json:"id,string"` + Name string `json:"name"` + Type string `json:"type"` + OwnerScope string `json:"owner_scope"` + OwnerID *uint64 `json:"owner_id,string,omitempty"` + Enabled bool `json:"enabled"` + Credentials map[string]string `json:"credentials"` + Extra map[string]string `json:"extra"` +} + +// CreateChannelRequest is admin create payload. +type CreateChannelRequest struct { + Name string `json:"name"` + Type string `json:"type"` + Enabled *bool `json:"enabled"` + Credentials map[string]string `json:"credentials"` + Extra map[string]string `json:"extra"` +} + +// UpdateChannelRequest is admin update payload. +type UpdateChannelRequest struct { + Name string `json:"name"` + Enabled *bool `json:"enabled"` + Credentials map[string]string `json:"credentials"` + Extra map[string]string `json:"extra"` +} diff --git a/backend/plugins/domain/message_gateway/model/models.go b/backend/plugins/domain/message_gateway/model/models.go new file mode 100644 index 00000000..f5064736 --- /dev/null +++ b/backend/plugins/domain/message_gateway/model/models.go @@ -0,0 +1,242 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package model defines the domain entities, DTOs, and schemas for message_gateway. +package model + +import ( + "Wavelet/plugins/domain/message_gateway/errs" + "errors" + "strings" + "time" +) + +// Channel type and scope constants. +const ( + ChannelTypeTelegram = "telegram" + ChannelTypeQQ = "qq" + MessageChannelTypeTelegram = "telegram" + MessageChannelTypeQQ = "qq" + MessageOwnerScopeSystem = "system" + + TypeCustom = "custom" + TypeEmail = "email" + TypeTelegram = "telegram" +) + +// Capability describes what an adapter can send and receive. +type Capability struct { + Text bool + Image bool + File bool + Reply bool + Group bool +} + +// ChannelConfig is the decrypted runtime config passed to a factory. +type ChannelConfig struct { + ID uint64 + Type string + Name string + Credentials map[string]string + Extra map[string]string +} + +// Recipient is the outbound destination on a platform. +type Recipient struct { + ChatID string + PlatformUserID string +} + +// Attachment is a downloaded inbound file sitting on local disk. +type Attachment struct { + Path string + FileName string + MIME string + Error string +} + +// InboundMessage is a normalized private-chat message. +type InboundMessage struct { + ChannelID uint64 + ChannelType string + PlatformUserID string + ChatID string + MessageID string + Text string + Attachments []Attachment + BindingUserID *uint64 +} + +// OutboundMessage is a reply or probe send. +type OutboundMessage struct { + Text string + ReplyToID string + Attachments []Attachment +} + +// MessageChannel is an admin-configured messaging adapter. +type MessageChannel struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + Type string `json:"type" gorm:"size:32;not null"` + Name string `json:"name" gorm:"size:64;not null"` + OwnerScope string `json:"owner_scope" gorm:"size:32;not null;default:'system'"` + OwnerID *uint64 `json:"owner_id,omitempty"` + Credentials string `json:"credentials" gorm:"type:text;not null"` + Extra string `json:"extra" gorm:"type:text"` + Enabled bool `json:"enabled" gorm:"default:false;not null"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` +} + +// TableName 表名 +func (MessageChannel) TableName() string { + return "w_message_channels" +} + +// MessageBinding maps a platform user to a Wavelet user on one channel. +type MessageBinding struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + ChannelID uint64 `json:"channel_id" gorm:"not null;index"` + PlatformUserID string `json:"platform_user_id" gorm:"size:128;not null;index"` + UserID uint64 `json:"user_id" gorm:"not null;index"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` +} + +// TableName 表名 +func (MessageBinding) TableName() string { + return "w_message_bindings" +} + +// MessagePairingCode is a one-time bind code. +type MessagePairingCode struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + Code string `json:"code" gorm:"size:32;uniqueIndex;not null"` + ChannelID uint64 `json:"channel_id" gorm:"not null;index"` + PlatformUserID string `json:"platform_user_id" gorm:"size:128;not null;index"` + UserID uint64 `json:"user_id" gorm:"not null;index"` + ExpiresAt time.Time `json:"expires_at" gorm:"not null;index"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` +} + +// TableName 表名 +func (MessagePairingCode) TableName() string { + return "w_message_pairing_codes" +} + +// PushChannel 消息通道模型 +type PushChannel struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + Name string `json:"name" gorm:"size:100;not null"` + Description string `json:"description" gorm:"size:255"` + Type string `json:"type" gorm:"size:50;not null;index"` + URL string `json:"url" gorm:"type:text"` + Token string `json:"token" gorm:"type:text"` + Other string `json:"other" gorm:"type:text"` + Enabled bool `json:"enabled" gorm:"index;not null;default:true"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"` +} + +// TableName 指定 GORM 表名 +func (PushChannel) TableName() string { + return "w_push_channels" +} + +// Validate 验证与标准化字段 +func (c *PushChannel) Validate() error { + c.Name = strings.TrimSpace(c.Name) + if c.Name == "" { + return errors.New(errs.ErrChannelNameRequired) + } + c.Type = strings.TrimSpace(c.Type) + if c.Type == "" { + return errors.New(errs.ErrChannelTypeRequired) + } + return nil +} + +// PushEvent 系统通知事件模型 +type PushEvent struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + EventKey string `json:"event_key" gorm:"uniqueIndex;size:80;not null"` + Name string `json:"name" gorm:"size:100;not null"` + TaskType string `json:"task_type" gorm:"size:100;index;not null;default:''"` + Channels []string `json:"channels" gorm:"type:text;serializer:json;not null"` + Targets []string `json:"targets" gorm:"type:text;serializer:json;not null"` + Template string `json:"template" gorm:"type:text;not null"` + Enabled bool `json:"enabled" gorm:"index;not null;default:false"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"` +} + +// TableName 指定 GORM 表名 +func (PushEvent) TableName() string { + return "w_push_events" +} + +// Validate 验证 PushEvent 实体字段 +func (e *PushEvent) Validate() error { + e.EventKey = strings.TrimSpace(e.EventKey) + if e.EventKey == "" { + return errors.New(errs.ErrEventKeyRequired) + } + e.Name = strings.TrimSpace(e.Name) + if e.Name == "" { + return errors.New(errs.ErrNameRequired) + } + return nil +} + +// PushHistory 推送日志/历史实体 +type PushHistory struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + EventKey string `json:"event_key" gorm:"size:80;not null;index"` + Channel string `json:"channel" gorm:"size:50;not null;index"` + Target string `json:"target" gorm:"size:255;not null"` + Title string `json:"title" gorm:"size:255;not null"` + Content string `json:"content" gorm:"type:text;not null"` + Level string `json:"level" gorm:"size:20;not null;default:'INFO'"` + Status string `json:"status" gorm:"size:20;not null;index"` + ErrorMsg string `json:"error_msg" gorm:"type:text"` + Payload string `json:"payload" gorm:"type:text"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"` +} + +// TableName 指定 GORM 表名 +func (PushHistory) TableName() string { + return "w_push_histories" +} + +// BindRequest is the user bind body. +type BindRequest struct { + ChannelID string `json:"channel_id"` + Code string `json:"code"` +} + +// BindingDTO is a user-facing binding row. +type BindingDTO struct { + ID uint64 `json:"id,string"` + UserID uint64 `json:"user_id,string"` + ChannelID uint64 `json:"channel_id,string"` + ChannelName string `json:"channel_name"` + ChannelType string `json:"channel_type"` + PlatformUserID string `json:"platform_user_id"` + CreatedAt time.Time `json:"created_at"` +} + +// PublicChannelDTO is an enabled channel a user can bind to. +type PublicChannelDTO struct { + ID uint64 `json:"id,string"` + Name string `json:"name"` + Type string `json:"type"` +} + +// PushNotificationEvent defines the payload for eventbus notification trigger. +type PushNotificationEvent struct { + UserID uint64 `json:"user_id"` + Channel string `json:"channel"` + Title string `json:"title"` + Content string `json:"content"` + Metadata map[string]any `json:"metadata,omitempty"` +} diff --git a/backend/plugins/domain/message_gateway/model/push.go b/backend/plugins/domain/message_gateway/model/push.go new file mode 100644 index 00000000..b16c9e38 --- /dev/null +++ b/backend/plugins/domain/message_gateway/model/push.go @@ -0,0 +1,313 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package model + +import ( + "sync" + "time" + + pkgpush "Wavelet/plugins/domain/message_gateway/push" +) + +// Push channel and payload constants. +const ( + ChannelCustom = "custom" + ChannelEmail = "email" + ChannelLark = "lark" + ChannelTelegram = "telegram" + DefaultLevelInfo = "INFO" + KeyTitle = "title" + KeyContent = "content" + KeyLevel = "level" + + // KeyURL represents the URL field key. + KeyURL = "url" + // KeyToken represents the Token field key. + KeyToken = "token" + // KeyOther represents the Other field key. + KeyOther = "other" + + // TypeText represents standard text input type. + TypeText = "text" + // TypePassword represents password input type. + TypePassword = "password" + // TypeTextarea represents textarea input type. + TypeTextarea = "textarea" +) + +// SMTPConfig mirrors the system SMTP settings consumed by the push service. +type SMTPConfig struct { + Host string + Port string + Username string + Password string +} + +// PushField represents a form field configuration for a channel. +type PushField struct { + Key string `json:"key"` + Label string `json:"label"` + Type string `json:"type"` + Required bool `json:"required"` + Placeholder string `json:"placeholder"` + Description string `json:"description"` +} + +// PushDefinition represents the metadata and form schema for a notification channel. +type PushDefinition struct { + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description"` + Fields []PushField `json:"fields"` +} + +// CreatePushChannelRequest is the create channel request payload. +type CreatePushChannelRequest struct { + Name string `json:"name" binding:"required"` + Description string `json:"description"` + Type string `json:"type" binding:"required"` + Token string `json:"token"` + URL string `json:"url"` + Other string `json:"other"` + Enabled bool `json:"enabled"` +} + +// UpdatePushChannelRequest is the update channel request payload. +type UpdatePushChannelRequest struct { + Description string `json:"description"` + Type string `json:"type" binding:"required"` + Token string `json:"token"` + URL string `json:"url"` + Other string `json:"other"` + Enabled bool `json:"enabled"` +} + +// TestPushChannelRequest is the test channel request payload. +type TestPushChannelRequest struct { + Name string `json:"name"` + Type string `json:"type"` + Token string `json:"token"` + URL string `json:"url"` + Other string `json:"other"` + Target string `json:"target"` +} + +// CustomPushRequest contains custom webhook parameters. +type CustomPushRequest struct { + Title string `json:"title" form:"title"` + Description string `json:"description" form:"description"` + Content string `json:"content" form:"content"` + URL string `json:"url" form:"url"` + To string `json:"to" form:"to"` + Token string `json:"token" form:"token"` +} + +// CreatePushEventRequest is the request body for creating a push event. +type CreatePushEventRequest struct { + EventKey string `json:"event_key"` + TaskType string `json:"task_type"` + Channels []string `json:"channels"` + Targets []string `json:"targets"` + Template string `json:"template"` + Enabled bool `json:"enabled"` +} + +// UpdatePushEventRequest is the request body for updating a push event. +type UpdatePushEventRequest struct { + Channels []string `json:"channels"` + Targets []string `json:"targets"` + Template string `json:"template" binding:"required"` + Enabled bool `json:"enabled"` +} + +// TestPushRequest is the request body for testing push config. +type TestPushRequest struct { + Config pkgpush.Config `json:"config" binding:"required"` + Target string `json:"target"` +} + +// NotificationMessage represents the structured notification message payload. +type NotificationMessage struct { + Title string `json:"title"` + Content string `json:"content"` + Level string `json:"level"` + Ext map[string]any `json:"ext,omitempty"` +} + +// Flatten converts the structured NotificationMessage back to a flat map (original json structure). +func (m NotificationMessage) Flatten() map[string]any { + res := map[string]any{ + KeyTitle: m.Title, + KeyContent: m.Content, + KeyLevel: m.Level, + } + for k, v := range m.Ext { + res[k] = v + } + return res +} + +// EventMetadata represents the metadata of a push notification event. +type EventMetadata struct { + Key string `json:"key"` + Name string `json:"name"` + DefaultTemplate NotificationMessage `json:"default_template"` + Description string `json:"description"` +} + +// SendPayload is the async push dispatch载荷 consumed by the notification worker. +type SendPayload struct { + EventKey string `json:"event_key"` + Config pkgpush.Config `json:"config"` + Target string `json:"target"` + Body NotificationMessage `json:"body"` + Template string `json:"template"` +} + +// PushHistoryListFilter filters push history pagination queries. +type PushHistoryListFilter struct { + EventKey string + Channel string + Status string + StartTime *time.Time + EndTime *time.Time + Page int + PageSize int +} + +var ( + pushDefMu sync.RWMutex + pushDefinitions = make(map[string]PushDefinition) +) + +// RegisterPushChannelDefinition registers a channel definition. +func RegisterPushChannelDefinition(def PushDefinition) { + pushDefMu.Lock() + defer pushDefMu.Unlock() + pushDefinitions[def.Type] = def +} + +// ListPushDefinitions returns all registered channel definitions. +func ListPushDefinitions() []PushDefinition { + pushDefMu.RLock() + defer pushDefMu.RUnlock() + + order := []string{ChannelCustom, ChannelLark, ChannelTelegram, ChannelEmail} + res := make([]PushDefinition, 0, len(pushDefinitions)) + for _, t := range order { + if d, ok := pushDefinitions[t]; ok { + res = append(res, d) + } + } + for t, d := range pushDefinitions { + found := false + for _, o := range order { + if o == t { + found = true + break + } + } + if !found { + res = append(res, d) + } + } + return res +} + +func init() { + RegisterPushChannelDefinition(PushDefinition{ + Type: ChannelCustom, + Name: "自定义消息通道", + Description: "使用自定义 HTTP POST 请求向外部 Webhook 发送数据。", + Fields: []PushField{ + { + Key: KeyURL, + Label: "请求地址", + Type: TypeText, + Required: true, + Placeholder: "在此填写完整的请求地址,必须使用 HTTPS 协议", + Description: "接口请求的完整 HTTPS URL,例如 https://api.example.com/webhook", + }, + { + Key: KeyOther, + Label: "请求体 (JSON)", + Type: TypeTextarea, + Required: true, + Placeholder: "在此输入请求体,支持模板变量,必须为合法的 JSON 格式", + Description: "可使用的变量:$title, $description, $content, $url, $to。例如 {\"text\": \"$content\"}", + }, + }, + }) + + RegisterPushChannelDefinition(PushDefinition{ + Type: ChannelLark, + Name: "飞书群机器人", + Description: "配置飞书群自定义机器人的 Webhook 接口投递。", + Fields: []PushField{ + { + Key: KeyURL, + Label: "Webhook 地址", + Type: TypeText, + Required: true, + Placeholder: "https://open.feishu.cn/open-apis/bot/v2/hook/YOUR_TOKEN", + Description: "从飞书群机器人设置中复制的 Webhook URL", + }, + { + Key: KeyToken, + Label: "签名校验密钥 (Secret) (可选)", + Type: TypeText, + Required: false, + Placeholder: "可选,若机器人启用了安全设置中的签名校验,请在此输入", + Description: "飞书群机器人安全设置中的签名校验 Key", + }, + { + Key: KeyOther, + Label: "自定义卡片 JSON 模版 (可选)", + Type: TypeTextarea, + Required: false, + Placeholder: "可选,留空则默认使用系统内置的精美互动卡片", + Description: "若填写,必须是合法的飞书卡片 JSON 格式", + }, + }, + }) + + RegisterPushChannelDefinition(PushDefinition{ + Type: ChannelTelegram, + Name: "Telegram 机器人", + Description: "配置 Telegram 机器人推送消息。", + Fields: []PushField{ + { + Key: KeyURL, + Label: "API 基础地址 (可选)", + Type: TypeText, + Required: false, + Placeholder: "https://api.telegram.org", + Description: "接口请求的 HTTPS 基础地址,留空默认为 https://api.telegram.org", + }, + { + Key: KeyToken, + Label: "机器人 Token (Bot Token)", + Type: TypePassword, + Required: true, + Placeholder: "在此输入 Telegram 机器人的 Bot Token", + Description: "通过 BotFather 申请到的机器人 Access Token", + }, + { + Key: KeyOther, + Label: "默认会话 ID (Chat ID) (可选)", + Type: TypeText, + Required: false, + Placeholder: "例如 -100123456789 或 @channel_name", + Description: "默认的消息接收 Chat ID。如果通知事件中未配置 targets,将推送到此 ID", + }, + }, + }) + + RegisterPushChannelDefinition(PushDefinition{ + Type: ChannelEmail, + Name: "邮件推送通道", + Description: "邮件推送通道直接使用系统全局 SMTP 设置进行发送,无需在此填写服务器配置。", + Fields: []PushField{}, + }) +} diff --git a/backend/plugins/domain/message_gateway/pairing_test.go b/backend/plugins/domain/message_gateway/pairing_test.go new file mode 100644 index 00000000..c442c30d --- /dev/null +++ b/backend/plugins/domain/message_gateway/pairing_test.go @@ -0,0 +1,33 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package message_gateway + +import ( + "strings" + "testing" +) + +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) + } +} diff --git a/backend/plugins/domain/message_gateway/plugin.go b/backend/plugins/domain/message_gateway/plugin.go new file mode 100644 index 00000000..16c1b463 --- /dev/null +++ b/backend/plugins/domain/message_gateway/plugin.go @@ -0,0 +1,384 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package message_gateway provides the Bot gateway, multi-channel notification dispatching, and asynchronous push worker domain plugin for Cordis. +package message_gateway + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "Wavelet/core/extpoints" + "Wavelet/pkg/ginutil" + "Wavelet/pkg/util" + "Wavelet/plugins/domain/message_gateway/handler" + "Wavelet/plugins/domain/message_gateway/model" + "Wavelet/plugins/domain/message_gateway/repository" + "Wavelet/plugins/domain/message_gateway/service" + "context" + "embed" + "reflect" + + "github.com/gin-gonic/gin" +) + +//go:embed migrations/*/*.sql +var mgMigrations embed.FS + +// Option configures the message_gateway plugin. +type Option func(*Plugin) + +// WithAutoStartRunner enables automatic bot runner startup in the background. +func WithAutoStartRunner(enable bool) Option { + return func(p *Plugin) { + p.autoStartRunner = enable + } +} + +// Plugin implements core.Plugin to provide Bot gateway and notification dispatch domain services. +type Plugin struct { + autoStartRunner bool + cancelRunner context.CancelFunc +} + +// New creates a new message_gateway domain plugin. +func New(opts ...Option) *Plugin { + p := &Plugin{} + for _, opt := range opts { + if opt != nil { + opt(p) + } + } + return p +} + +// Name returns the unique identifier for the message_gateway domain plugin. +func (p *Plugin) Name() string { + return "message_gateway" +} + +// Inject declares required dependencies for the message_gateway domain plugin. +func (p *Plugin) Inject() []reflect.Type { + return []reflect.Type{ + reflect.TypeFor[contracts.DBService](), + // AuthService is captured as a middleware value in Apply, so it cannot + // be late-bound with core.When like the other services below; the + // kernel must mount auth first or the routes get a pass-through guard. + reflect.TypeFor[contracts.AuthService](), + } +} + +// Manifest returns the plugin metadata. +func (p *Plugin) Manifest() core.Manifest { + return core.Manifest{ + Name: "message_gateway", + Version: "1.0.0", + Description: "Bot gateway, multi-channel notification push, and async worker dispatch plugin", + Author: "Wavelet Team", + } +} + +type mgAppConfig struct { + SessionSecret string `config:"session_secret" env:"APP_SESSION_SECRET" secret:"true"` +} + +// DeclareConfig declares configuration bindings for the message_gateway plugin. +func (p *Plugin) DeclareConfig() []core.ConfigBinding { + return []core.ConfigBinding{ + {Prefix: "app", Target: &mgAppConfig{}}, + } +} + +// Apply registers message_gateway migrations, routes, tasks, schedules, events, and settings into the Context. +func (p *Plugin) Apply(ctx *core.Context) error { + var cfg mgAppConfig + if err := ctx.Config().Bind("app", &cfg); err == nil && cfg.SessionSecret != "" { + service.SetCredentialSecret(cfg.SessionSecret) + } + // 0. Bind DBService, CacheService, TaskService, UserService + if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil { + repository.SetDBService(db) + } else { + core.When[contracts.DBService](ctx, func(db contracts.DBService) { + repository.SetDBService(db) + }) + } + if cache, err := core.Inject[contracts.CacheService](ctx); err == nil && cache != nil { + repository.SetCacheService(cache) + service.SetCacheService(cache) + } else { + core.When[contracts.CacheService](ctx, func(cache contracts.CacheService) { + repository.SetCacheService(cache) + service.SetCacheService(cache) + }) + } + if taskSvc, err := core.Inject[contracts.TaskService](ctx); err == nil && taskSvc != nil { + service.SetTaskService(taskSvc) + } else { + core.When[contracts.TaskService](ctx, func(taskSvc contracts.TaskService) { + service.SetTaskService(taskSvc) + }) + } + if uSvc, err := core.Inject[contracts.UserService](ctx); err == nil && uSvc != nil { + service.SetUserService(uSvc) + } else { + core.When[contracts.UserService](ctx, func(uSvc contracts.UserService) { + service.SetUserService(uSvc) + }) + } + ctx.OnDispose(func() error { + repository.SetDBService(nil) + repository.SetCacheService(nil) + service.SetCacheService(nil) + service.SetTaskService(nil) + service.SetUserService(nil) + return nil + }) + + // 0. Resolve auth service for middleware (via IoC, not direct import) + denyAuth := ginutil.AuthUnavailable() + loginMW := denyAuth + adminMW := denyAuth + if authSvc, err := core.Inject[contracts.AuthService](ctx); err == nil && authSvc != nil { + if mw, ok := authSvc.RequireAuthMiddleware().(gin.HandlerFunc); ok { + loginMW = mw + } + if mw, ok := authSvc.RequireAdminMiddleware().(gin.HandlerFunc); ok { + adminMW = mw + } + } + + // 1. Register migrations + ctx.Migrations().Register("message_gateway", mgMigrations) + + // 2. Register User HTTP Routes + handler.RegisterUserRoutes(ctx.Router().Group("/api/v1"), loginMW) + + // 3. Register Admin Message Gateway HTTP Routes + handler.RegisterAdminRoutes(ctx.Router().Group("/api/v1/admin"), loginMW, adminMW) + + // 4. Register Admin Push HTTP Routes + handler.RegisterAdminPushRoutes(ctx.Router().Group("/api/v1/admin"), loginMW, adminMW) + + const defaultTaskRetry = 3 + pushHandler := &service.PushHandler{} + + // 5. Register background tasks + ctx.Task().Register("message_gateway:push_notification", func(c context.Context, payload []byte) error { + return pushHandler.Execute(c, payload) + }, + extpoints.WithTaskType("push_notification"), + extpoints.WithTaskName("消息网关推送通知"), + extpoints.WithTaskDescription("异步执行系统通知的多渠道派发与推送"), + extpoints.WithTaskCategory("push"), + extpoints.WithTaskRetry(defaultTaskRetry), + extpoints.WithTaskQueue("default"), + extpoints.WithTaskRetryable(true), + ) + + ctx.Task().Register(service.SendNotificationTask, func(c context.Context, payload []byte) error { + return pushHandler.Execute(c, payload) + }, extpoints.WithTaskMeta(service.SendNotificationMeta), extpoints.WithTaskRetry(defaultTaskRetry)) + + ctx.Task().Register("message_gateway:dispatch_bot_msg", func(_ context.Context, _ []byte) error { + return nil + }, + extpoints.WithTaskType("dispatch_bot_msg"), + extpoints.WithTaskName("分发 Bot 消息"), + extpoints.WithTaskDescription("异步处理与分发 Bot 下行消息"), + extpoints.WithTaskCategory("messaging"), + extpoints.WithTaskQueue("default"), + ) + + ctx.Task().Register("message_gateway:cleanup_pairing_codes", func(c context.Context, _ []byte) error { + return repository.DeleteExpiredPairingCodes(c) + }, + extpoints.WithTaskType("cleanup_pairing_codes"), + extpoints.WithTaskName("清理过期配对码"), + extpoints.WithTaskDescription("定时清理已过期的平台 Bot 配对码"), + extpoints.WithTaskCategory("messaging"), + extpoints.WithTaskRetry(defaultTaskRetry), + extpoints.WithTaskQueue("default"), + extpoints.WithTaskRetryable(true), + ) + + // 6. Register Cron Schedules + ctx.Schedule().RegisterCron("*/10 * * * *", "message_gateway:cleanup_pairing_codes", map[string]any{"action": "cleanup"}) + + // 7. Register EventBus listeners for decoupled push triggers + ctx.Events().On("notification:push", func(c context.Context, e model.PushNotificationEvent) error { + meta := model.EventMetadata{ + Key: "eventbus:" + e.Channel, + Name: e.Title, + DefaultTemplate: model.NotificationMessage{ + Title: e.Title, + Content: e.Content, + Level: model.DefaultLevelInfo, + Ext: e.Metadata, + }, + Description: "EventBus triggered notification", + } + service.DefaultTrigger.Trigger(c, meta, map[string]any{ + "user.id": e.UserID, + "title": e.Title, + "content": e.Content, + }) + return nil + }) + + // 8. Register task completed event listener + ctx.Events().On(contracts.EventTopicTaskCompleted, func(c context.Context, e contracts.TaskCompletedEvent) error { + service.HandleTaskCompleted(c, e) + return nil + }) + + // 9. Register built-in domain events + service.RegisterCustomEvents() + + // 10. Register Settings Schemas + ctx.Settings().Register(extpoints.SettingSchema{ + Key: "message_gateway.pairing_code_expiry_minutes", + Default: 15, + Description: "Expiry duration for bot pairing codes in minutes", + Type: "integer", + Category: "messaging", + }) + ctx.Settings().Register(extpoints.SettingSchema{ + Key: "message_gateway.max_bindings_per_user", + Default: 5, + Description: "Maximum platform bot bindings per user", + Type: "integer", + Category: "messaging", + }) + + // 11. Optional runner start & lifecycle + if p.autoStartRunner { + runnerCtx, cancel := context.WithCancel(ctx.GoContext()) + p.cancelRunner = cancel + util.Go(func() { + _ = service.Start(runnerCtx) + }) + } + + ctx.OnDispose(func() error { + if p.cancelRunner != nil { + p.cancelRunner() + } + return nil + }) + + return nil +} + +// Re-exported constants. +const ( + CodeAlphabet = service.CodeAlphabet + CodeLength = service.CodeLength +) + +// MessageChannel is an alias for model.MessageChannel. +type MessageChannel = model.MessageChannel + +// MessageBinding is an alias for model.MessageBinding. +type MessageBinding = model.MessageBinding + +// MessagePairingCode is an alias for model.MessagePairingCode. +type MessagePairingCode = model.MessagePairingCode + +// PushChannel is an alias for model.PushChannel. +type PushChannel = model.PushChannel + +// PushEvent is an alias for model.PushEvent. +type PushEvent = model.PushEvent + +// PushHistory is an alias for model.PushHistory. +type PushHistory = model.PushHistory + +// PushNotificationEvent is an alias for model.PushNotificationEvent. +type PushNotificationEvent = model.PushNotificationEvent + +// ChannelConfig is an alias for model.ChannelConfig. +type ChannelConfig = model.ChannelConfig + +// Capability is an alias for model.Capability. +type Capability = model.Capability + +// Recipient is an alias for model.Recipient. +type Recipient = model.Recipient + +// Attachment is an alias for model.Attachment. +type Attachment = model.Attachment + +// InboundMessage is an alias for model.InboundMessage. +type InboundMessage = model.InboundMessage + +// OutboundMessage is an alias for model.OutboundMessage. +type OutboundMessage = model.OutboundMessage + +// BindingDTO is an alias for model.BindingDTO. +type BindingDTO = model.BindingDTO + +// PublicChannelDTO is an alias for model.PublicChannelDTO. +type PublicChannelDTO = model.PublicChannelDTO + +// Definition is an alias for model.Definition. +type Definition = model.Definition + +// ChannelDTO is an alias for model.ChannelDTO. +type ChannelDTO = model.ChannelDTO + +// CreateChannelRequest is an alias for model.CreateChannelRequest. +type CreateChannelRequest = model.CreateChannelRequest + +// UpdateChannelRequest is an alias for model.UpdateChannelRequest. +type UpdateChannelRequest = model.UpdateChannelRequest + +// PushDefinition is an alias for model.PushDefinition. +type PushDefinition = model.PushDefinition + +// PushField is an alias for model.PushField. +type PushField = model.PushField + +// NotificationMessage is an alias for model.NotificationMessage. +type NotificationMessage = model.NotificationMessage + +// EventMetadata is an alias for model.EventMetadata. +type EventMetadata = model.EventMetadata + +// SendPayload is an alias for model.SendPayload. +type SendPayload = model.SendPayload + +// Handler is an alias for service.Handler. +type Handler = service.Handler + +// Factory is an alias for service.Factory. +type Factory = service.Factory + +// Channel is an alias for service.Channel. +type Channel = service.Channel + +// Runner is an alias for service.Runner. +type Runner = service.Runner + +// EventTrigger is an alias for service.EventTrigger. +type EventTrigger = service.EventTrigger + +// PushHandler is an alias for service.PushHandler. +type PushHandler = service.PushHandler + +// Re-exported variables and functions. +var ( + SetDBServiceForTest = repository.SetDBServiceForTest + UpsertPairingCode = repository.UpsertPairingCode + Register = service.Register + Lookup = service.Lookup + GenerateCode = service.GenerateCode + NormalizeCode = service.NormalizeCode + FormatCode = service.FormatCode + Start = service.Start + Stop = service.Stop + GlobalRunner = service.GlobalRunner + DefaultTrigger = service.DefaultTrigger + SyncEvents = service.SyncEvents + AdminLogin = service.AdminLogin + HandleAdminLoggedIn = service.HandleAdminLoggedIn +) diff --git a/backend/plugins/domain/message_gateway/plugin_test.go b/backend/plugins/domain/message_gateway/plugin_test.go new file mode 100644 index 00000000..3a6f8ef2 --- /dev/null +++ b/backend/plugins/domain/message_gateway/plugin_test.go @@ -0,0 +1,61 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package message_gateway_test + +import ( + "Wavelet/core" + "Wavelet/plugins/domain/message_gateway" + "context" + "io/fs" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMessageGatewayPluginUnit(t *testing.T) { + ctx := core.NewContext(context.Background()) + p := message_gateway.New() + assert.Equal(t, "message_gateway", p.Name()) + assert.Equal(t, "1.0.0", p.Manifest().Version) + require.NoError(t, p.Apply(ctx)) + + // Verify migrations + entry, ok := ctx.Migrations().Get("message_gateway") + require.True(t, ok) + entries, err := fs.ReadDir(entry.FS, entry.Dir) + require.NoError(t, err) + assert.NotEmpty(t, entries) + + // Verify tasks + task, ok := ctx.Tasks().Get("message_gateway:push_notification") + require.True(t, ok) + assert.Equal(t, 3, task.Retry) + + // Verify schedules + sched, ok := ctx.Schedules().Get("message_gateway:cleanup_pairing_codes") + require.True(t, ok) + assert.Equal(t, "*/10 * * * *", sched.Spec) + + // Verify settings + setting, ok := ctx.Settings().Get("message_gateway.max_bindings_per_user") + require.True(t, ok) + assert.Equal(t, 5, setting.Default) +} + +// TestEveryScheduleHasTaskHandler 回归:RegisterCron 仅登记调度;若同名任务从未 +// Register,则每次触发都投递到无人处理的任务类型,清理逻辑静默失效。 +func TestEveryScheduleHasTaskHandler(t *testing.T) { + ctx := core.NewContext(context.Background()) + require.NoError(t, message_gateway.New().Apply(ctx)) + + schedules := ctx.Schedules().Schedules() + require.NotEmpty(t, schedules) + + for _, sched := range schedules { + _, ok := ctx.Tasks().Get(sched.TaskType) + assert.Truef(t, ok, "schedule %q dispatches to task %q, which is never registered", + sched.Spec, sched.TaskType) + } +} diff --git a/backend/plugins/domain/message_gateway/push/custom.go b/backend/plugins/domain/message_gateway/push/custom.go new file mode 100644 index 00000000..942bc183 --- /dev/null +++ b/backend/plugins/domain/message_gateway/push/custom.go @@ -0,0 +1,97 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package push + +import ( + "Wavelet/pkg/httppool" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" +) + +func init() { + Register("custom", &CustomPusher{}) +} + +// maxCustomResponseBytes 限制读取 Webhook 响应体的最大字节数,防止无界读取。 +const maxCustomResponseBytes = 4096 + +// CustomPusher 自定义 Webhook 发送实现 +type CustomPusher struct{} + +// Send 发送自定义 webhook +func (p *CustomPusher) Send(ctx context.Context, cfg Config, _ string, body map[string]any, template string, _ map[string]any) (string, error) { + if cfg.URL == "" { + return "", errors.New("custom: URL is required") + } + + var reqBody []byte + + if template != "" { + // 替换模板中的 {{key}} 占位符 + rendered := ParseTemplate(template, body) + reqBody = []byte(rendered) + } else { + // 兜底:直接把 body 转为 JSON 字符串发送 + var err error + reqBody, err = json.Marshal(body) + if err != nil { + return "", fmt.Errorf("custom: marshal body failed: %w", err) + } + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.URL, bytes.NewReader(reqBody)) + if err != nil { + return "", fmt.Errorf("custom: create http request failed: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + // 如果配置了 Key 且格式为 "HeaderName:HeaderValue",我们可以附加测试用 Header + if cfg.Key != "" && strings.Contains(cfg.Key, ":") { + parts := strings.SplitN(cfg.Key, ":", 2) //nolint:mnd + httpReq.Header.Set(strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])) + } + + client := httppool.NewClient(defaultHTTPClientTimeout) + resp, err := client.Do(httpReq) + if err != nil { + return "", fmt.Errorf("custom: http request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, maxCustomResponseBytes)) + upstreamResp := strings.TrimSpace(string(bodyBytes)) + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return upstreamResp, fmt.Errorf("custom: http status %s", resp.Status) + } + + // 部分 Webhook(如企业微信、钉钉)即使业务失败也返回 HTTP 200, + // 仅当响应体包含非零 errcode 时才判定为发送失败,避免审计记录误报成功。 + var apiResp struct { + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + } + if err := json.Unmarshal(bodyBytes, &apiResp); err == nil && apiResp.ErrCode != 0 { + return upstreamResp, fmt.Errorf("custom: webhook rejected: errcode=%d errmsg=%q", apiResp.ErrCode, apiResp.ErrMsg) + } + + return upstreamResp, nil +} + +// ValidateConfig 校验自定义配置 +func (p *CustomPusher) ValidateConfig(cfg Config) error { + if cfg.URL == "" { + return errors.New("webhook URL is required") + } + if !strings.HasPrefix(cfg.URL, "http://") && !strings.HasPrefix(cfg.URL, "https://") { + return errors.New("webhook URL must start with http:// or https://") + } + return nil +} diff --git a/backend/plugins/domain/message_gateway/push/custom_test.go b/backend/plugins/domain/message_gateway/push/custom_test.go new file mode 100644 index 00000000..fb622e65 --- /dev/null +++ b/backend/plugins/domain/message_gateway/push/custom_test.go @@ -0,0 +1,91 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package push + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCustomPusherSend_ResponseBodyErrcode(t *testing.T) { + tests := []struct { + name string + statusCode int + body string + wantErr bool + wantErrMsg string + }{ + { + name: "wechat business error returns HTTP 200 with non-zero errcode", + statusCode: http.StatusOK, + body: `{"errcode":93000,"errmsg":"invalid request data"}`, + wantErr: true, + wantErrMsg: "errcode=93000", + }, + { + name: "wechat success returns errcode 0", + statusCode: http.StatusOK, + body: `{"errcode":0,"errmsg":"ok"}`, + wantErr: false, + }, + { + name: "json response without errcode is tolerated", + statusCode: http.StatusOK, + body: `{"success":true}`, + wantErr: false, + }, + { + name: "non-json response body is tolerated", + statusCode: http.StatusOK, + body: "ok", + wantErr: false, + }, + { + name: "empty response body is tolerated", + statusCode: http.StatusNoContent, + body: "", + wantErr: false, + }, + { + name: "http error status still fails", + statusCode: http.StatusInternalServerError, + body: `{"errcode":0,"errmsg":"ok"}`, + wantErr: true, + wantErrMsg: "http status", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tt.statusCode) + _, _ = w.Write([]byte(tt.body)) + })) + defer srv.Close() + + pusher := &CustomPusher{} + upstreamResp, err := pusher.Send(context.Background(), + Config{Channel: "custom", URL: srv.URL}, + "", + map[string]any{"title": "t", "content": "c"}, + `{"title":"$title","content":"$content"}`, + nil, + ) + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErrMsg) + return + } + assert.NoError(t, err) + if tt.body != "" { + assert.Contains(t, upstreamResp, tt.body) + } + }) + } +} diff --git a/backend/plugins/domain/message_gateway/push/email.go b/backend/plugins/domain/message_gateway/push/email.go new file mode 100644 index 00000000..2948c577 --- /dev/null +++ b/backend/plugins/domain/message_gateway/push/email.go @@ -0,0 +1,104 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package push + +import ( + "Wavelet/pkg/util" + "context" + "errors" + "fmt" + "net" + "net/smtp" + "strings" +) + +func init() { + Register("email", &EmailPusher{}) +} + +// EmailPusher 极简 SMTP 邮件推送实现 (静态、解耦) +type EmailPusher struct{} + +// sanitizeEmailHeader removes CR/LF bytes so untrusted values cannot inject +// additional email headers (email header injection). +func sanitizeEmailHeader(v string) string { + v = strings.ReplaceAll(v, "\r", "") + v = strings.ReplaceAll(v, "\n", "") + return v +} + +// Send 发送邮件 +func (p *EmailPusher) Send(ctx context.Context, cfg Config, target string, body map[string]any, _ string, ext map[string]any) (string, error) { + if cfg.URL == "" || cfg.Key == "" || cfg.Secret == "" { + return "", errors.New("email: SMTP configuration (url, key, secret) is incomplete") + } + if target == "" { + return "", errors.New("email: target email address is required") + } + + title := bodyTitle(body) + content := bodyContent(body, "

%s: %v

", "") + + // 邮件头和体 + from := cfg.Key + to := target + + // 如果 ext 中指定了 from_name,我们在 From 头部包含它 + fromName := "System Notification" + if ext != nil { + if fn, ok := ext["from_name"].(string); ok && fn != "" { + fromName = fn + } + } + + subjectHeader := fmt.Sprintf("Subject: %s\r\n", sanitizeEmailHeader(title)) + fromHeader := fmt.Sprintf("From: %s <%s>\r\n", sanitizeEmailHeader(fromName), sanitizeEmailHeader(from)) + toHeader := fmt.Sprintf("To: %s\r\n", sanitizeEmailHeader(to)) + mimeHeader := "MIME-version: 1.0;\r\nContent-Type: text/html; charset=\"UTF-8\";\r\n\r\n" + + // 拼装完整的邮件报文 + // 简单的 HTML 正文渲染 + htmlBody := fmt.Sprintf(`

%s

%s
`, title, content) + msg := []byte(fromHeader + toHeader + subjectHeader + mimeHeader + htmlBody + "\r\n") + + // 解析 Host 和 Port + host, port, err := net.SplitHostPort(cfg.URL) + if err != nil { + host = cfg.URL + port = "25" // 默认 SMTP 端口 + } + + auth := smtp.PlainAuth("", cfg.Key, cfg.Secret, host) + + // 异步超时处理 + errChan := make(chan error, 1) + util.Go(func() { + errChan <- smtp.SendMail(host+":"+port, auth, from, []string{to}, msg) + }) + + select { + case <-ctx.Done(): + return "", ctx.Err() + case err := <-errChan: + if err != nil { + return "", fmt.Errorf("email: send smtp mail failed: %w", err) + } + } + + return "", nil +} + +// ValidateConfig 校验邮件 SMTP 配置 +func (p *EmailPusher) ValidateConfig(cfg Config) error { + if cfg.URL == "" { + return errors.New("SMTP host:port is required") + } + if cfg.Key == "" { + return errors.New("SMTP username is required") + } + if cfg.Secret == "" { + return errors.New("SMTP password is required") + } + return nil +} diff --git a/backend/plugins/domain/message_gateway/push/email_test.go b/backend/plugins/domain/message_gateway/push/email_test.go new file mode 100644 index 00000000..fb7367cc --- /dev/null +++ b/backend/plugins/domain/message_gateway/push/email_test.go @@ -0,0 +1,26 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package push + +import "testing" + +func TestSanitizeEmailHeader(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"plain", "System Notification", "System Notification"}, + {"crlf stripped", "alert\r\nBcc: attacker@example.com", "alertBcc: attacker@example.com"}, + {"cr stripped", "a\rb", "ab"}, + {"lf stripped", "a\nb", "ab"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := sanitizeEmailHeader(tt.input); got != tt.want { + t.Errorf("sanitizeEmailHeader(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} diff --git a/backend/plugins/domain/message_gateway/push/lark.go b/backend/plugins/domain/message_gateway/push/lark.go new file mode 100644 index 00000000..afa2cd58 --- /dev/null +++ b/backend/plugins/domain/message_gateway/push/lark.go @@ -0,0 +1,256 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package push + +import ( + "Wavelet/pkg/httppool" + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" +) + +func init() { + Register("lark", &LarkPusher{}) +} + +const ( + msgTypeInteractive = "interactive" +) + +// LarkPusher 飞书 Webhook 机器人推送实现 +type LarkPusher struct{} + +type larkTextContent struct { + Text string `json:"text"` +} + +type larkCardHeaderTitle struct { + Content string `json:"content"` + Tag string `json:"tag"` +} + +type larkCardHeader struct { + Template string `json:"template"` // "blue", "orange", "red" etc. + Title larkCardHeaderTitle `json:"title"` +} + +type larkCardElementText struct { + Content string `json:"content"` + Tag string `json:"tag"` // "lark_md" +} + +type larkCardElement struct { + Tag string `json:"tag"` // "div" + Text larkCardElementText `json:"text"` +} + +type larkCardContent struct { + Header larkCardHeader `json:"header"` + Elements []larkCardElement `json:"elements"` +} + +type larkMessageRequest struct { + MessageType string `json:"msg_type"` + Timestamp string `json:"timestamp,omitempty"` + Sign string `json:"sign,omitempty"` + Content larkTextContent `json:"content,omitempty"` + Card *larkCardContent `json:"card,omitempty"` +} + +type larkMessageResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` +} + +// Send 执行飞书消息发送 +// +//nolint:nestif,cyclop +func (p *LarkPusher) Send(ctx context.Context, cfg Config, _ string, body map[string]any, template string, _ map[string]any) (string, error) { + if cfg.URL == "" { + return "", errors.New("lark: URL is required") + } + + var req larkMessageRequest + + // 1. 如果有自定义模板,我们尝试进行解析 + if template != "" { + rendered := ParseTemplate(template, body) + + // 尝试解析原生的 Lark Card + var customCard larkCardContent + var rawMap map[string]any + _ = json.Unmarshal([]byte(rendered), &rawMap) + + if rawMap != nil && rawMap["elements"] != nil { + // 如果包含 elements 字段,说明是用户定制的原生飞书卡片 JSON + if err := json.Unmarshal([]byte(rendered), &customCard); err == nil { + req.MessageType = msgTypeInteractive + req.Card = &customCard + } else { + req.MessageType = "text" + req.Content.Text = rendered + } + } else { + // 说明配置的是系统统一通知消息 of JSON 模板:{"title": "...", "content": "...", "level": "..."} + type larkNotificationMessage struct { + Title string `json:"title"` + Content string `json:"content"` + Level string `json:"level"` + } + var msg larkNotificationMessage + if err := json.Unmarshal([]byte(rendered), &msg); err == nil && (msg.Title != "" || msg.Content != "") { + title := msg.Title + if title == "" { + title = defaultTitle + } + content := msg.Content + level := strings.ToUpper(msg.Level) + if level == "" { + level = levelInfo + } + + headerColor := "blue" + switch level { + case "IMPORTANT": + headerColor = "orange" + case "CRITICAL": + headerColor = "red" + } + + req.MessageType = msgTypeInteractive + req.Card = &larkCardContent{ + Header: larkCardHeader{ + Template: headerColor, + Title: larkCardHeaderTitle{ + Content: title, + Tag: "plain_text", + }, + }, + Elements: []larkCardElement{ + { + Tag: "div", + Text: larkCardElementText{ + Content: content, + Tag: "lark_md", + }, + }, + }, + } + } else { + // 兜底:如果无法按 JSON 解析出结构化字段,当做普通文本发送 + req.MessageType = "text" + req.Content.Text = rendered + } + } + } else { + // 2. 如果无模板,默认生成一个精美的飞书互动卡片 + title := bodyTitle(body) + content := bodyContent(body, "**%s**: %v", "\n") + level := bodyLevel(body) + + // 根据级别确定飞书卡片头部的背景色模板 + headerColor := "blue" + switch level { + case "IMPORTANT": + headerColor = "orange" + case "CRITICAL": + headerColor = "red" + } + + req.MessageType = msgTypeInteractive + req.Card = &larkCardContent{ + Header: larkCardHeader{ + Template: headerColor, + Title: larkCardHeaderTitle{ + Content: title, + Tag: "plain_text", + }, + }, + Elements: []larkCardElement{ + { + Tag: "div", + Text: larkCardElementText{ + Content: content, + Tag: "lark_md", + }, + }, + }, + } + } + + // 3. 计算签名 (如果配置了 secret) + if cfg.Secret != "" { + timestamp := time.Now().Unix() + sign, err := larkSign(cfg.Secret, timestamp) + if err != nil { + return "", fmt.Errorf("lark: sign failed: %w", err) + } + req.Timestamp = strconv.FormatInt(timestamp, 10) + req.Sign = sign + } + + jsonData, err := json.Marshal(req) + if err != nil { + return "", fmt.Errorf("lark: marshal request failed: %w", err) + } + + // 4. 发送 POST 请求 + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.URL, bytes.NewBuffer(jsonData)) + if err != nil { + return "", fmt.Errorf("lark: create http request failed: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + client := httppool.NewClient(defaultHTTPClientTimeout) + resp, err := client.Do(httpReq) + if err != nil { + return "", fmt.Errorf("lark: http request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("lark: http status %s", resp.Status) + } + + var res larkMessageResponse + if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { + return "", fmt.Errorf("lark: decode response failed: %w", err) + } + + if res.Code != 0 { + return "", fmt.Errorf("lark: send message failed, code %d: %s", res.Code, res.Msg) + } + + return "", nil +} + +// ValidateConfig 校验飞书配置 +func (p *LarkPusher) ValidateConfig(cfg Config) error { + if cfg.URL == "" { + return errors.New("webhook URL is required") + } + if !strings.HasPrefix(cfg.URL, "http://") && !strings.HasPrefix(cfg.URL, "https://") { + return errors.New("webhook URL must start with http:// or https://") + } + return nil +} + +func larkSign(secret string, timestamp int64) (string, error) { + stringToSign := fmt.Sprintf("%v", timestamp) + "\n" + secret + h := hmac.New(sha256.New, []byte(stringToSign)) + _, err := h.Write(nil) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(h.Sum(nil)), nil +} diff --git a/backend/plugins/domain/message_gateway/push/push.go b/backend/plugins/domain/message_gateway/push/push.go new file mode 100644 index 00000000..e035d62a --- /dev/null +++ b/backend/plugins/domain/message_gateway/push/push.go @@ -0,0 +1,67 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package push 提供解耦的、无外部业务依赖 of 通知推送底层实现 +package push + +import ( + "context" + "fmt" + "sync" + "time" +) + +const ( + defaultTitle = "系统通知" + levelInfo = "INFO" + defaultHTTPClientTimeout = 10 * time.Second +) + +// Config 基础通知渠道配置 +type Config struct { + Channel string `json:"channel"` // 渠道名称,例如 "lark", "custom", "email" 等,唯一标识 + URL string `json:"url,omitempty"` // Webhook 地址或 SMTP 地址 + Secret string `json:"secret,omitempty"` // 签名密钥或 SMTP 密码/Token + Key string `json:"key,omitempty"` // AppID 或 SMTP 用户名 + Ext map[string]any `json:"ext,omitempty"` // 预留拓展 JSON 配置 +} + +// Pusher 通知推送渠道接口 +type Pusher interface { + // Send 发送通知消息 + // target: 发送目标 (如邮箱地址或特定用户标识;若为 bot 机器人此项为空) + // body: 消息体数据 (含默认字段如 title, content, level) + // template: 消息卡片/模板 JSON (可选) + // ext: 预留的单次发送拓展数据 + // 返回 upstreamResp: 上游服务返回的响应内容(如 Webhook 响应体),用于任务日志审计;无响应时为空字符串 + Send(ctx context.Context, cfg Config, target string, body map[string]any, template string, ext map[string]any) (upstreamResp string, err error) + + // ValidateConfig 校验渠道配置合法性 + ValidateConfig(cfg Config) error +} + +var ( + pushersMu sync.RWMutex + pushers = make(map[string]Pusher) +) + +// Register 注册一个推送渠道实现 +func Register(channelType string, pusher Pusher) { + pushersMu.Lock() + defer pushersMu.Unlock() + if pusher == nil { + panic("push: Register pusher is nil") + } + pushers[channelType] = pusher +} + +// GetPusher 获取指定类型的推送渠道实现 +func GetPusher(channelType string) (Pusher, error) { + pushersMu.RLock() + defer pushersMu.RUnlock() + pusher, ok := pushers[channelType] + if !ok { + return nil, fmt.Errorf("push: unknown channel type %q", channelType) + } + return pusher, nil +} diff --git a/backend/plugins/domain/message_gateway/push/telegram.go b/backend/plugins/domain/message_gateway/push/telegram.go new file mode 100644 index 00000000..68f6ba12 --- /dev/null +++ b/backend/plugins/domain/message_gateway/push/telegram.go @@ -0,0 +1,140 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package push + +import ( + "Wavelet/pkg/httppool" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" +) + +func init() { + Register("telegram", &TelegramPusher{}) +} + +// TelegramPusher Telegram 机器人推送实现 +type TelegramPusher struct{} + +type telegramMessageRequest struct { + ChatID string `json:"chat_id"` + Text string `json:"text"` + ParseMode string `json:"parse_mode,omitempty"` +} + +type telegramErrorResponse struct { + Ok bool `json:"ok"` + ErrorCode int `json:"error_code"` + Description string `json:"description"` +} + +// Send 执行 Telegram 消息发送 +func (p *TelegramPusher) Send(ctx context.Context, cfg Config, target string, body map[string]any, template string, _ map[string]any) (string, error) { + if cfg.Secret == "" { + return "", errors.New("telegram: Bot Token (Secret) is required") + } + + chatID := target + if chatID == "" { + chatID = cfg.Key // Use default chat ID (Key) if target is blank + } + if chatID == "" { + return "", errors.New("telegram: chat_id (target or default Key) is required") + } + + baseURL := cfg.URL + if baseURL == "" { + baseURL = "https://api.telegram.org" + } + baseURL = strings.TrimSuffix(baseURL, "/") + + title := bodyTitle(body) + content := bodyContent(body, "%s: %v", "\n") + level := bodyLevel(body) + + var text string + if template != "" { + text = ParseTemplate(template, body) + } else { + text = fmt.Sprintf("[%s] %s\n\n%s", escapeHTML(level), escapeHTML(title), escapeHTML(content)) + } + + // Try sending with HTML parse mode + err := p.sendMessage(ctx, baseURL, cfg.Secret, chatID, text, "HTML") + if err != nil { + // Fallback: send as plain text without parse mode + plainText := text + if template == "" { + plainText = fmt.Sprintf("[%s] %s\n\n%s", level, title, content) + } + fallbackErr := p.sendMessage(ctx, baseURL, cfg.Secret, chatID, plainText, "") + if fallbackErr != nil { + return "", fmt.Errorf("telegram: send message failed (fallback also failed): %w (original HTML error: %w)", fallbackErr, err) + } + } + + return "", nil +} + +// ValidateConfig 校验 Telegram 配置 +func (p *TelegramPusher) ValidateConfig(cfg Config) error { + if cfg.Secret == "" { + return errors.New("bot Token (Secret) is required") + } + if cfg.URL != "" { + if !strings.HasPrefix(cfg.URL, "http://") && !strings.HasPrefix(cfg.URL, "https://") { + return errors.New("API base URL must start with http:// or https://") + } + } + return nil +} + +func (p *TelegramPusher) sendMessage(ctx context.Context, baseURL, token, chatID, text, parseMode string) error { + apiURL := fmt.Sprintf("%s/bot%s/sendMessage", baseURL, token) + + reqPayload := telegramMessageRequest{ + ChatID: chatID, + Text: text, + ParseMode: parseMode, + } + + jsonData, err := json.Marshal(reqPayload) + if err != nil { + return fmt.Errorf("marshal request failed: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("create http request failed: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + client := httppool.NewClient(defaultHTTPClientTimeout) + resp, err := client.Do(httpReq) + if err != nil { + return fmt.Errorf("http request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + var errRes telegramErrorResponse + if decodeErr := json.NewDecoder(resp.Body).Decode(&errRes); decodeErr == nil { + return fmt.Errorf("http status %d: %s", resp.StatusCode, errRes.Description) + } + return fmt.Errorf("http status %s", resp.Status) + } + + return nil +} + +func escapeHTML(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + return s +} diff --git a/backend/plugins/domain/message_gateway/push/telegram_test.go b/backend/plugins/domain/message_gateway/push/telegram_test.go new file mode 100644 index 00000000..74fcf728 --- /dev/null +++ b/backend/plugins/domain/message_gateway/push/telegram_test.go @@ -0,0 +1,116 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package push + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTelegramPusher_Send(t *testing.T) { + t.Run("successful send with HTML parse mode", func(t *testing.T) { + var receivedReq telegramMessageRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/botmy-token/sendMessage", r.URL.Path) + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + + err := json.NewDecoder(r.Body).Decode(&receivedReq) + require.NoError(t, err) + + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok": true}`)) + })) + defer server.Close() + + pusher := &TelegramPusher{} + cfg := Config{ + Channel: "telegram", + URL: server.URL, + Secret: "my-token", + } + body := map[string]any{ + "title": "Alert", + "content": "Host down", + "level": "CRITICAL", + } + _, err := pusher.Send(context.Background(), cfg, "123456", body, "", nil) + require.NoError(t, err) + + assert.Equal(t, "123456", receivedReq.ChatID) + assert.Contains(t, receivedReq.Text, "[CRITICAL] Alert") + assert.Contains(t, receivedReq.Text, "Host down") + assert.Equal(t, "HTML", receivedReq.ParseMode) + }) + + t.Run("fallback to plain text on HTML error", func(t *testing.T) { + var requests []*telegramMessageRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req telegramMessageRequest + err := json.NewDecoder(r.Body).Decode(&req) + require.NoError(t, err) + requests = append(requests, &req) + + if len(requests) == 1 { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"ok": false, "error_code": 400, "description": "Bad Request: can't parse entities"}`)) + } else { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok": true}`)) + } + })) + defer server.Close() + + pusher := &TelegramPusher{} + cfg := Config{ + Channel: "telegram", + URL: server.URL, + Secret: "my-token", + } + body := map[string]any{ + "title": "Alert & Info", + "content": "A < B comparison", + "level": "INFO", + } + _, err := pusher.Send(context.Background(), cfg, "123456", body, "", nil) + require.NoError(t, err) + + require.Len(t, requests, 2) + assert.Equal(t, "HTML", requests[0].ParseMode) + assert.Equal(t, "", requests[1].ParseMode) + assert.Contains(t, requests[1].Text, "[INFO] Alert & Info") + assert.Contains(t, requests[1].Text, "A < B comparison") + }) + + t.Run("validation error", func(t *testing.T) { + pusher := &TelegramPusher{} + cfg := Config{ + Channel: "telegram", + URL: "https://api.telegram.org", + } + err := pusher.ValidateConfig(cfg) + assert.Error(t, err) + + cfg = Config{ + Channel: "telegram", + URL: "ftp://api.telegram.org", + Secret: "token", + } + err = pusher.ValidateConfig(cfg) + assert.Error(t, err) + + cfg = Config{ + Channel: "telegram", + Secret: "token", + } + err = pusher.ValidateConfig(cfg) + assert.NoError(t, err) + }) +} diff --git a/backend/plugins/domain/message_gateway/push/template.go b/backend/plugins/domain/message_gateway/push/template.go new file mode 100644 index 00000000..9e472bd3 --- /dev/null +++ b/backend/plugins/domain/message_gateway/push/template.go @@ -0,0 +1,111 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package push + +import ( + "encoding/json" + "fmt" + "maps" + "slices" + "strconv" + "strings" +) + +// ParseTemplate parses template strings by replacing {{placeholder}} structures with values from body. +// It is a single-pass parser designed for high performance and low allocations. +func ParseTemplate(template string, body map[string]any) string { + var buf strings.Builder + buf.Grow(len(template)) + + i := 0 + for { + pos := strings.Index(template[i:], "{{") + if pos == -1 { + buf.WriteString(template[i:]) + break + } + // Write prefix + buf.WriteString(template[i : i+pos]) + i += pos + 2 // skip "{{" + + endPos := strings.Index(template[i:], "}}") + if endPos == -1 { + // Unbalanced "{{" + buf.WriteString("{{") + buf.WriteString(template[i:]) + break + } + key := template[i : i+endPos] + if val, ok := body[key]; ok { + buf.WriteString(formatValue(val)) + } else { + // Keep the placeholder if key not found + buf.WriteString("{{") + buf.WriteString(key) + buf.WriteString("}}") + } + i += endPos + 2 // skip "}}" + } + return buf.String() +} + +func formatValue(v any) string { + if v == nil { + return "" + } + switch val := v.(type) { + case string: + return val + case []byte: + return string(val) + case int: + return strconv.Itoa(val) + case int32: + return strconv.FormatInt(int64(val), 10) + case int64: + return strconv.FormatInt(val, 10) + case float64: + return strconv.FormatFloat(val, 'f', -1, 64) + case bool: + return strconv.FormatBool(val) + default: + // If it's a map, slice, or struct, marshal it to JSON. + b, err := json.Marshal(v) + if err == nil { + return string(b) + } + return fmt.Sprintf("%v", v) + } +} + +// bodyTitle returns the notification title, falling back to the default. +func bodyTitle(body map[string]any) string { + if t, ok := body["title"].(string); ok && t != "" { + return t + } + return defaultTitle +} + +// bodyContent returns the notification body, rendering every entry with format +// (a "%s … %v" pair) and joining them with sep when no content field is given. +// Entries render in sorted key order so identical bodies always produce +// identical text. +func bodyContent(body map[string]any, format, sep string) string { + if c, ok := body["content"].(string); ok && c != "" { + return c + } + parts := make([]string, 0, len(body)) + for _, k := range slices.Sorted(maps.Keys(body)) { + parts = append(parts, fmt.Sprintf(format, k, body[k])) + } + return strings.Join(parts, sep) +} + +// bodyLevel returns the upper-cased notification level, falling back to INFO. +func bodyLevel(body map[string]any) string { + if l, ok := body["level"].(string); ok && l != "" { + return strings.ToUpper(l) + } + return levelInfo +} diff --git a/backend/plugins/domain/message_gateway/push/template_test.go b/backend/plugins/domain/message_gateway/push/template_test.go new file mode 100644 index 00000000..5132212a --- /dev/null +++ b/backend/plugins/domain/message_gateway/push/template_test.go @@ -0,0 +1,96 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package push + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseTemplate(t *testing.T) { + tests := []struct { + name string + template string + body map[string]any + expected string + }{ + { + name: "simple replacement", + template: "hello {{name}}", + body: map[string]any{"name": "world"}, + expected: "hello world", + }, + { + name: "multiple replacements", + template: "{{greeting}} {{name}}!", + body: map[string]any{"greeting": "Hello", "name": "Alice"}, + expected: "Hello Alice!", + }, + { + name: "missing key preserves placeholder", + template: "hello {{name}} and {{other}}", + body: map[string]any{"name": "world"}, + expected: "hello world and {{other}}", + }, + { + name: "unbalanced placeholders", + template: "hello {{name", + body: map[string]any{"name": "world"}, + expected: "hello {{name", + }, + { + name: "nil value", + template: "val: {{val}}", + body: map[string]any{"val": nil}, + expected: "val: ", + }, + { + name: "basic types", + template: "int: {{i}}, float: {{f}}, bool: {{b}}", + body: map[string]any{"i": 123, "f": 45.67, "b": true}, + expected: "int: 123, float: 45.67, bool: true", + }, + { + name: "complex type slice", + template: "items: {{items}}", + body: map[string]any{"items": []string{"a", "b"}}, + expected: `items: ["a","b"]`, + }, + { + name: "complex type map", + template: "obj: {{obj}}", + body: map[string]any{"obj": map[string]any{"key": "value"}}, + expected: `obj: {"key":"value"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ParseTemplate(tt.template, tt.body) + assert.Equal(t, tt.expected, result) + }) + } +} + +// Synthesized content must render in a stable order, otherwise two identical +// notifications produce different text on every send. +func TestBodyContentFallbackIsDeterministic(t *testing.T) { + body := map[string]any{ + "zebra": 1, + "alpha": 2, + "mike": 3, + "charlie": 4, + "yankee": 5, + } + + first := bodyContent(body, "%s=%v", ",") + for i := 1; i <= 50; i++ { + if got := bodyContent(body, "%s=%v", ","); got != first { + t.Fatalf("bodyContent order changed on call %d: %q != %q", i, got, first) + } + } + + assert.Equal(t, "alpha=2,charlie=4,mike=3,yankee=5,zebra=1", first) +} diff --git a/backend/plugins/domain/message_gateway/registry_test.go b/backend/plugins/domain/message_gateway/registry_test.go new file mode 100644 index 00000000..ad86d322 --- /dev/null +++ b/backend/plugins/domain/message_gateway/registry_test.go @@ -0,0 +1,38 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package message_gateway + +import ( + "context" + "testing" +) + +type stubChannel struct{} + +func (stubChannel) Type() string { return "stub" } +func (stubChannel) Connect(context.Context) error { + return nil +} +func (stubChannel) Disconnect(context.Context) error { return nil } +func (stubChannel) Send(context.Context, Recipient, OutboundMessage) error { + return nil +} +func (stubChannel) Capabilities() Capability { return Capability{Text: true} } + +func TestRegisterLookup(t *testing.T) { + Register("stub", func(ChannelConfig, Handler) (Channel, error) { + return stubChannel{}, nil + }) + fn, ok := Lookup("stub") + if !ok { + t.Fatal("expected factory") + } + ch, err := fn(ChannelConfig{}, nil) + if err != nil { + t.Fatal(err) + } + if ch.Type() != "stub" { + t.Fatalf("type=%s", ch.Type()) + } +} diff --git a/backend/plugins/domain/message_gateway/repository/push.go b/backend/plugins/domain/message_gateway/repository/push.go new file mode 100644 index 00000000..a944c3c9 --- /dev/null +++ b/backend/plugins/domain/message_gateway/repository/push.go @@ -0,0 +1,364 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package repository + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "Wavelet/plugins/domain/message_gateway/errs" + "Wavelet/plugins/domain/message_gateway/model" + "context" + "errors" + "fmt" + "sync" + "time" + + "gorm.io/gorm" +) + +const ( + activePushChannelCacheTTL = 24 * time.Hour + activePushEventCacheTTL = 24 * time.Hour +) + +var ( + cacheMu sync.RWMutex + cacheSvc contracts.CacheService +) + +// SetCacheService sets the cache service singleton. +func SetCacheService(s contracts.CacheService) { + cacheMu.Lock() + defer cacheMu.Unlock() + cacheSvc = s +} + +// GetCache resolves the cache service for the current call. +func GetCache(ctx context.Context) contracts.CacheService { + if c, ok := ctx.(*core.Context); ok && c != nil { + if s, err := core.Inject[contracts.CacheService](c); err == nil && s != nil { + return s + } + } + cacheMu.RLock() + s := cacheSvc + cacheMu.RUnlock() + return s +} + +// ListPushChannelsRecord returns all push channels ordered by creation time descending. +func ListPushChannelsRecord(ctx context.Context) ([]model.PushChannel, error) { + var channels []model.PushChannel + if err := GetDB(ctx).Order("created_at DESC").Find(&channels).Error; err != nil { + return nil, err + } + return channels, nil +} + +// GetPushChannelByIDRecord loads a push channel by primary key. +func GetPushChannelByIDRecord(ctx context.Context, id uint64) (model.PushChannel, error) { + var channel model.PushChannel + if err := GetDB(ctx).Where("id = ?", id).First(&channel).Error; err != nil { + return model.PushChannel{}, mapNotFound(err) + } + return channel, nil +} + +// GetPushChannelByNameRecord loads a push channel by its unique name. +func GetPushChannelByNameRecord(ctx context.Context, name string) (*model.PushChannel, error) { + var channel model.PushChannel + if err := GetDB(ctx).Where("name = ?", name).First(&channel).Error; err != nil { + return nil, mapNotFound(err) + } + return &channel, nil +} + +// CountPushChannelsByNameRecord returns how many channels share the given name. +func CountPushChannelsByNameRecord(ctx context.Context, name string) (int64, error) { + var count int64 + if err := GetDB(ctx).Model(&model.PushChannel{}).Where("name = ?", name).Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + +// CreatePushChannelRecord persists a new channel and invalidates cache. +func CreatePushChannelRecord(ctx context.Context, channel *model.PushChannel) error { + if err := GetDB(ctx).Create(channel).Error; err != nil { + return err + } + DeleteActivePushChannelCache(ctx, channel.Name) + return nil +} + +// SavePushChannelRecord updates a channel and invalidates cache. +func SavePushChannelRecord(ctx context.Context, channel *model.PushChannel) error { + if err := GetDB(ctx).Save(channel).Error; err != nil { + return err + } + DeleteActivePushChannelCache(ctx, channel.Name) + return nil +} + +// DeletePushChannelRecord removes a channel and invalidates cache. +func DeletePushChannelRecord(ctx context.Context, channel *model.PushChannel) error { + if err := GetDB(ctx).Delete(channel).Error; err != nil { + return err + } + DeleteActivePushChannelCache(ctx, channel.Name) + return nil +} + +func getCachedOrQuery[T any](ctx context.Context, cacheKey string, ttl time.Duration, query func(db *gorm.DB, dest *T) error) (*T, error) { + var val T + if cache := GetCache(ctx); cache != nil { + if err := cache.Get(ctx, cacheKey, &val); err == nil { + return &val, nil + } + } + + db := GetDB(ctx) + if err := query(db, &val); err != nil { + return nil, err + } + + if cache := GetCache(ctx); cache != nil { + _ = cache.Set(ctx, cacheKey, val, ttl) + } + + return &val, nil +} + +// GetActivePushChannelByName loads an enabled push channel, preferring the cache layer. +func GetActivePushChannelByName(ctx context.Context, name string) (*model.PushChannel, error) { + channel, err := getCachedOrQuery(ctx, "push:channel:active:"+name, activePushChannelCacheTTL, func(db *gorm.DB, dest *model.PushChannel) error { + return db.Where("name = ? AND enabled = ?", name, true).First(dest).Error + }) + if err != nil { + return nil, mapNotFound(err) + } + return channel, nil +} + +// DeleteActivePushChannelCache drops the cached enabled-channel entry. +func DeleteActivePushChannelCache(ctx context.Context, name string) { + if cache := GetCache(ctx); cache != nil { + _ = cache.Delete(ctx, "push:channel:active:"+name) + } +} + +// ListPushEventsRecord returns all push events ordered by creation time descending. +func ListPushEventsRecord(ctx context.Context) ([]model.PushEvent, error) { + var events []model.PushEvent + if err := GetDB(ctx).Order("created_at DESC").Find(&events).Error; err != nil { + return nil, err + } + return events, nil +} + +// GetPushEventByIDRecord loads a push event by primary key. +func GetPushEventByIDRecord(ctx context.Context, id uint64) (model.PushEvent, error) { + var event model.PushEvent + if err := GetDB(ctx).First(&event, id).Error; err != nil { + return model.PushEvent{}, mapNotFound(err) + } + return event, nil +} + +// GetPushEventByKeyRecord loads a push event by event key. +func GetPushEventByKeyRecord(ctx context.Context, key string) (model.PushEvent, error) { + var event model.PushEvent + if err := GetDB(ctx).Where("event_key = ?", key).First(&event).Error; err != nil { + return model.PushEvent{}, mapNotFound(err) + } + return event, nil +} + +// CountPushEventsByKeyRecord returns how many events use the given event key. +func CountPushEventsByKeyRecord(ctx context.Context, key string) (int64, error) { + var count int64 + if err := GetDB(ctx).Model(&model.PushEvent{}).Where("event_key = ?", key).Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + +// CreatePushEventRecord persists a new push event and invalidates cache. +func CreatePushEventRecord(ctx context.Context, event *model.PushEvent) error { + if err := GetDB(ctx).Create(event).Error; err != nil { + return err + } + DeleteActivePushEventCache(ctx, event.EventKey) + return nil +} + +// SavePushEventRecord updates a push event and invalidates cache. +func SavePushEventRecord(ctx context.Context, event *model.PushEvent) error { + if err := GetDB(ctx).Save(event).Error; err != nil { + return err + } + DeleteActivePushEventCache(ctx, event.EventKey) + return nil +} + +// UpdatePushEventEnabledRecord toggles the enabled flag for a push event. +func UpdatePushEventEnabledRecord(ctx context.Context, event *model.PushEvent, enabled bool) error { + event.Enabled = enabled + if err := GetDB(ctx).Model(event).Update("enabled", enabled).Error; err != nil { + return err + } + DeleteActivePushEventCache(ctx, event.EventKey) + return nil +} + +// DeletePushEventRecord removes a push event and invalidates cache. +func DeletePushEventRecord(ctx context.Context, event *model.PushEvent) error { + if err := GetDB(ctx).Delete(event).Error; err != nil { + return err + } + DeleteActivePushEventCache(ctx, event.EventKey) + return nil +} + +// ListActivePushEventsByTaskTypeRecord returns enabled events bound to a task type. +func ListActivePushEventsByTaskTypeRecord(ctx context.Context, taskType string) ([]model.PushEvent, error) { + var events []model.PushEvent + if err := GetDB(ctx).Where("task_type = ? AND enabled = ?", taskType, true).Find(&events).Error; err != nil { + return nil, err + } + return events, nil +} + +// GetActivePushEventByKey loads an enabled push event, preferring the cache layer. +func GetActivePushEventByKey(ctx context.Context, key string) (*model.PushEvent, error) { + event, err := getCachedOrQuery(ctx, "push:event:active:"+key, activePushEventCacheTTL, func(db *gorm.DB, dest *model.PushEvent) error { + return db.Where("event_key = ? AND enabled = ?", key, true).First(dest).Error + }) + if err != nil { + return nil, mapNotFound(err) + } + return event, nil +} + +// DeleteActivePushEventCache drops the cached enabled-event entry. +func DeleteActivePushEventCache(ctx context.Context, key string) { + if cache := GetCache(ctx); cache != nil { + _ = cache.Delete(ctx, "push:event:active:"+key) + } +} + +// ListPushHistoriesRecord returns paginated push history records. +func ListPushHistoriesRecord(ctx context.Context, filter model.PushHistoryListFilter) (int64, []model.PushHistory, error) { + query := GetDB(ctx).Model(&model.PushHistory{}).Order("created_at DESC") + if filter.EventKey != "" { + query = query.Where("event_key = ?", filter.EventKey) + } + if filter.Status != "" { + query = query.Where("status = ?", filter.Status) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return 0, nil, err + } + + var results []model.PushHistory + offset := (filter.Page - 1) * filter.PageSize + if err := query.Offset(offset).Limit(filter.PageSize).Find(&results).Error; err != nil { + return 0, nil, err + } + + return total, results, nil +} + +// CreatePushHistoryRecord persists a push history audit record. +func CreatePushHistoryRecord(ctx context.Context, history *model.PushHistory) error { + return GetDB(ctx).Create(history).Error +} + +// PushHistoryQuery returns a scoped query builder for push histories. +func PushHistoryQuery(ctx context.Context) *gorm.DB { + return GetDB(ctx).Model(&model.PushHistory{}) +} + +// smtpConfigKeys are the system-config rows backing the built-in email channel. +var smtpConfigKeys = []string{"smtp_host", "smtp_port", "smtp_username", "smtp_password"} + +// LoadSMTPConfigRecord reads the SMTP settings in one query. +// +// A key that is simply absent leaves its field empty, which is how an unconfigured +// mailer is represented. A read that fails is returned as an error, so callers +// cannot mistake an unhealthy database for "no SMTP configured" and silently drop +// the notification. +func LoadSMTPConfigRecord(ctx context.Context) (model.SMTPConfig, error) { + db := GetDB(ctx) + if db == nil { + return model.SMTPConfig{}, errors.New("database not available") + } + + var rows []struct { + Key string + Value string + } + if err := db.Table("w_system_configs"). + Select("key", "value"). + Where("key IN ?", smtpConfigKeys). + Find(&rows).Error; err != nil { + return model.SMTPConfig{}, fmt.Errorf("read smtp system configs: %w", err) + } + + var cfg model.SMTPConfig + for _, row := range rows { + switch row.Key { + case "smtp_host": + cfg.Host = row.Value + case "smtp_port": + cfg.Port = row.Value + case "smtp_username": + cfg.Username = row.Value + case "smtp_password": + cfg.Password = row.Value + } + } + return cfg, nil +} + +// userLookupColumns allow-lists the columns FindUserByFieldRecord may filter on. +// The column name is concatenated into the WHERE clause, so anything not listed +// here must never reach the database. +var userLookupColumns = map[string]struct{}{ + "id": {}, + "username": {}, +} + +// FindUserByFieldRecord is the user lookup fallback for when the UserService +// contract is not wired yet. field must be one of userLookupColumns. +func FindUserByFieldRecord(ctx context.Context, field string, value any) (*contracts.UserDTO, error) { + if _, ok := userLookupColumns[field]; !ok { + return nil, errs.ErrUnsupportedUserLookupField + } + db := GetDB(ctx) + if db == nil { + return nil, errs.ErrRecordNotFound + } + var user contracts.UserDTO + if err := db.Table("w_users").Where(field+" = ?", value).First(&user).Error; err != nil { + return nil, err + } + return &user, nil +} + +// FindFirstAdminUserRecord is the admin lookup fallback for when the UserService +// contract is not wired yet. +func FindFirstAdminUserRecord(ctx context.Context) (*contracts.UserDTO, error) { + db := GetDB(ctx) + if db == nil { + return nil, errs.ErrRecordNotFound + } + var adminUser contracts.UserDTO + if err := db.Table("w_users").Where("is_admin = ?", true).Order("id ASC").First(&adminUser).Error; err != nil { + return nil, err + } + return &adminUser, nil +} diff --git a/backend/plugins/domain/message_gateway/repository/push_test.go b/backend/plugins/domain/message_gateway/repository/push_test.go new file mode 100644 index 00000000..18376ccd --- /dev/null +++ b/backend/plugins/domain/message_gateway/repository/push_test.go @@ -0,0 +1,131 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package repository_test + +import ( + "Wavelet/pkg/testhelper" + "Wavelet/plugins/domain/message_gateway/errs" + "Wavelet/plugins/domain/message_gateway/repository" + "context" + "errors" + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +// stubDBService satisfies contracts.DBService over a test database handle. +type stubDBService struct{ db *gorm.DB } + +func (s stubDBService) GORM() *gorm.DB { return s.db } + +func (s stubDBService) DB(_ context.Context) *gorm.DB { return s.db } + +func (s stubDBService) Named(_ string) *gorm.DB { return s.db } + +// TestFindUserByFieldRecordRejectsUnlistedColumns pins the column allow-list. The +// lookup column is interpolated into SQL, so an unlisted name must be refused before +// any query is built rather than trusted because call sites happen to pass literals. +func TestFindUserByFieldRecordRejectsUnlistedColumns(t *testing.T) { + db, _, cleanup := testhelper.SetupTestEnvironment(t) + defer cleanup() + + if err := db.Table("w_users").Create(map[string]any{"id": 77, "username": "seeded"}).Error; err != nil { + t.Fatalf("seed user failed: %v", err) + } + + repository.SetDBServiceForTest(stubDBService{db: db}) + t.Cleanup(func() { repository.SetDBServiceForTest(nil) }) + + ctx := context.Background() + + user, err := repository.FindUserByFieldRecord(ctx, "username", "seeded") + if err != nil { + t.Fatalf("allowlisted lookup by username failed: %v", err) + } + if user.ID != 77 { + t.Errorf("allowlisted lookup returned ID %d, want 77", user.ID) + } + if _, err := repository.FindUserByFieldRecord(ctx, "id", uint64(77)); err != nil { + t.Errorf("allowlisted lookup by id failed: %v", err) + } + + cases := []struct { + name string + field string + }{ + {"tautology injection", `username = '' OR 1=1 --`}, + {"stacked statement", "id; DROP TABLE w_users"}, + {"column outside allow-list", "password"}, + {"empty field", ""}, + } + for _, tc := range cases { + if _, err := repository.FindUserByFieldRecord(ctx, tc.field, "seeded"); !errors.Is(err, errs.ErrUnsupportedUserLookupField) { + t.Errorf("%s: got err %v, want ErrUnsupportedUserLookupField", tc.name, err) + } + } + + var remaining int64 + if err := db.Table("w_users").Count(&remaining).Error; err != nil || remaining != 1 { + t.Fatalf("w_users damaged by rejected lookups: count=%d err=%v", remaining, err) + } +} + +// smtpTestValues are the four system-config rows the built-in email channel reads. +var smtpTestValues = map[string]string{ + "smtp_host": "mail.example.test", + "smtp_port": "465", + "smtp_username": "notify@example.test", + "smtp_password": "s3cret-value", +} + +// TestLoadSMTPConfigRecordMapsEveryKey guards the single-query rewrite: every field +// must still be filled from its own row. +func TestLoadSMTPConfigRecordMapsEveryKey(t *testing.T) { + db, _, cleanup := testhelper.SetupTestEnvironment(t) + defer cleanup() + + keys := make([]string, 0, len(smtpTestValues)) + for key := range smtpTestValues { + keys = append(keys, key) + } + if err := db.Table("w_system_configs").Where("key IN ?", keys).Delete(map[string]any{}).Error; err != nil { + t.Fatalf("clear smtp rows: %v", err) + } + for _, key := range keys { + row := map[string]any{"key": key, "value": smtpTestValues[key], "type": "system"} + if err := db.Table("w_system_configs").Create(row).Error; err != nil { + t.Fatalf("seed %s: %v", key, err) + } + } + + repository.SetDBServiceForTest(stubDBService{db: db}) + t.Cleanup(func() { repository.SetDBServiceForTest(nil) }) + + cfg, err := repository.LoadSMTPConfigRecord(context.Background()) + if err != nil { + t.Fatalf("LoadSMTPConfigRecord: %v", err) + } + if cfg.Host != smtpTestValues["smtp_host"] || cfg.Port != smtpTestValues["smtp_port"] || + cfg.Username != smtpTestValues["smtp_username"] || cfg.Password != smtpTestValues["smtp_password"] { + t.Errorf("got %+v, want every SMTP field mapped from its own row", cfg) + } +} + +// TestLoadSMTPConfigRecordSurfacesReadFailure pins the actual defect: a read that +// fails used to be discarded, returning four blank strings that callers could only +// interpret as "SMTP was never configured", so the notification was dropped silently. +func TestLoadSMTPConfigRecordSurfacesReadFailure(t *testing.T) { + bare, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open bare sqlite: %v", err) + } + + repository.SetDBServiceForTest(stubDBService{db: bare}) + t.Cleanup(func() { repository.SetDBServiceForTest(nil) }) + + if _, err := repository.LoadSMTPConfigRecord(context.Background()); err == nil { + t.Fatal("LoadSMTPConfigRecord returned nil error although the config table cannot be read") + } +} diff --git a/backend/plugins/domain/message_gateway/repository/repository.go b/backend/plugins/domain/message_gateway/repository/repository.go new file mode 100644 index 00000000..b6e5302a --- /dev/null +++ b/backend/plugins/domain/message_gateway/repository/repository.go @@ -0,0 +1,199 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package repository provides data persistence for the message_gateway plugin. +package repository + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "Wavelet/pkg/idgen" + "Wavelet/plugins/domain/message_gateway/errs" + "Wavelet/plugins/domain/message_gateway/model" + "context" + "errors" + "sync" + "time" + + "gorm.io/gorm" +) + +var ( + dbMu sync.RWMutex + dbSvc contracts.DBService +) + +// SetDBServiceForTest injects a DBService for tests. Production wiring must use Apply. +func SetDBServiceForTest(s contracts.DBService) { + SetDBService(s) +} + +// SetDBService sets the database service singleton. +func SetDBService(s contracts.DBService) { + dbMu.Lock() + defer dbMu.Unlock() + dbSvc = s +} + +// GetDB resolves the persistence handle for the current call, preferring an +// explicitly injected *core.Context before falling back to the plugin singleton. +func GetDB(ctx context.Context) *gorm.DB { + if c, ok := ctx.(*core.Context); ok && c != nil { + if s, err := core.Inject[contracts.DBService](c); err == nil && s != nil { + return s.DB(ctx) + } + } + dbMu.RLock() + s := dbSvc + dbMu.RUnlock() + if s != nil { + return s.DB(ctx) + } + return nil +} + +// mapNotFound translates GORM's missing-row sentinel into the plugin-level +// errs.ErrRecordNotFound so the service and handler layers stay free of gorm imports. +func mapNotFound(err error) error { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errs.ErrRecordNotFound + } + return err +} + +// CreateMessageChannel inserts a channel row. +func CreateMessageChannel(ctx context.Context, ch *model.MessageChannel) error { + if ch.ID == 0 { + ch.ID = idgen.NextUint64ID() + } + return GetDB(ctx).Create(ch).Error +} + +// UpdateMessageChannel saves a channel row. +func UpdateMessageChannel(ctx context.Context, ch *model.MessageChannel) error { + return GetDB(ctx).Save(ch).Error +} + +// GetMessageChannel loads a channel by id. +func GetMessageChannel(ctx context.Context, id uint64) (*model.MessageChannel, error) { + var ch model.MessageChannel + if err := GetDB(ctx).Where("id = ?", id).First(&ch).Error; err != nil { + return nil, mapNotFound(err) + } + return &ch, nil +} + +// ListMessageChannels returns all channels newest first. +func ListMessageChannels(ctx context.Context) ([]model.MessageChannel, error) { + var rows []model.MessageChannel + if err := GetDB(ctx).Order("id DESC").Find(&rows).Error; err != nil { + return nil, err + } + return rows, nil +} + +// DeleteMessageChannel removes pairings, bindings, then the channel. +func DeleteMessageChannel(ctx context.Context, id uint64) error { + return GetDB(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("channel_id = ?", id).Delete(&model.MessagePairingCode{}).Error; err != nil { + return err + } + if err := tx.Where("channel_id = ?", id).Delete(&model.MessageBinding{}).Error; err != nil { + return err + } + return tx.Delete(&model.MessageChannel{}, id).Error + }) +} + +// CreateMessageBinding inserts a binding. +func CreateMessageBinding(ctx context.Context, b *model.MessageBinding) error { + if b.ID == 0 { + b.ID = idgen.NextUint64ID() + } + return GetDB(ctx).Create(b).Error +} + +// GetBindingByChannelPlatform finds a binding for a platform user on a channel. +func GetBindingByChannelPlatform(ctx context.Context, channelID uint64, platformUserID string) (*model.MessageBinding, error) { + var b model.MessageBinding + err := GetDB(ctx).Where("channel_id = ? AND platform_user_id = ?", channelID, platformUserID).First(&b).Error + if err != nil { + return nil, mapNotFound(err) + } + return &b, nil +} + +// ListBindingsByUser lists bindings for a Wavelet user. +func ListBindingsByUser(ctx context.Context, userID uint64) ([]model.MessageBinding, error) { + var rows []model.MessageBinding + if err := GetDB(ctx).Where("user_id = ?", userID).Order("id DESC").Find(&rows).Error; err != nil { + return nil, err + } + return rows, nil +} + +// GetMessageBinding loads a binding by id. +func GetMessageBinding(ctx context.Context, id uint64) (*model.MessageBinding, error) { + var b model.MessageBinding + if err := GetDB(ctx).Where("id = ?", id).First(&b).Error; err != nil { + return nil, mapNotFound(err) + } + return &b, nil +} + +// DeleteMessageBinding deletes a binding by id. +func DeleteMessageBinding(ctx context.Context, id uint64) error { + return GetDB(ctx).Delete(&model.MessageBinding{}, id).Error +} + +// UpsertPairingCode reuses an unexpired code for the same channel+platform user. +func UpsertPairingCode(ctx context.Context, channelID uint64, platformUserID, code string, expiresAt time.Time) (*model.MessagePairingCode, error) { + var existing model.MessagePairingCode + err := GetDB(ctx). + Where("channel_id = ? AND platform_user_id = ? AND expires_at > ?", channelID, platformUserID, time.Now()). + First(&existing).Error + if err == nil { + return &existing, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + row := &model.MessagePairingCode{ + Code: code, + ChannelID: channelID, + PlatformUserID: platformUserID, + ExpiresAt: expiresAt, + } + if err := GetDB(ctx).Create(row).Error; err != nil { + return nil, err + } + return row, nil +} + +// GetPairingCode loads a pairing code by normalized code string. +func GetPairingCode(ctx context.Context, code string) (*model.MessagePairingCode, error) { + var row model.MessagePairingCode + if err := GetDB(ctx).Where("code = ?", code).First(&row).Error; err != nil { + return nil, mapNotFound(err) + } + return &row, nil +} + +// DeletePairingCode removes a pairing code. +func DeletePairingCode(ctx context.Context, code string) error { + return GetDB(ctx).Where("code = ?", code).Delete(&model.MessagePairingCode{}).Error +} + +// DeleteExpiredPairingCodes removes expired pairing rows. +func DeleteExpiredPairingCodes(ctx context.Context) error { + return GetDB(ctx).Where("expires_at <= ?", time.Now()).Delete(&model.MessagePairingCode{}).Error +} + +// ListEnabledMessageChannels returns enabled channels. +func ListEnabledMessageChannels(ctx context.Context) ([]model.MessageChannel, error) { + var rows []model.MessageChannel + if err := GetDB(ctx).Where("enabled = ?", true).Find(&rows).Error; err != nil { + return nil, err + } + return rows, nil +} diff --git a/backend/plugins/domain/message_gateway/service/admin.go b/backend/plugins/domain/message_gateway/service/admin.go new file mode 100644 index 00000000..50633dea --- /dev/null +++ b/backend/plugins/domain/message_gateway/service/admin.go @@ -0,0 +1,310 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "Wavelet/plugins/domain/message_gateway/errs" + "Wavelet/plugins/domain/message_gateway/model" + "Wavelet/plugins/domain/message_gateway/repository" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/tencent-connect/botgo/token" +) + +const defaultTelegramAPI = "https://api.telegram.org" + +// ListDefinitions returns the admin form schema of every supported channel type. +func ListDefinitions() []model.Definition { + return []model.Definition{ + { + Type: model.MessageChannelTypeTelegram, + Fields: []model.Field{ + {Key: "token", Type: "password", Required: true}, + {Key: "api_base", Type: "text", Required: false}, + }, + }, + { + Type: model.MessageChannelTypeQQ, + Fields: []model.Field{ + {Key: "app_id", Type: "text", Required: true}, + {Key: "client_secret", Type: "password", Required: true}, + }, + }, + } +} + +// CreateChannel validates the admin payload and persists an encrypted channel. +func CreateChannel(ctx context.Context, req model.CreateChannelRequest) (model.ChannelDTO, error) { + name := strings.TrimSpace(req.Name) + if name == "" { + return model.ChannelDTO{}, errors.New(errs.ErrNameRequired) + } + channelType := strings.TrimSpace(req.Type) + if channelType != model.MessageChannelTypeTelegram && channelType != model.MessageChannelTypeQQ { + return model.ChannelDTO{}, errors.New(errs.ErrTypeInvalid) + } + creds := req.Credentials + if creds == nil { + creds = map[string]string{} + } + if err := ValidateCredentials(channelType, creds, false); err != nil { + return model.ChannelDTO{}, err + } + cipher, err := EncryptCredentials(creds) + if err != nil { + return model.ChannelDTO{}, err + } + extra := req.Extra + if extra == nil { + extra = map[string]string{} + } + enabled := true + if req.Enabled != nil { + enabled = *req.Enabled + } + row := &model.MessageChannel{ + Name: name, + Type: channelType, + OwnerScope: model.MessageOwnerScopeSystem, + Enabled: enabled, + Credentials: cipher, + Extra: EncodeExtra(extra), + } + if err := repository.CreateMessageChannel(ctx, row); err != nil { + return model.ChannelDTO{}, err + } + return ToDTO(row, creds, extra), nil +} + +// UpdateChannel patches a channel; empty secrets keep the stored ciphertext. +func UpdateChannel(ctx context.Context, id uint64, req model.UpdateChannelRequest) (model.ChannelDTO, error) { + row, err := repository.GetMessageChannel(ctx, id) + if err != nil { + if errors.Is(err, errs.ErrRecordNotFound) { + return model.ChannelDTO{}, errors.New(errs.ErrChannelNotFound) + } + return model.ChannelDTO{}, err + } + creds, err := DecryptCredentials(row.Credentials) + if err != nil { + return model.ChannelDTO{}, err + } + extra := ParseExtra(row.Extra) + + if name := strings.TrimSpace(req.Name); name != "" { + row.Name = name + } + if req.Enabled != nil { + row.Enabled = *req.Enabled + } + if req.Extra != nil { + extra = req.Extra + } + if len(req.Credentials) > 0 { + merged := make(map[string]string, len(creds)) + for k, v := range creds { + merged[k] = v + } + for k, v := range req.Credentials { + if strings.TrimSpace(v) == "" { + continue + } + merged[k] = v + } + if err := ValidateCredentials(row.Type, merged, true); err != nil { + return model.ChannelDTO{}, err + } + creds = merged + } + + cipher, err := EncryptCredentials(creds) + if err != nil { + return model.ChannelDTO{}, err + } + row.Credentials = cipher + row.Extra = EncodeExtra(extra) + if err := repository.UpdateMessageChannel(ctx, row); err != nil { + return model.ChannelDTO{}, err + } + return ToDTO(row, creds, extra), nil +} + +// ListChannels returns every channel with secrets masked. +func ListChannels(ctx context.Context) ([]model.ChannelDTO, error) { + rows, err := repository.ListMessageChannels(ctx) + if err != nil { + return nil, err + } + out := make([]model.ChannelDTO, 0, len(rows)) + for i := range rows { + creds, _ := DecryptCredentials(rows[i].Credentials) + extra := ParseExtra(rows[i].Extra) + out = append(out, ToDTO(&rows[i], creds, extra)) + } + return out, nil +} + +// DeleteChannel removes a channel together with its bindings and pairing codes. +func DeleteChannel(ctx context.Context, id uint64) error { + if _, err := repository.GetMessageChannel(ctx, id); err != nil { + if errors.Is(err, errs.ErrRecordNotFound) { + return errors.New(errs.ErrChannelNotFound) + } + return err + } + return repository.DeleteMessageChannel(ctx, id) +} + +// ProbeChannel verifies the stored credentials against the upstream platform. +func ProbeChannel(ctx context.Context, id uint64) error { + row, err := repository.GetMessageChannel(ctx, id) + if err != nil { + if errors.Is(err, errs.ErrRecordNotFound) { + return errors.New(errs.ErrChannelNotFound) + } + return err + } + creds, err := DecryptCredentials(row.Credentials) + if err != nil { + return err + } + switch row.Type { + case model.MessageChannelTypeTelegram: + return ProbeTelegram(ctx, creds) + case model.MessageChannelTypeQQ: + return ProbeQQ(ctx, creds) + default: + return errors.New(errs.ErrTypeInvalid) + } +} + +// ProbeTelegram calls getMe to confirm the bot token is usable. +func ProbeTelegram(ctx context.Context, creds map[string]string) error { + tok := creds["token"] + if strings.TrimSpace(tok) == "" { + return errors.New(errs.ErrMissingTelegramToken) + } + base := creds["api_base"] + base = strings.TrimRight(strings.TrimSpace(base), "/") + if base == "" { + base = defaultTelegramAPI + } + url := fmt.Sprintf("%s/bot%s/getMe", base, tok) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("%s (%d): %s", errs.ErrTelegramGetMeFailed, resp.StatusCode, string(body)) + } + var res struct { + OK bool `json:"ok"` + } + if err := json.Unmarshal(body, &res); err != nil { + return err + } + if !res.OK { + return fmt.Errorf("%s: %s", errs.ErrTelegramNotOK, string(body)) + } + return nil +} + +// ProbeQQ exchanges the app credentials for an access token. +func ProbeQQ(_ context.Context, creds map[string]string) error { + appID := strings.TrimSpace(creds["app_id"]) + secret := strings.TrimSpace(creds["client_secret"]) + if appID == "" || secret == "" { + return errors.New(errs.ErrMissingQQCredentials) + } + credentials := &token.QQBotCredentials{ + AppID: appID, + AppSecret: secret, + } + tokSrc := token.NewQQBotTokenSource(credentials) + tok, err := tokSrc.Token() + if err != nil { + return fmt.Errorf("%s: %w", errs.ErrQQTokenFetchFailed, err) + } + if tok == nil || tok.AccessToken == "" { + return errors.New(errs.ErrQQEmptyToken) + } + return nil +} + +// ValidateCredentials checks the admin submitted credentials for a channel type. +func ValidateCredentials(t string, creds map[string]string, isUpdate bool) error { + switch t { + case model.MessageChannelTypeTelegram: + tok := creds["token"] + if strings.TrimSpace(tok) == "" && !isUpdate { + return errors.New(errs.ErrTelegramTokenRequired) + } + if base, ok := creds["api_base"]; ok && strings.TrimSpace(base) != "" { + if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") { + return errors.New(errs.ErrAPIBaseInvalid) + } + } + case model.MessageChannelTypeQQ: + appID := creds["app_id"] + secret := creds["client_secret"] + if (strings.TrimSpace(appID) == "" || strings.TrimSpace(secret) == "") && !isUpdate { + return errors.New(errs.ErrQQCredentialsRequired) + } + default: + return errors.New(errs.ErrTypeInvalid) + } + return nil +} + +// ToDTO projects a channel row onto the admin DTO with credentials masked. +func ToDTO(row *model.MessageChannel, creds, extra map[string]string) model.ChannelDTO { + return model.ChannelDTO{ + ID: row.ID, + Name: row.Name, + Type: row.Type, + OwnerScope: row.OwnerScope, + OwnerID: row.OwnerID, + Enabled: row.Enabled, + Credentials: MaskCredentials(row.Type, creds), + Extra: extra, + } +} + +// MaskCredentials hides secret bearing credential entries. +func MaskCredentials(_ string, in map[string]string) map[string]string { + out := make(map[string]string, len(in)) + for k, v := range in { + if k == "token" || k == "client_secret" { + out[k] = MaskSecret(v) + } else { + out[k] = v + } + } + return out +} + +const minMaskSecretLength = 8 + +// MaskSecret keeps only a short visible prefix and suffix of a secret. +func MaskSecret(s string) string { + s = strings.TrimSpace(s) + if len(s) <= minMaskSecretLength { + return "******" + } + return s[:4] + "..." + s[len(s)-4:] +} diff --git a/backend/plugins/domain/message_gateway/service/push.go b/backend/plugins/domain/message_gateway/service/push.go new file mode 100644 index 00000000..e45f7bdc --- /dev/null +++ b/backend/plugins/domain/message_gateway/service/push.go @@ -0,0 +1,1128 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "Wavelet/core/contracts" + "Wavelet/pkg/logger" + "Wavelet/pkg/util" + "Wavelet/plugins/domain/message_gateway/errs" + "Wavelet/plugins/domain/message_gateway/model" + pkgpush "Wavelet/plugins/domain/message_gateway/push" + "Wavelet/plugins/domain/message_gateway/repository" + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "sync" + "time" +) + +var ( + builtInEventsMu sync.RWMutex + // BuiltInEvents lists all built-in events defined in custom_events. + BuiltInEvents []model.EventMetadata +) + +// RegisterBuiltInEvent registers a built-in event definition. +func RegisterBuiltInEvent(meta model.EventMetadata) { + builtInEventsMu.Lock() + defer builtInEventsMu.Unlock() + for i, e := range BuiltInEvents { + if e.Key == meta.Key { + BuiltInEvents[i] = meta + return + } + } + BuiltInEvents = append(BuiltInEvents, meta) +} + +// GetBuiltInEvents returns a copy of registered built-in events. +func GetBuiltInEvents() []model.EventMetadata { + builtInEventsMu.RLock() + defer builtInEventsMu.RUnlock() + out := make([]model.EventMetadata, len(BuiltInEvents)) + copy(out, BuiltInEvents) + return out +} + +// SyncBuiltInEvents seeds a database row for every registered built-in event. +func SyncBuiltInEvents(ctx context.Context) error { + for _, meta := range GetBuiltInEvents() { + _, err := repository.GetPushEventByKeyRecord(ctx, meta.Key) + if errors.Is(err, errs.ErrRecordNotFound) { + var defaultTemplateStr string + if defaultTemplateBytes, err := json.Marshal(meta.DefaultTemplate); err == nil { + defaultTemplateStr = string(defaultTemplateBytes) + } + event := model.PushEvent{ + EventKey: meta.Key, + Name: meta.Name, + Channels: []string{}, + Targets: []string{}, + Template: defaultTemplateStr, + Enabled: false, + } + if err := repository.CreatePushEventRecord(ctx, &event); err != nil { + return err + } + } else if err != nil { + return err + } + } + return nil +} + +// ListPushEvents lists all configured push events. +func ListPushEvents(ctx context.Context) ([]model.PushEvent, error) { + return repository.ListPushEventsRecord(ctx) +} + +// CreatePushEvent stores a push event configuration for a built-in event or task type. +func CreatePushEvent(ctx context.Context, req model.CreatePushEventRequest) (model.PushEvent, error) { + eventKey, eventName, defaultTemplateBytes, err := GetEventInfo(req) + if err != nil { + return model.PushEvent{}, err + } + + count, err := repository.CountPushEventsByKeyRecord(ctx, eventKey) + if err != nil { + return model.PushEvent{}, err + } + if count > 0 { + return model.PushEvent{}, errors.New(errs.ErrEventAlreadyConfigured) + } + + templateStr := strings.TrimSpace(req.Template) + if templateStr == "" { + templateStr = string(defaultTemplateBytes) + } else { + var tempMap map[string]any + if err := json.Unmarshal([]byte(templateStr), &tempMap); err != nil { + return model.PushEvent{}, errors.New(errs.ErrTemplateInvalidJSON) + } + } + + channels := req.Channels + if channels == nil { + channels = []string{} + } + targets := req.Targets + if targets == nil { + targets = []string{} + } + + event := model.PushEvent{ + EventKey: eventKey, + Name: eventName, + TaskType: req.TaskType, + Channels: channels, + Targets: targets, + Template: templateStr, + Enabled: req.Enabled, + } + if err := event.Validate(); err != nil { + return model.PushEvent{}, err + } + if err := repository.CreatePushEventRecord(ctx, &event); err != nil { + return model.PushEvent{}, err + } + return event, nil +} + +// DeletePushEvent deletes a push event configuration by id. +func DeletePushEvent(ctx context.Context, id uint64) error { + event, err := repository.GetPushEventByIDRecord(ctx, id) + if err != nil { + return err + } + return repository.DeletePushEventRecord(ctx, &event) +} + +// UpdatePushEvent replaces mutable push event fields. +func UpdatePushEvent(ctx context.Context, id uint64, req model.UpdatePushEventRequest) error { + event, err := repository.GetPushEventByIDRecord(ctx, id) + if err != nil { + return err + } + + event.Channels = req.Channels + event.Targets = req.Targets + event.Template = req.Template + event.Enabled = req.Enabled + if err := event.Validate(); err != nil { + return err + } + return repository.SavePushEventRecord(ctx, &event) +} + +// TogglePushEvent flips the enabled flag of a push event. +func TogglePushEvent(ctx context.Context, id uint64) (bool, error) { + event, err := repository.GetPushEventByIDRecord(ctx, id) + if err != nil { + return false, err + } + + enabled := !event.Enabled + if enabled && len(event.Channels) == 0 { + return false, errors.New(errs.ErrEnableWithoutChannels) + } + if err := repository.UpdatePushEventEnabledRecord(ctx, &event, enabled); err != nil { + return false, err + } + return enabled, nil +} + +// ListPushHistories returns a paginated push delivery audit page. +func ListPushHistories(ctx context.Context, filter model.PushHistoryListFilter) (int64, []model.PushHistory, error) { + return repository.ListPushHistoriesRecord(ctx, filter) +} + +// ApplySMTPFallbackToPushConfig fills an email config from the system SMTP settings. +func ApplySMTPFallbackToPushConfig(ctx context.Context, cfg *pkgpush.Config) { + if cfg.Channel != model.ChannelEmail || (cfg.URL != "" && cfg.Key != "") { + return + } + smtp, err := repository.LoadSMTPConfigRecord(ctx) + if err != nil { + logger.ErrorF(ctx, "[Push] 读取 SMTP 系统配置失败: %v", err) + return + } + if smtp.Host == "" || smtp.Username == "" { + return + } + port := smtp.Port + if port == "" { + port = "587" + } + cfg.URL = smtp.Host + ":" + port + cfg.Key = smtp.Username + cfg.Secret = smtp.Password +} + +// RunPushTest validates an ad-hoc channel config and sends a connectivity probe. +func RunPushTest(ctx context.Context, cfg pkgpush.Config, target string) error { + pusher, err := pkgpush.GetPusher(cfg.Channel) + if err != nil { + return err + } + if err := pusher.ValidateConfig(cfg); err != nil { + return fmt.Errorf("%s: %w", errs.ErrValidationFailed, err) + } + + ApplySMTPFallbackToPushConfig(ctx, &cfg) + + testBody := map[string]any{ + model.KeyTitle: "测试通道推送", + model.KeyContent: "当您收到这条消息,说明当前渠道连通性测试通过。", + model.KeyLevel: model.DefaultLevelInfo, + } + if _, err := pusher.Send(ctx, cfg, target, testBody, "", nil); err != nil { + return err + } + return nil +} + +// ListPushChannels returns every configured push channel. +func ListPushChannels(ctx context.Context) ([]model.PushChannel, error) { + return repository.ListPushChannelsRecord(ctx) +} + +// CreatePushChannel validates uniqueness and persists a new push channel. +func CreatePushChannel(ctx context.Context, req model.CreatePushChannelRequest) (model.PushChannel, error) { + count, err := repository.CountPushChannelsByNameRecord(ctx, req.Name) + if err != nil { + return model.PushChannel{}, err + } + if count > 0 { + return model.PushChannel{}, errors.New(errs.ErrChannelNameExists) + } + + channel := model.PushChannel{ + Name: req.Name, + Description: req.Description, + Type: req.Type, + Token: req.Token, + URL: req.URL, + Other: req.Other, + Enabled: req.Enabled, + } + if err := channel.Validate(); err != nil { + return model.PushChannel{}, err + } + if err := repository.CreatePushChannelRecord(ctx, &channel); err != nil { + return model.PushChannel{}, err + } + return channel, nil +} + +// UpdatePushChannel replaces the mutable fields of an existing push channel. +func UpdatePushChannel(ctx context.Context, id uint64, req model.UpdatePushChannelRequest) (model.PushChannel, error) { + channel, err := repository.GetPushChannelByIDRecord(ctx, id) + if err != nil { + return model.PushChannel{}, err + } + + channel.Description = req.Description + channel.Type = req.Type + channel.Token = req.Token + channel.URL = req.URL + channel.Other = req.Other + channel.Enabled = req.Enabled + if err := channel.Validate(); err != nil { + return model.PushChannel{}, err + } + if err := repository.SavePushChannelRecord(ctx, &channel); err != nil { + return model.PushChannel{}, err + } + return channel, nil +} + +// DeletePushChannel removes a push channel by id. +func DeletePushChannel(ctx context.Context, id uint64) error { + channel, err := repository.GetPushChannelByIDRecord(ctx, id) + if err != nil { + return err + } + return repository.DeletePushChannelRecord(ctx, &channel) +} + +// LoadChannelForTest resolves the credentials under test, either from a stored +// channel name or from the ad-hoc values sent by the caller. +func LoadChannelForTest(ctx context.Context, req model.TestPushChannelRequest) (string, string, string, string, error) { + if req.Name != "" { + channel, err := repository.GetPushChannelByNameRecord(ctx, req.Name) + if err != nil { + return "", "", "", "", errors.New(errs.ErrChannelNotFound) + } + return channel.URL, channel.Token, channel.Other, channel.Type, nil + } + return req.URL, req.Token, req.Other, req.Type, nil +} + +// PreparePushChannelTest builds the connectivity probe payload for a channel. +func PreparePushChannelTest(ctx context.Context, req model.TestPushChannelRequest) (model.SendPayload, error) { + url, token, other, channelType, err := LoadChannelForTest(ctx, req) + if err != nil { + return model.SendPayload{}, err + } + + if channelType == model.ChannelEmail { + url, token, other = ResolveSMTPConfig(ctx, url, token, other) + } + + tempChannel := model.PushChannel{ + Name: "test_temp", + URL: url, + Token: token, + Other: other, + Type: channelType, + Enabled: true, + } + if err := tempChannel.Validate(); err != nil { + return model.SendPayload{}, err + } + url = tempChannel.URL + + var config pkgpush.Config + var renderedJSON string + switch channelType { + case model.ChannelLark: + config = pkgpush.Config{Channel: model.ChannelLark, URL: url, Secret: token} + renderedJSON = other + case model.ChannelEmail: + config = pkgpush.Config{Channel: model.ChannelEmail, URL: url, Key: token, Secret: other} + case model.ChannelTelegram: + config = pkgpush.Config{Channel: model.ChannelTelegram, URL: url, Secret: token, Key: other} + default: + config = pkgpush.Config{Channel: model.ChannelCustom, URL: url} + customPushReq := model.CustomPushRequest{ + Title: "通道测试通知", + Content: "这是一条来自系统的消息通道连通性测试消息。", + Description: "系统通道测试", + URL: "https://example.com", + To: req.Target, + } + renderedJSON = RenderCustomPayload(other, customPushReq) + } + + return model.SendPayload{ + EventKey: "test_channel", + Config: config, + Target: req.Target, + Body: model.NotificationMessage{ + Title: "通道测试通知", + Content: "这是一条来自系统的消息通道连通性测试消息。", + Level: model.DefaultLevelInfo, + }, + Template: renderedJSON, + }, nil +} + +// RenderCustomPayload substitutes the supported template variables of a custom +// webhook body, JSON-escaping every injected value. +func RenderCustomPayload(template string, req model.CustomPushRequest) string { + result := template + result = strings.ReplaceAll(result, "$title", EscapeJSONString(req.Title)) + result = strings.ReplaceAll(result, "$description", EscapeJSONString(req.Description)) + result = strings.ReplaceAll(result, "$content", EscapeJSONString(req.Content)) + result = strings.ReplaceAll(result, "$url", EscapeJSONString(req.URL)) + result = strings.ReplaceAll(result, "$to", EscapeJSONString(req.To)) + return result +} + +// EscapeJSONString renders s as a JSON string body without the surrounding quotes. +func EscapeJSONString(s string) string { + b, _ := json.Marshal(s) + const minJSONLen = 2 + if len(b) >= minJSONLen { + return string(b[1 : len(b)-1]) + } + return s +} + +// ListActivePushEventsByTaskType returns enabled push events for a given task type. +func ListActivePushEventsByTaskType(ctx context.Context, taskType string) ([]model.PushEvent, error) { + return repository.ListActivePushEventsByTaskTypeRecord(ctx, taskType) +} + +// QueryUser resolves a user through the UserService contract, falling back to the +// repository read path while the contract is not wired yet. +func QueryUser(ctx context.Context, fromService func(contracts.UserService) (*contracts.UserDTO, error), dbField string, dbVal any) (*contracts.UserDTO, error) { + if userSvc := GetUserService(ctx); userSvc != nil { + return fromService(userSvc) + } + if user, err := repository.FindUserByFieldRecord(ctx, dbField, dbVal); err == nil && user != nil { + return user, nil + } + return nil, errors.New(errs.ErrUserNotFound) +} + +// FindUserByID resolves a user by primary key. +func FindUserByID(ctx context.Context, id uint64) (*contracts.UserDTO, error) { + return QueryUser(ctx, func(s contracts.UserService) (*contracts.UserDTO, error) { + return s.GetUserByID(ctx, id) + }, "id", id) +} + +// FindUserByUsername resolves a user by login name. +func FindUserByUsername(ctx context.Context, username string) (*contracts.UserDTO, error) { + return QueryUser(ctx, func(s contracts.UserService) (*contracts.UserDTO, error) { + return s.GetUserByUsername(ctx, username) + }, "username", username) +} + +// LoadUserFromPayload extracts user info from data. +func LoadUserFromPayload(ctx context.Context, data map[string]any) any { + if u, exists := data["user"]; exists && u != nil { + return u + } + + if userID, ok := ExtractUserID(data); ok && userID > 0 { + if user, err := FindUserByID(ctx, userID); err == nil && user != nil { + return user + } + } + + if username := ExtractUsername(data); username != "" { + if user, err := FindUserByUsername(ctx, username); err == nil && user != nil { + return user + } + } + return nil +} + +// RecordPushHistory creates a push history audit record. +func RecordPushHistory(ctx context.Context, req model.SendPayload, status, errMsg string) error { + title := req.Body.Title + content := req.Body.Content + level := req.Body.Level + if title == "" { + title = "系统通知" + } + if level == "" { + level = model.DefaultLevelInfo + } + + target := req.Target + if target == "" { + if req.Config.URL != "" { + target = req.Config.URL + const maxTargetLen = 50 + const truncatedLen = 47 + if len(target) > maxTargetLen { + target = target[:truncatedLen] + "..." + } + } else { + target = "default" + } + } + + history := model.PushHistory{ + EventKey: req.EventKey, + Channel: req.Config.Channel, + Target: target, + Title: title, + Content: content, + Level: level, + Status: status, + ErrorMsg: errMsg, + } + return repository.CreatePushHistoryRecord(ctx, &history) +} + +// ResolveTarget parses dynamic placeholders into concrete receiver targets. +func ResolveTarget(ctx context.Context, target string, flatBody map[string]any, channel string) string { + target = strings.TrimSpace(target) + if target == "" { + return "" + } + + resolved := ResolveDynamicKeyword(target, flatBody) + if strings.Contains(resolved, "@") { + return resolved + } + if val, matched := ResolveSystemTarget(ctx, resolved, channel); matched { + return val + } + + user, found := ResolveTargetUser(ctx, resolved, channel) + if !found { + return resolved + } + if channel == model.ChannelEmail && user.Email != "" { + return user.Email + } + if channel != model.ChannelEmail && user.Username != "" { + return user.Username + } + return resolved +} + +// ResolveDynamicKeyword resolves user.id, username, email keywords. +func ResolveDynamicKeyword(target string, flatBody map[string]any) string { + switch target { + case "user.id", "id": + if val, ok := flatBody["user.id"]; ok { + return fmt.Sprintf("%v", val) + } + if val, ok := flatBody["id"]; ok { + return fmt.Sprintf("%v", val) + } + case "user.username", "username": + if val, ok := flatBody["user.username"]; ok { + return fmt.Sprintf("%v", val) + } + if val, ok := flatBody["username"]; ok { + return fmt.Sprintf("%v", val) + } + case "user.email", model.ChannelEmail: + if val, ok := flatBody["user.email"]; ok { + return fmt.Sprintf("%v", val) + } + if val, ok := flatBody["email"]; ok { + return fmt.Sprintf("%v", val) + } + } + return target +} + +// ResolveTargetUser resolves user by numeric ID or username string. +func ResolveTargetUser(ctx context.Context, resolved, _ string) (contracts.UserDTO, bool) { + if id, err := strconv.ParseUint(resolved, 10, 64); err == nil { + if u, err := FindUserByID(ctx, id); err == nil && u != nil { + return *u, true + } + } + if u, err := FindUserByUsername(ctx, resolved); err == nil && u != nil { + return *u, true + } + return contracts.UserDTO{}, false +} + +// GetFirstAdminUser resolves the first administrator through the UserService +// contract, falling back to the repository read path when it is unavailable. +func GetFirstAdminUser(ctx context.Context) (*contracts.UserDTO, error) { + if userSvc := GetUserService(ctx); userSvc != nil { + return userSvc.GetFirstAdminUser(ctx) + } + if adminUser, err := repository.FindFirstAdminUserRecord(ctx); err == nil && adminUser != nil { + return adminUser, nil + } + return nil, errors.New(errs.ErrNoAdminUser) +} + +// ResolveSystemTarget maps system receiver aliases to administrator contact info. +func ResolveSystemTarget(ctx context.Context, resolved, channel string) (string, bool) { + if resolved != "系统" && resolved != "system" && resolved != "0" { + return "", false + } + adminUser, err := GetFirstAdminUser(ctx) + if err != nil || adminUser == nil { + return resolved, true + } + if channel == model.ChannelEmail && adminUser.Email != "" { + return adminUser.Email, true + } + if channel != model.ChannelEmail && adminUser.Username != "" { + return adminUser.Username, true + } + return resolved, true +} + +// ResolveSMTPConfig fills missing email endpoint fields from the system SMTP settings. +func ResolveSMTPConfig(ctx context.Context, url, token, other string) (string, string, string) { + if url != "" && token != "" { + return url, token, other + } + smtp, err := repository.LoadSMTPConfigRecord(ctx) + if err != nil { + logger.ErrorF(ctx, "[Push] 读取 SMTP 系统配置失败: %v", err) + return url, token, other + } + if smtp.Host == "" || smtp.Username == "" { + return url, token, other + } + port := smtp.Port + if port == "" { + port = "587" + } + if url == "" { + url = smtp.Host + ":" + port + } + if token == "" { + token = smtp.Username + } + if other == "" { + other = smtp.Password + } + return url, token, other +} + +// GetSystemUser gets a system user DTO. +func GetSystemUser(ctx context.Context) *contracts.UserDTO { + if adminUser, err := GetFirstAdminUser(ctx); err == nil && adminUser != nil { + return adminUser + } + return &contracts.UserDTO{ + Username: "system", + Nickname: "系统管理员", + } +} + +// FindBuiltInEvent finds a registered built-in event by key. +func FindBuiltInEvent(key string) (model.EventMetadata, bool) { + for _, meta := range GetBuiltInEvents() { + if meta.Key == key { + return meta, true + } + } + return model.EventMetadata{}, false +} + +// GetEventInfo derives the event key, display name and default template for a +// task-completion based event or a registered built-in event key. +func GetEventInfo(req model.CreatePushEventRequest) (string, string, []byte, error) { + if req.TaskType != "" { + taskName := req.TaskType + if taskSvc := GetTaskService(); taskSvc != nil { + if meta, ok := taskSvc.GetTaskMeta(req.TaskType); ok { + taskName = meta.DisplayName + } + } + eventKey := "task_completed:" + req.TaskType + eventName := "任务完成: " + taskName + defaultTemplate := model.NotificationMessage{ + Title: "任务完成: " + taskName, + Content: "异步任务 {{task_name}} (ID: {{task_id}}) 已完成。状态: {{task_status}},耗时: {{task_duration}} ms。", + Level: model.DefaultLevelInfo, + } + defaultTemplateBytes, err := json.Marshal(defaultTemplate) + if err != nil { + return "", "", nil, err + } + return eventKey, eventName, defaultTemplateBytes, nil + } + + if req.EventKey == "" { + return "", "", nil, errors.New(errs.ErrEventKeyOrTaskType) + } + + meta, found := FindBuiltInEvent(req.EventKey) + if !found { + return "", "", nil, errors.New(errs.ErrUnsupportedEventKey) + } + + defaultTemplateBytes, err := json.Marshal(meta.DefaultTemplate) + if err != nil { + return "", "", nil, err + } + return req.EventKey, meta.Name, defaultTemplateBytes, nil +} + +// EnqueuePushTask dispatches a notification payload to the async push worker. +func EnqueuePushTask(ctx context.Context, payload model.SendPayload) error { + payloadBytes, err := json.Marshal(payload) + if err != nil { + return err + } + if taskSvc := GetTaskService(); taskSvc != nil { + _, err = taskSvc.Dispatch(ctx, "send_notification", payloadBytes, "system") + return err + } + return errors.New(errs.ErrTaskServiceUnavailable) +} + +// GetFlatBody flattens nested body map. +func GetFlatBody(body map[string]any) map[string]any { + jsonBytes, err := json.Marshal(body) + if err != nil { + return body + } + var jsonMap map[string]any + if err := json.Unmarshal(jsonBytes, &jsonMap); err != nil { + return body + } + + flatResult := make(map[string]any) + FlattenMap("", jsonMap, flatResult) + return flatResult +} + +// FlattenMap recursively flattens map key-values. +func FlattenMap(prefix string, m, result map[string]any) { + for k, v := range m { + key := k + if prefix != "" { + key = prefix + "." + k + } + if nestedMap, ok := v.(map[string]any); ok { + FlattenMap(key, nestedMap, result) + } else { + result[key] = v + } + } +} + +const ( + // SendNotificationTask is the asynq task name for push notification. + SendNotificationTask = "push:send" + // TaskTypeSendNotification is the admin task manager type identifier. + TaskTypeSendNotification = "send_notification" +) + +// SendNotificationMeta represents the task metadata. +var SendNotificationMeta = contracts.TaskMetaDTO{ + Type: TaskTypeSendNotification, + AsynqTask: SendNotificationTask, + Name: "推送通知", + DisplayName: "推送通知", + Description: "异步执行系统通知的多渠道派发与推送", + Category: "push", + SupportsTime: false, + MaxRetry: 3, + Queue: "default", + Retryable: true, + Params: []contracts.TaskParamDTO{ + { + Name: "event_key", + Label: "事件标识", + Type: "string", + Required: true, + Placeholder: "admin_login", + Description: "事件标识 (如 admin_login)", + }, + { + Name: "target", + Label: "目标接收者", + Type: "string", + Required: false, + Description: "目标接收者", + }, + }, +} + +// PushHandler handles asynchronous notification sending. +type PushHandler struct{} + +// ValidatePayload validates and normalizes push parameters. +func (h *PushHandler) ValidatePayload(payload []byte) ([]byte, error) { + if len(payload) == 0 { + return nil, errors.New(errs.ErrPayloadRequired) + } + + var req model.SendPayload + if err := json.Unmarshal(payload, &req); err != nil { + return nil, fmt.Errorf("%s: %w", errs.ErrInvalidJSONFormat, err) + } + + if req.Config.Channel == "" { + return nil, errors.New(errs.ErrChannelTypeRequired) + } + + return json.Marshal(req) +} + +// Execute performs the push send and logs delivery history audit. +func (h *PushHandler) Execute(ctx context.Context, payload []byte) error { + var req model.SendPayload + if err := json.Unmarshal(payload, &req); err != nil { + logger.ErrorF(ctx, "[Push] 解析推送参数失败: %v", err) + return fmt.Errorf("%s: %w", errs.ErrParsePayloadFailed, err) + } + + logger.InfoF(ctx, "[Push] 开始推送通知: 事件 = %s, 渠道 = %s, 接收目标 = %s", req.EventKey, req.Config.Channel, req.Target) + + pusher, err := pkgpush.GetPusher(req.Config.Channel) + if err != nil { + errWrap := fmt.Errorf("%s: %w", errs.ErrGetPusherFailed, err) + logger.ErrorF(ctx, "[Push] 推送失败: %v", errWrap) + h.recordHistory(ctx, req, "failed", errWrap.Error()) + return errWrap + } + + flatBody := req.Body.Flatten() + upstreamResp, err := pusher.Send(ctx, req.Config, req.Target, flatBody, req.Template, nil) + + title := req.Body.Title + content := req.Body.Content + + if err != nil { + logger.ErrorF(ctx, "[Push] 消息推送失败 (标题: %s): %v, 上游返回: %s", title, err, upstreamResp) + h.recordHistory(ctx, req, "failed", err.Error()) + return fmt.Errorf("pusher.Send failed: %w", err) + } + + logger.InfoF(ctx, "[Push] 消息推送成功 (标题: %s, 内容摘要: %s), 上游返回: %s", title, content, upstreamResp) + h.recordHistory(ctx, req, "success", "") + + return nil +} + +func (h *PushHandler) recordHistory(ctx context.Context, req model.SendPayload, status, errMsg string) { + if dbErr := RecordPushHistory(ctx, req, status, errMsg); dbErr != nil { + logger.ErrorF(ctx, "[Push] 写入推送历史审计记录失败: %v", dbErr) + } +} + +// HandleTaskCompleted handles task completion notifications. +func HandleTaskCompleted(ctx context.Context, e contracts.TaskCompletedEvent) { + events, err := ListActivePushEventsByTaskType(ctx, e.TaskType) + if err != nil { + logger.ErrorF(ctx, "push_task_completed_listener: failed to query push events for task type %s: %v", e.TaskType, err) + return + } + if len(events) == 0 { + return + } + + body := map[string]any{ + "task_id": e.TaskID, + "task_name": e.TaskName, + "task_type": e.TaskType, + "task_status": e.Status, + "task_duration": e.Duration, + "time": time.Now().Format("2006-01-02 15:04:05"), + "task_error": e.ErrorMsg, + "task_result": e.ResultMsg, + } + + var payloadMap map[string]any + if e.Payload != "" { + if err := json.Unmarshal([]byte(e.Payload), &payloadMap); err == nil { + body["payload"] = payloadMap + ExtractUserFromMap(ctx, payloadMap, body) + } + } + if e.Detail != "" { + var detailMap map[string]any + if err := json.Unmarshal([]byte(e.Detail), &detailMap); err == nil { + body["detail"] = detailMap + ExtractUserFromMap(ctx, detailMap, body) + } + } + + for _, event := range events { + meta := model.EventMetadata{ + Key: event.EventKey, + Name: event.Name, + Description: "异步任务执行完毕触发的自动通知", + } + DefaultTrigger.Trigger(ctx, meta, body) + } +} + +// ExtractUserFromMap extracts user info from payload/detail into body map. +func ExtractUserFromMap(ctx context.Context, data, body map[string]any) { + if u, exists := body["user"]; exists && u != nil { + return + } + if user := LoadUserFromPayload(ctx, data); user != nil { + body["user"] = user + } +} + +// ExtractUserID extracts a user ID from map keys. +func ExtractUserID(data map[string]any) (uint64, bool) { + for _, k := range []string{"user_id", "userId", "uid"} { + val, ok := data[k] + if !ok || val == nil { + continue + } + switch v := val.(type) { + case float64: + if v >= 0 { + return uint64(v), true + } + case int: + if v >= 0 { + return uint64(v), true + } + case int64: + if v >= 0 { + return uint64(v), true + } + case uint64: + return v, true + case string: + if id, err := strconv.ParseUint(v, 10, 64); err == nil { + return id, true + } + } + } + return 0, false +} + +// ExtractUsername extracts a username string from map keys. +func ExtractUsername(data map[string]any) string { + for _, k := range []string{"username", "user_name"} { + if val, ok := data[k]; ok && val != nil { + if s, ok := val.(string); ok && s != "" { + return s + } + } + } + return "" +} + +// EventTrigger represents the unified event trigger class. +type EventTrigger struct{} + +// DefaultTrigger is the singleton instance of EventTrigger. +var DefaultTrigger = &EventTrigger{} + +// Trigger receives event metadata and processes the event notification dispatch asynchronously. +func (t *EventTrigger) Trigger(ctx context.Context, meta model.EventMetadata, body map[string]any) { + asyncCtx := context.WithoutCancel(ctx) + util.Go(func() { + if body == nil { + body = make(map[string]any) + } + if _, hasUser := body["user"]; !hasUser || body["user"] == nil { + body["user"] = GetSystemUser(asyncCtx) + } + + eventPtr, err := repository.GetActivePushEventByKey(asyncCtx, meta.Key) + if err != nil { + if errors.Is(err, errs.ErrRecordNotFound) { + return + } + logger.ErrorF(asyncCtx, "push_event_trigger: failed to get active event %s: %v", meta.Key, err) + return + } + event := *eventPtr + if len(event.Channels) == 0 { + return + } + + flatBody := GetFlatBody(body) + msg, _ := t.buildMessage(&event, meta, flatBody, body) + t.enqueuePushTasks(asyncCtx, meta, &event, msg, flatBody) + }) +} + +func (t *EventTrigger) buildMessage(event *model.PushEvent, meta model.EventMetadata, flatBody, body map[string]any) (model.NotificationMessage, string) { + var msg model.NotificationMessage + renderedTemplate := "" + + templateSource := event.Template + if templateSource != "" { + var err error + msg, renderedTemplate, err = t.parseCustomTemplate(event, templateSource, flatBody) + if err != nil { + msg.Title = event.Name + msg.Content = renderedTemplate + msg.Level = model.DefaultLevelInfo + } + } else { + msg = t.parseDefaultTemplate(meta, flatBody) + } + + if msg.Ext == nil { + msg.Ext = make(map[string]any) + } + for k, v := range body { + if k == model.KeyTitle || k == model.KeyContent || k == model.KeyLevel { + continue + } + if _, exists := msg.Ext[k]; !exists { + msg.Ext[k] = v + } + } + + return msg, renderedTemplate +} + +func (t *EventTrigger) parseCustomTemplate(event *model.PushEvent, templateSource string, flatBody map[string]any) (model.NotificationMessage, string, error) { + var msg model.NotificationMessage + renderedTemplate := pkgpush.ParseTemplate(templateSource, flatBody) + + var tMap map[string]any + if err := json.Unmarshal([]byte(renderedTemplate), &tMap); err != nil { + return msg, renderedTemplate, err + } + + if title, ok := tMap[model.KeyTitle].(string); ok && title != "" { + msg.Title = title + } else { + msg.Title = event.Name + } + delete(tMap, model.KeyTitle) + + if content, ok := tMap[model.KeyContent].(string); ok && content != "" { + msg.Content = content + } else { + msg.Content = renderedTemplate + } + delete(tMap, model.KeyContent) + + if level, ok := tMap[model.KeyLevel].(string); ok && level != "" { + msg.Level = level + } else { + msg.Level = model.DefaultLevelInfo + } + delete(tMap, model.KeyLevel) + + msg.Ext = tMap + return msg, renderedTemplate, nil +} + +func (t *EventTrigger) parseDefaultTemplate(meta model.EventMetadata, flatBody map[string]any) model.NotificationMessage { + var msg model.NotificationMessage + msg.Title = pkgpush.ParseTemplate(meta.DefaultTemplate.Title, flatBody) + msg.Content = pkgpush.ParseTemplate(meta.DefaultTemplate.Content, flatBody) + msg.Level = pkgpush.ParseTemplate(meta.DefaultTemplate.Level, flatBody) + + if meta.DefaultTemplate.Ext != nil { + msg.Ext = make(map[string]any) + for k, v := range meta.DefaultTemplate.Ext { + if strVal, ok := v.(string); ok { + msg.Ext[k] = pkgpush.ParseTemplate(strVal, flatBody) + } else { + msg.Ext[k] = v + } + } + } + return msg +} + +func (t *EventTrigger) enqueuePushTasks(ctx context.Context, meta model.EventMetadata, event *model.PushEvent, msg model.NotificationMessage, flatBody map[string]any) { + for _, channelName := range event.Channels { + customChannel, err := repository.GetActivePushChannelByName(ctx, channelName) + if err == nil { + t.enqueueCustomPushChannelTasks(ctx, meta, event, customChannel, msg, flatBody) + continue + } + logger.WarnF(ctx, "push_event_trigger: channel %q not found in DB or disabled: %v", channelName, err) + } +} + +func (t *EventTrigger) enqueueCustomPushChannelTasks(ctx context.Context, meta model.EventMetadata, event *model.PushEvent, channel *model.PushChannel, msg model.NotificationMessage, flatBody map[string]any) { + if len(event.Targets) == 0 { + t.enqueueSingleCustomPushChannelTask(ctx, meta, channel, "", msg) + return + } + + for _, target := range event.Targets { + resolvedTarget := ResolveTarget(ctx, target, flatBody, channel.Name) + t.enqueueSingleCustomPushChannelTask(ctx, meta, channel, resolvedTarget, msg) + } +} + +func (t *EventTrigger) enqueueSingleCustomPushChannelTask(ctx context.Context, meta model.EventMetadata, channel *model.PushChannel, target string, msg model.NotificationMessage) { + var config pkgpush.Config + var renderedTemplate string + + switch channel.Type { + case model.ChannelLark: + config = pkgpush.Config{Channel: model.ChannelLark, URL: channel.URL, Secret: channel.Token} + renderedTemplate = channel.Other + case model.ChannelEmail: + url, token, other := ResolveSMTPConfig(ctx, channel.URL, channel.Token, channel.Other) + config = pkgpush.Config{Channel: model.ChannelEmail, URL: url, Key: token, Secret: other} + case model.ChannelTelegram: + config = pkgpush.Config{Channel: model.ChannelTelegram, URL: channel.URL, Secret: channel.Token, Key: channel.Other} + default: + config = pkgpush.Config{Channel: model.ChannelCustom, URL: channel.URL} + customPushReq := model.CustomPushRequest{ + Title: msg.Title, + Content: msg.Content, + Description: meta.Description, + To: target, + } + if urlVal, ok := msg.Ext["url"].(string); ok { + customPushReq.URL = urlVal + } + renderedTemplate = RenderCustomPayload(channel.Other, customPushReq) + } + + payload := model.SendPayload{ + EventKey: meta.Key, + Config: config, + Target: target, + Body: msg, + Template: renderedTemplate, + } + if err := EnqueuePushTask(ctx, payload); err != nil { + logger.ErrorF(ctx, "push_event_trigger: enqueuePushTask failed for %s channel %s -> %s: %v", channel.Type, channel.Name, target, err) + } +} + +// SyncEvents automatically registers/updates built-in events in the database. +func SyncEvents(ctx context.Context) error { + return SyncBuiltInEvents(ctx) +} + +// AdminLogin is the metadata definition for the admin login event. +var AdminLogin = model.EventMetadata{ + Key: "admin_login", + Name: "管理员登录", + DefaultTemplate: model.NotificationMessage{ + Title: "管理员登录提醒", + Content: "管理员 {{user.username}} 于 {{time}} 从 IP {{ip}} 登录系统。", + Level: "INFO", + }, + Description: "当管理员成功登录系统时触发此通知", +} + +// HandleAdminLoggedIn 处理管理员登录事件并触发通知 +func HandleAdminLoggedIn(ctx context.Context, event contracts.AdminLoggedIn) { + if event.User == nil { + return + } + + body := map[string]any{ + "user": event.User, + "ip": event.IP, + "time": time.Now().Format("2006-01-02 15:04:05"), + } + DefaultTrigger.Trigger(ctx, AdminLogin, body) +} + +// RegisterCustomEvents registers default domain push notification events. +func RegisterCustomEvents() { + RegisterBuiltInEvent(AdminLogin) +} diff --git a/backend/plugins/domain/message_gateway/service/service.go b/backend/plugins/domain/message_gateway/service/service.go new file mode 100644 index 00000000..dfd33db5 --- /dev/null +++ b/backend/plugins/domain/message_gateway/service/service.go @@ -0,0 +1,405 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package service implements domain business logic and channel runners for message_gateway. +package service + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "Wavelet/pkg/logger" + "Wavelet/pkg/util" + "Wavelet/plugins/domain/message_gateway/errs" + "Wavelet/plugins/domain/message_gateway/model" + "Wavelet/plugins/domain/message_gateway/repository" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "strconv" + "strings" + "sync" + "time" + "unicode" +) + +// Handler processes one inbound message. +type Handler func(ctx context.Context, msg model.InboundMessage) error + +// Factory constructs a Channel from decrypted config. +type Factory func(cfg model.ChannelConfig, onInbound Handler) (Channel, error) + +// Channel is one connected messaging adapter. +type Channel interface { + Type() string + Connect(ctx context.Context) error + Disconnect(ctx context.Context) error + Send(ctx context.Context, to model.Recipient, msg model.OutboundMessage) error + Capabilities() model.Capability +} + +var ( + factoriesMu sync.RWMutex + factories = map[string]Factory{} +) + +// Register stores a channel factory under typ. +func Register(typ string, fn Factory) { + factoriesMu.Lock() + defer factoriesMu.Unlock() + factories[typ] = fn +} + +// Lookup returns a previously registered factory. +func Lookup(typ string) (Factory, bool) { + factoriesMu.RLock() + defer factoriesMu.RUnlock() + fn, ok := factories[typ] + return fn, ok +} + +// CodeAlphabet excludes easily confused runes 0/O/1/I. +const CodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + +// CodeLength is the raw pairing code size. +const CodeLength = 8 + +// GenerateCode returns an 8-character pairing code. +func GenerateCode() (string, error) { + buf := make([]byte, CodeLength) + if _, err := rand.Read(buf); err != nil { + return "", err + } + out := make([]byte, CodeLength) + for i, b := range buf { + out[i] = CodeAlphabet[int(b)%len(CodeAlphabet)] + } + return string(out), nil +} + +// NormalizeCode strips separators and uppercases. +func NormalizeCode(s string) string { + var b strings.Builder + for _, r := range s { + if r == '-' || unicode.IsSpace(r) { + continue + } + b.WriteRune(unicode.ToUpper(r)) + } + return b.String() +} + +// FormatCode renders ABCD-EFGH. +func FormatCode(s string) string { + s = NormalizeCode(s) + if len(s) != CodeLength { + return s + } + return s[:4] + "-" + s[4:] +} + +var ( + credentialSecretMu sync.RWMutex + credentialSecret string +) + +// SetCredentialSecret sets the secret used to derive CredentialKey. +func SetCredentialSecret(secret string) { + credentialSecretMu.Lock() + defer credentialSecretMu.Unlock() + credentialSecret = secret +} + +// CredentialKey is AES-256 hex derived from the session secret. +func CredentialKey() string { + credentialSecretMu.RLock() + secret := credentialSecret + credentialSecretMu.RUnlock() + sum := sha256.Sum256([]byte(secret)) + return hex.EncodeToString(sum[:]) +} + +// EncryptCredentials encrypts a credential map as JSON. +func EncryptCredentials(creds map[string]string) (string, error) { + if creds == nil { + creds = map[string]string{} + } + raw, err := json.Marshal(creds) + if err != nil { + return "", err + } + return util.Encrypt(CredentialKey(), string(raw)) +} + +// DecryptCredentials decrypts a credential map. +func DecryptCredentials(ciphertext string) (map[string]string, error) { + if ciphertext == "" { + return map[string]string{}, nil + } + plain, err := util.Decrypt(CredentialKey(), ciphertext) + if err != nil { + return nil, err + } + var out map[string]string + if err := json.Unmarshal([]byte(plain), &out); err != nil { + return nil, err + } + if out == nil { + out = map[string]string{} + } + return out, nil +} + +// ParseExtra decodes optional extra JSON into a string map. +func ParseExtra(raw string) map[string]string { + if raw == "" { + return map[string]string{} + } + var out map[string]string + if err := json.Unmarshal([]byte(raw), &out); err != nil || out == nil { + return map[string]string{} + } + return out +} + +// EncodeExtra encodes extra fields as JSON. +func EncodeExtra(extra map[string]string) string { + if extra == nil { + return "" + } + raw, err := json.Marshal(extra) + if err != nil { + return "" + } + return string(raw) +} + +// Runner manages lifecycle for long-lived channel adapters (WebSocket, long-polling, etc.). +type Runner struct { + mu sync.Mutex + running bool + cancel context.CancelFunc +} + +// GlobalRunner is the default global runner instance. +var GlobalRunner = &Runner{} + +// Start starts all background long-lived channel runners. +func Start(ctx context.Context) error { + GlobalRunner.mu.Lock() + defer GlobalRunner.mu.Unlock() + + if GlobalRunner.running { + return nil + } + + runCtx, cancel := context.WithCancel(ctx) + GlobalRunner.cancel = cancel + GlobalRunner.running = true + + logger.InfoF(runCtx, "[MessageGateway] Starting bot channel runners...") + return nil +} + +// Stop stops the channel runner. +func Stop() { + GlobalRunner.mu.Lock() + defer GlobalRunner.mu.Unlock() + + if !GlobalRunner.running { + return + } + + if GlobalRunner.cancel != nil { + GlobalRunner.cancel() + } + GlobalRunner.running = false +} + +// Cordis contract singletons consumed by service layer. +var ( + cacheMu sync.RWMutex + cacheSvc contracts.CacheService + taskMu sync.RWMutex + taskSvc contracts.TaskService + userMu sync.RWMutex + userSvc contracts.UserService +) + +// SetCacheService sets the cache service. +func SetCacheService(s contracts.CacheService) { + cacheMu.Lock() + defer cacheMu.Unlock() + cacheSvc = s +} + +// SetTaskService sets the task service. +func SetTaskService(s contracts.TaskService) { + taskMu.Lock() + defer taskMu.Unlock() + taskSvc = s +} + +// SetUserService sets the user service. +func SetUserService(s contracts.UserService) { + userMu.Lock() + defer userMu.Unlock() + userSvc = s +} + +// GetCache resolves the cache service for the context. +func GetCache(ctx context.Context) contracts.CacheService { + if c, ok := ctx.(*core.Context); ok && c != nil { + if s, err := core.Inject[contracts.CacheService](c); err == nil && s != nil { + return s + } + } + cacheMu.RLock() + s := cacheSvc + cacheMu.RUnlock() + return s +} + +// GetTaskService returns the task service. +func GetTaskService() contracts.TaskService { + taskMu.RLock() + defer taskMu.RUnlock() + return taskSvc +} + +// GetUserService resolves the user service for the context. +func GetUserService(ctx context.Context) contracts.UserService { + if c, ok := ctx.(*core.Context); ok && c != nil { + if s, err := core.Inject[contracts.UserService](c); err == nil && s != nil { + return s + } + } + userMu.RLock() + s := userSvc + userMu.RUnlock() + return s +} + +// BindChannel consumes a pairing code and binds the platform identity to the user. +func BindChannel(ctx context.Context, userID uint64, req model.BindRequest) (model.BindingDTO, error) { + channelID, err := strconv.ParseUint(strings.TrimSpace(req.ChannelID), 10, 64) + if err != nil || channelID == 0 { + return model.BindingDTO{}, errs.ErrChannelIDRequired + } + code := NormalizeCode(req.Code) + if code == "" { + return model.BindingDTO{}, errs.ErrCodeInvalid + } + pairing, err := repository.GetPairingCode(ctx, code) + if err != nil { + if errors.Is(err, errs.ErrRecordNotFound) { + return model.BindingDTO{}, errs.ErrCodeInvalid + } + return model.BindingDTO{}, err + } + if !pairing.ExpiresAt.After(time.Now()) { + return model.BindingDTO{}, errs.ErrCodeInvalid + } + if pairing.ChannelID != channelID { + return model.BindingDTO{}, errs.ErrChannelMismatch + } + ch, err := repository.GetMessageChannel(ctx, channelID) + if err != nil { + if errors.Is(err, errs.ErrRecordNotFound) { + return model.BindingDTO{}, errs.ErrCodeInvalid + } + return model.BindingDTO{}, err + } + if !ch.Enabled { + return model.BindingDTO{}, errs.ErrChannelDisabled + } + + existing, err := repository.GetBindingByChannelPlatform(ctx, channelID, pairing.PlatformUserID) + if err != nil && !errors.Is(err, errs.ErrRecordNotFound) { + return model.BindingDTO{}, err + } + if err == nil && existing != nil { + if existing.UserID != userID { + return model.BindingDTO{}, errs.ErrPlatformAlreadyBound + } + _ = repository.DeletePairingCode(ctx, pairing.Code) + return ToBindingDTO(existing, ch), nil + } + + row := &model.MessageBinding{ + UserID: userID, + ChannelID: channelID, + PlatformUserID: pairing.PlatformUserID, + } + if err := repository.CreateMessageBinding(ctx, row); err != nil { + return model.BindingDTO{}, err + } + if err := repository.DeletePairingCode(ctx, pairing.Code); err != nil { + return model.BindingDTO{}, err + } + return ToBindingDTO(row, ch), nil +} + +// ListEnabledPublicChannels returns the channels a user may bind to. +func ListEnabledPublicChannels(ctx context.Context) ([]model.PublicChannelDTO, error) { + rows, err := repository.ListEnabledMessageChannels(ctx) + if err != nil { + return nil, err + } + out := make([]model.PublicChannelDTO, 0, len(rows)) + for _, row := range rows { + out = append(out, model.PublicChannelDTO{ID: row.ID, Name: row.Name, Type: row.Type}) + } + return out, nil +} + +// ListUserBindings returns the binding rows of one user enriched with channel info. +func ListUserBindings(ctx context.Context, userID uint64) ([]model.BindingDTO, error) { + rows, err := repository.ListBindingsByUser(ctx, userID) + if err != nil { + return nil, err + } + out := make([]model.BindingDTO, 0, len(rows)) + for i := range rows { + ch, err := repository.GetMessageChannel(ctx, rows[i].ChannelID) + if err != nil { + out = append(out, ToBindingDTO(&rows[i], nil)) + continue + } + out = append(out, ToBindingDTO(&rows[i], ch)) + } + return out, nil +} + +// UnbindChannel removes a binding owned by the given user. +func UnbindChannel(ctx context.Context, userID, bindingID uint64) error { + row, err := repository.GetMessageBinding(ctx, bindingID) + if err != nil { + if errors.Is(err, errs.ErrRecordNotFound) { + return errs.ErrBindingNotFound + } + return err + } + if row.UserID != userID { + return errs.ErrBindingForbidden + } + return repository.DeleteMessageBinding(ctx, bindingID) +} + +// ToBindingDTO projects a binding row and its optional channel onto the user DTO. +func ToBindingDTO(row *model.MessageBinding, ch *model.MessageChannel) model.BindingDTO { + dto := model.BindingDTO{ + ID: row.ID, + UserID: row.UserID, + ChannelID: row.ChannelID, + PlatformUserID: row.PlatformUserID, + CreatedAt: row.CreatedAt, + } + if ch != nil { + dto.ChannelName = ch.Name + dto.ChannelType = ch.Type + } + return dto +} diff --git a/backend/plugins/domain/risk_control/errs.go b/backend/plugins/domain/risk_control/errs.go new file mode 100644 index 00000000..8e77dd31 --- /dev/null +++ b/backend/plugins/domain/risk_control/errs.go @@ -0,0 +1,10 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package risk_control + +// 模块内专用错误文案常量,集中在此文件维护,禁止在 handler 中内联。 +const ( + // errSystemBusy 访问日志缓冲队列满载时的限流提示,刻意使用模糊文案避免泄露内部容量细节。 + errSystemBusy = "系统繁忙,请稍后再试" +) diff --git a/backend/plugins/domain/risk_control/logstore/access_log.go b/backend/plugins/domain/risk_control/logstore/access_log.go new file mode 100644 index 00000000..2d23be31 --- /dev/null +++ b/backend/plugins/domain/risk_control/logstore/access_log.go @@ -0,0 +1,105 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package logstore provides data access for analytics tables. +package logstore + +import ( + "Wavelet/pkg/util" + "context" + "fmt" + "time" + + "gorm.io/gorm" +) + +// CountAccessLogs returns the number of access logs matching filter. +func CountAccessLogs(ctx context.Context, filter AccessLogFilter) (uint64, error) { + ch := getChDB(ctx) + if ch == nil { + return 0, fmt.Errorf("clickhouse gorm connection is not initialized") + } + + var count int64 + query := applyFilter(ch.Model(&UserAccessLog{}), filter) + if err := query.Count(&count).Error; err != nil { + return 0, fmt.Errorf("count access logs: %w", err) + } + return safeUint64Count(count), nil +} + +// ListAccessLogs returns paginated access logs and the total match count. +func ListAccessLogs(ctx context.Context, filter AccessLogFilter, page, pageSize int) ([]UserAccessLog, uint64, error) { + ch := getChDB(ctx) + if ch == nil { + return nil, 0, fmt.Errorf("clickhouse gorm connection is not initialized") + } + + if filter.UserIDs != nil && len(filter.UserIDs) == 0 { + return []UserAccessLog{}, 0, nil + } + + var count int64 + query := applyFilter(ch.Model(&UserAccessLog{}), filter) + if err := query.Count(&count).Error; err != nil { + return nil, 0, fmt.Errorf("count access logs: %w", err) + } + + var logs []UserAccessLog + offset := (page - 1) * pageSize + if err := query.Order("created_at DESC").Limit(pageSize).Offset(offset).Find(&logs).Error; err != nil { + return nil, 0, fmt.Errorf("list access logs: %w", err) + } + + return logs, safeUint64Count(count), nil +} + +// DeleteAllUserAccessLogs hard-deletes all user access logs via TRUNCATE. +func DeleteAllUserAccessLogs(ctx context.Context) (int64, error) { + conn := getChConn() + if conn == nil { + return 0, fmt.Errorf("clickhouse connection is not initialized") + } + if err := conn.Exec(ctx, "TRUNCATE TABLE "+UserAccessLog{}.TableName()); err != nil { + return 0, fmt.Errorf("truncate user access logs: %w", err) + } + return 0, nil +} + +// DeleteUserAccessLogsBefore deletes user access logs older than cutoff. +func DeleteUserAccessLogsBefore(ctx context.Context, cutoff time.Time) (int64, error) { + conn := getChConn() + if conn == nil { + return 0, fmt.Errorf("clickhouse connection is not initialized") + } + if err := conn.Exec(ctx, "ALTER TABLE "+UserAccessLog{}.TableName()+" DELETE WHERE created_at < ?", cutoff); err != nil { + return 0, fmt.Errorf("delete expired user access logs: %w", err) + } + return 0, nil +} + +func safeUint64Count(count int64) uint64 { + if count < 0 { + return 0 + } + return uint64(count) +} + +func applyFilter(query *gorm.DB, filter AccessLogFilter) *gorm.DB { + if filter.UserIDs != nil { + if len(filter.UserIDs) == 0 { + return query.Where("1 = 0") + } + query = query.Where("user_id IN ?", filter.UserIDs) + } + if filter.Path != "" { + query = query.Where("path LIKE ? ESCAPE '\\'", "%"+util.EscapeLike(filter.Path)+"%") + } + if filter.StartTime != nil { + query = query.Where("created_at >= ?", *filter.StartTime) + } + if filter.EndTime != nil { + query = query.Where("created_at <= ?", *filter.EndTime) + } + return query +} diff --git a/backend/plugins/domain/risk_control/logstore/access_log_filter.go b/backend/plugins/domain/risk_control/logstore/access_log_filter.go new file mode 100644 index 00000000..162207c3 --- /dev/null +++ b/backend/plugins/domain/risk_control/logstore/access_log_filter.go @@ -0,0 +1,35 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package logstore + +import "time" + +// AccessLogFilter scopes user access log queries. +type AccessLogFilter struct { + // UserIDs filters by user IDs. nil means no user filter; an empty slice means no matches. + UserIDs []uint64 + Path string + // StartTime filters created_at >= StartTime when non-nil. + StartTime *time.Time + // EndTime filters created_at <= EndTime when non-nil. + EndTime *time.Time +} + +// DailyTrend is a single day's access count. +type DailyTrend struct { + Date string + Count uint64 +} + +// BrowserShare is a browser group's share of access logs. +type BrowserShare struct { + Browser string + Count uint64 +} + +// TopUser is an active user ranked by access count. +type TopUser struct { + UserID uint64 + Count uint64 +} diff --git a/backend/plugins/domain/risk_control/logstore/access_log_stats.go b/backend/plugins/domain/risk_control/logstore/access_log_stats.go new file mode 100644 index 00000000..19bd43f9 --- /dev/null +++ b/backend/plugins/domain/risk_control/logstore/access_log_stats.go @@ -0,0 +1,138 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package logstore + +import ( + "context" + "fmt" + "sort" + "time" +) + +const hoursInDay = 24 + +// GetDailyTrend returns per-day access counts for the last days days (inclusive of today). +func GetDailyTrend(ctx context.Context, days int) ([]DailyTrend, error) { + if days < 1 { + days = 7 + } + + ch := getChDB(ctx) + if ch == nil { + return nil, fmt.Errorf("clickhouse gorm connection is not initialized") + } + + startTime := time.Now().AddDate(0, 0, -(days - 1)).Truncate(hoursInDay * time.Hour) + tableName := UserAccessLog{}.TableName() + + query := fmt.Sprintf(` + SELECT toDate(created_at) AS date, count() AS count + FROM %s + WHERE created_at >= ? + GROUP BY date + ORDER BY date ASC + `, tableName) + + type trendRow struct { + Date time.Time + Count uint64 + } + + var rows []trendRow + if err := ch.Raw(query, startTime).Scan(&rows).Error; err != nil { + return nil, fmt.Errorf("get daily trend: %w", err) + } + + trendMap := make(map[string]uint64, days) + for i := 0; i < days; i++ { + dateStr := time.Now().AddDate(0, 0, -i).Format("2006-01-02") + trendMap[dateStr] = 0 + } + for _, row := range rows { + dateStr := row.Date.Format("2006-01-02") + trendMap[dateStr] = row.Count + } + + result := make([]DailyTrend, 0, days) + for i := days - 1; i >= 0; i-- { + dateStr := time.Now().AddDate(0, 0, -i).Format("2006-01-02") + result = append(result, DailyTrend{ + Date: dateStr, + Count: trendMap[dateStr], + }) + } + return result, nil +} + +// GetBrowserDistribution returns browser-grouped access counts since startTime. +func GetBrowserDistribution(ctx context.Context, startTime time.Time) ([]BrowserShare, error) { + ch := getChDB(ctx) + if ch == nil { + return nil, fmt.Errorf("clickhouse gorm connection is not initialized") + } + + tableName := UserAccessLog{}.TableName() + query := fmt.Sprintf(` + SELECT user_agent, count() AS count + FROM %s + WHERE created_at >= ? + GROUP BY user_agent + `, tableName) + + type uaRow struct { + UserAgent string + Count uint64 + } + + var rows []uaRow + if err := ch.Raw(query, startTime).Scan(&rows).Error; err != nil { + return nil, fmt.Errorf("get browser distribution: %w", err) + } + + browserCounts := make(map[string]uint64) + for _, row := range rows { + browser := ParseBrowserName(row.UserAgent) + browserCounts[browser] += row.Count + } + + result := make([]BrowserShare, 0, len(browserCounts)) + for browser, count := range browserCounts { + result = append(result, BrowserShare{ + Browser: browser, + Count: count, + }) + } + sort.Slice(result, func(i, j int) bool { + return result[i].Count > result[j].Count + }) + return result, nil +} + +// GetTopActiveUsers returns the most active users since startTime. +func GetTopActiveUsers(ctx context.Context, startTime time.Time, limit int) ([]TopUser, error) { + if limit < 1 { + limit = 10 + } + + ch := getChDB(ctx) + if ch == nil { + return nil, fmt.Errorf("clickhouse gorm connection is not initialized") + } + + tableName := UserAccessLog{}.TableName() + query := fmt.Sprintf(` + SELECT user_id, count() AS count + FROM %s + WHERE created_at >= ? AND user_id > 0 + GROUP BY user_id + ORDER BY count DESC + LIMIT ? + `, tableName) + + var users []TopUser + if err := ch.Raw(query, startTime, limit).Scan(&users).Error; err != nil { + return nil, fmt.Errorf("get top active users: %w", err) + } + return users, nil +} diff --git a/backend/plugins/domain/risk_control/logstore/access_log_test.go b/backend/plugins/domain/risk_control/logstore/access_log_test.go new file mode 100644 index 00000000..376c980f --- /dev/null +++ b/backend/plugins/domain/risk_control/logstore/access_log_test.go @@ -0,0 +1,213 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package logstore + +import ( + "context" + "io" + "testing" + "time" + + "github.com/ClickHouse/clickhouse-go/v2/lib/column" + "github.com/ClickHouse/clickhouse-go/v2/lib/driver" + + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func setupChGormDB(t *testing.T) *gorm.DB { + t.Helper() + + gormDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ + DisableForeignKeyConstraintWhenMigrating: true, + }) + require.NoError(t, err) + require.NoError(t, gormDB.AutoMigrate(&UserAccessLog{})) + SetChDBForTest(gormDB) + return gormDB +} + +func TestParseBrowserName(t *testing.T) { + tests := []struct { + name string + ua string + want string + }{ + {name: "chrome", ua: "Mozilla/5.0 Chrome/120.0.0.0", want: "Chrome"}, + {name: "firefox", ua: "Mozilla/5.0 Firefox/121.0", want: "Firefox"}, + {name: "safari", ua: "Mozilla/5.0 Safari/605.1.15", want: "Safari"}, + {name: "edge", ua: "Mozilla/5.0 Edg/120.0.0.0", want: "Edge"}, + {name: "wechat", ua: "MicroMessenger/8.0", want: "WeChat"}, + {name: "postman", ua: "PostmanRuntime/7.36.0", want: "Postman"}, + {name: "other", ua: "curl/8.0", want: "Other"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, ParseBrowserName(tt.ua)) + }) + } +} + +func TestCountAccessLogs_EmptyUserIDs(t *testing.T) { + setupChGormDB(t) + t.Cleanup(func() { SetChDBForTest(nil) }) + + count, err := CountAccessLogs(context.Background(), AccessLogFilter{UserIDs: []uint64{}}) + require.NoError(t, err) + assert.Equal(t, uint64(0), count) +} + +func TestListAccessLogs_EmptyUserIDs(t *testing.T) { + setupChGormDB(t) + t.Cleanup(func() { SetChDBForTest(nil) }) + + logs, total, err := ListAccessLogs(context.Background(), AccessLogFilter{UserIDs: []uint64{}}, 1, 20) + require.NoError(t, err) + assert.Equal(t, uint64(0), total) + assert.Empty(t, logs) +} + +func TestListAccessLogs_WithFilters(t *testing.T) { + gormDB := setupChGormDB(t) + t.Cleanup(func() { SetChDBForTest(nil) }) + + now := time.Now().UTC().Truncate(time.Second) + logs := []UserAccessLog{ + {ID: 1, UserID: 10, Path: "/api/v1/users", Method: "GET", Status: 200, CreatedAt: now}, + {ID: 2, UserID: 20, Path: "/api/v1/admin/logs", Method: "GET", Status: 200, CreatedAt: now}, + {ID: 3, UserID: 10, Path: "/api/v1/other", Method: "POST", Status: 201, CreatedAt: now}, + } + require.NoError(t, gormDB.Create(&logs).Error) + + start := now.Add(-time.Hour) + filter := AccessLogFilter{ + UserIDs: []uint64{10}, + Path: "users", + StartTime: &start, + } + + count, err := CountAccessLogs(context.Background(), filter) + require.NoError(t, err) + assert.Equal(t, uint64(1), count) + + result, total, err := ListAccessLogs(context.Background(), filter, 1, 10) + require.NoError(t, err) + assert.Equal(t, uint64(1), total) + require.Len(t, result, 1) + assert.Equal(t, uint64(1), result[0].ID) + assert.Equal(t, "/api/v1/users", result[0].Path) +} + +func TestBatchInsert_Empty(t *testing.T) { + err := BatchInsert(context.Background(), nil) + require.NoError(t, err) +} + +func TestBatchInsert_UsesModelBatchSQL(t *testing.T) { + ctx := context.Background() + mockBatch := &mockBatch{} + mockConn := &mockConn{ + batch: mockBatch, + batchQuery: UserAccessLog{}.BatchInsertSQL(), + } + SetChConnForTest(mockConn) + t.Cleanup(func() { SetChConnForTest(nil) }) + + createdAt := time.Now().UTC() + err := BatchInsert(ctx, []UserAccessLog{ + { + ID: 1, + UserID: 42, + Path: "/api/v1/test", + Method: "GET", + IP: "127.0.0.1", + UserAgent: "test-agent", + Headers: "{}", + Status: 200, + Latency: 12, + CreatedAt: createdAt, + }, + }) + require.NoError(t, err) + assert.True(t, mockConn.prepareCalled) + assert.Equal(t, UserAccessLog{}.BatchInsertSQL(), mockConn.preparedQuery) + assert.True(t, mockBatch.sendCalled) + require.Len(t, mockBatch.rows, 1) + assert.Equal(t, uint64(42), mockBatch.rows[0][1]) +} + +type mockConn struct { + batch driver.Batch + batchQuery string + prepareCalled bool + preparedQuery string +} + +func (m *mockConn) Contributors() []string { return nil } + +func (m *mockConn) ServerVersion() (*driver.ServerVersion, error) { return nil, nil } + +func (m *mockConn) Select(_ context.Context, _ any, _ string, _ ...any) error { return nil } + +func (m *mockConn) Query(_ context.Context, _ string, _ ...any) (driver.Rows, error) { + return nil, nil +} + +func (m *mockConn) QueryRow(_ context.Context, _ string, _ ...any) driver.Row { return nil } + +func (m *mockConn) PrepareBatch(_ context.Context, query string, _ ...driver.PrepareBatchOption) (driver.Batch, error) { + m.prepareCalled = true + m.preparedQuery = query + return m.batch, nil +} + +func (m *mockConn) Exec(_ context.Context, _ string, _ ...any) error { return nil } + +func (m *mockConn) AsyncInsert(_ context.Context, _ string, _ bool, _ ...any) error { return nil } + +func (m *mockConn) InsertFormat(_ context.Context, _, _ string, _ io.Reader) error { return nil } + +func (m *mockConn) QueryFormat(_ context.Context, _, _ string, _ ...any) (io.ReadCloser, error) { + return nil, nil +} + +func (m *mockConn) Ping(_ context.Context) error { return nil } + +func (m *mockConn) Stats() driver.Stats { return driver.Stats{} } + +func (m *mockConn) Close() error { return nil } + +type mockBatch struct { + rows [][]any + sendCalled bool +} + +func (m *mockBatch) Abort() error { return nil } + +func (m *mockBatch) Append(v ...any) error { + m.rows = append(m.rows, v) + return nil +} + +func (m *mockBatch) AppendStruct(_ any) error { return nil } + +func (m *mockBatch) Column(_ int) driver.BatchColumn { return nil } + +func (m *mockBatch) Flush() error { return nil } + +func (m *mockBatch) Send() error { + m.sendCalled = true + return nil +} + +func (m *mockBatch) IsSent() bool { return m.sendCalled } + +func (m *mockBatch) Rows() int { return len(m.rows) } + +func (m *mockBatch) Columns() []column.Interface { return nil } + +func (m *mockBatch) Close() error { return nil } diff --git a/backend/plugins/domain/risk_control/logstore/access_log_writer.go b/backend/plugins/domain/risk_control/logstore/access_log_writer.go new file mode 100644 index 00000000..9bd3c1db --- /dev/null +++ b/backend/plugins/domain/risk_control/logstore/access_log_writer.go @@ -0,0 +1,47 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package logstore + +import ( + "context" + "fmt" +) + +// BatchInsert writes access logs to ClickHouse using the native batch API. +func BatchInsert(ctx context.Context, logs []UserAccessLog) error { + if len(logs) == 0 { + return nil + } + conn := getChConn() + if conn == nil { + return fmt.Errorf("clickhouse connection is not initialized") + } + + batch, err := conn.PrepareBatch(ctx, UserAccessLog{}.BatchInsertSQL()) + if err != nil { + return fmt.Errorf("prepare clickhouse batch: %w", err) + } + + for _, logItem := range logs { + if err := batch.Append( + logItem.ID, + logItem.UserID, + logItem.Path, + logItem.Method, + logItem.IP, + logItem.UserAgent, + logItem.Headers, + logItem.Status, + logItem.Latency, + logItem.CreatedAt, + ); err != nil { + return fmt.Errorf("append access log to batch: %w", err) + } + } + + if err := batch.Send(); err != nil { + return fmt.Errorf("send clickhouse batch: %w", err) + } + return nil +} diff --git a/backend/plugins/domain/risk_control/logstore/browser.go b/backend/plugins/domain/risk_control/logstore/browser.go new file mode 100644 index 00000000..94634db2 --- /dev/null +++ b/backend/plugins/domain/risk_control/logstore/browser.go @@ -0,0 +1,30 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package logstore + +import "strings" + +// ParseBrowserName performs lightweight User-Agent browser identification. +func ParseBrowserName(ua string) string { + uaLower := strings.ToLower(ua) + if strings.Contains(uaLower, "micromessenger") { + return "WeChat" + } + if strings.Contains(uaLower, "postman") { + return "Postman" + } + if strings.Contains(uaLower, "edg/") || strings.Contains(uaLower, "edge") { + return "Edge" + } + if strings.Contains(uaLower, "firefox") { + return "Firefox" + } + if strings.Contains(uaLower, "chrome") { + return "Chrome" + } + if strings.Contains(uaLower, "safari") { + return "Safari" + } + return "Other" +} diff --git a/backend/plugins/domain/risk_control/logstore/cleanup.go b/backend/plugins/domain/risk_control/logstore/cleanup.go new file mode 100644 index 00000000..b1a1c6f4 --- /dev/null +++ b/backend/plugins/domain/risk_control/logstore/cleanup.go @@ -0,0 +1,98 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package logstore + +import ( + "Wavelet/pkg/logger" + "context" + "errors" + "fmt" + "strconv" + "time" +) + +const ( + defaultLogRetentionDays = 30 + partitionLeadMonths = 2 + userAccessLogTable = "w_user_access_logs" +) + +// CleanupSummary 汇总本次清理结果。 +type CleanupSummary struct { + ActiveDatabase string `json:"active_database"` + RetentionDays int `json:"retention_days"` + Deleted int64 `json:"deleted"` +} + +// CleanupExpired 按当前日志库保留天数删除过期用户访问日志,并预建 PG 分区。 +func CleanupExpired(ctx context.Context) (CleanupSummary, error) { + active, err := ActiveDatabase(ctx) + if err != nil { + return CleanupSummary{}, err + } + days := retentionDaysForDatabase(ctx, active) + summary := CleanupSummary{ActiveDatabase: active, RetentionDays: days} + + store, err := Active(ctx) + if err != nil { + return summary, err + } + now := time.Now().UTC() + if err := store.UserAccessLogs.EnsurePartitions(ctx, now, now.AddDate(0, partitionLeadMonths, 0)); err != nil { + logger.WarnF(ctx, "logstore: ensure partitions during cleanup failed: %v", err) + } + cutoff := now.AddDate(0, 0, -days) + // 先 DROP 完全过期的整月分区,再对边界月逐行 DeleteBefore。 + if err := store.UserAccessLogs.DropExpiredPartitions(ctx, cutoff); err != nil { + return summary, fmt.Errorf("drop expired partitions: %w", err) + } + deleted, err := store.UserAccessLogs.DeleteBefore(ctx, cutoff) + if err != nil { + return summary, fmt.Errorf("delete expired user access logs: %w", err) + } + summary.Deleted = deleted + if err := store.UserAccessLogs.DropEmptyPartitions(ctx, now); err != nil { + logger.WarnF(ctx, "drop empty log partitions failed: %v", err) + } + return summary, nil +} + +func retentionDaysForDatabase(ctx context.Context, dbName string) int { + key := "log_retention_days_postgres" + switch dbName { + case dbNameSQLite: + key = "log_retention_days_sqlite" + case dbNameClickHouse: + key = "log_retention_days_clickhouse" + } + v, err := getConfig(ctx, key) + if err != nil { + if !errors.Is(err, errConfigReaderNotWired) { + logger.ErrorF(ctx, "读取日志保留天数配置失败(key=%s),回退默认 %d 天: %v", key, defaultLogRetentionDays, err) + } + return defaultLogRetentionDays + } + days, perr := strconv.Atoi(v) + if perr != nil || days <= 0 { + logger.ErrorF(ctx, "日志保留天数配置非法(key=%s, value=%q),回退默认 %d 天", key, v, defaultLogRetentionDays) + return defaultLogRetentionDays + } + return days +} + +func partitionStatementsRange(from, to time.Time) []string { + var out []string + start := time.Date(from.Year(), from.Month(), 1, 0, 0, 0, 0, time.UTC) + end := time.Date(to.Year(), to.Month(), 1, 0, 0, 0, 0, time.UTC).AddDate(0, 1, 0) + for ; start.Before(end); start = start.AddDate(0, 1, 0) { + monthEnd := start.AddDate(0, 1, 0) + suffix := start.Format("200601") + fromDay := start.Format("2006-01-02") + toDay := monthEnd.Format("2006-01-02") + out = append(out, fmt.Sprintf( + "CREATE TABLE IF NOT EXISTS %s_%s PARTITION OF %s FOR VALUES FROM ('%s') TO ('%s')", + userAccessLogTable, suffix, userAccessLogTable, fromDay, toDay)) + } + return out +} diff --git a/backend/plugins/domain/risk_control/logstore/clickhouse.go b/backend/plugins/domain/risk_control/logstore/clickhouse.go new file mode 100644 index 00000000..b5cfa74d --- /dev/null +++ b/backend/plugins/domain/risk_control/logstore/clickhouse.go @@ -0,0 +1,153 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package logstore + +import ( + "context" + "fmt" + "time" + + "github.com/ClickHouse/clickhouse-go/v2/lib/driver" +) + +type clickhouseUserAccessLogStore struct { + skipFreeze bool +} + +func newClickHouseUserAccessLogStore() *clickhouseUserAccessLogStore { + return &clickhouseUserAccessLogStore{} +} + +var ( + _ UserAccessLogStore = (*clickhouseUserAccessLogStore)(nil) + _ StatusStore = (*clickhouseUserAccessLogStore)(nil) +) + +func (s *clickhouseUserAccessLogStore) ActiveDatabase(_ context.Context) (string, error) { + return dbNameClickHouse, nil +} + +func (s *clickhouseUserAccessLogStore) ensureWritable(ctx context.Context) error { + if !s.skipFreeze && Migrating(ctx) { + return ErrMigrating + } + return nil +} + +func (s *clickhouseUserAccessLogStore) BatchInsert(ctx context.Context, logs []UserAccessLog) error { + if len(logs) == 0 { + return nil + } + if err := s.ensureWritable(ctx); err != nil { + return err + } + return BatchInsert(ctx, logs) +} + +func (s *clickhouseUserAccessLogStore) DeleteAll(ctx context.Context) (int64, error) { + if err := s.ensureWritable(ctx); err != nil { + return 0, err + } + return DeleteAllUserAccessLogs(ctx) +} + +func (s *clickhouseUserAccessLogStore) DeleteBefore(ctx context.Context, cutoff time.Time) (int64, error) { + if err := s.ensureWritable(ctx); err != nil { + return 0, err + } + return DeleteUserAccessLogsBefore(ctx, cutoff) +} + +func (s *clickhouseUserAccessLogStore) Count(ctx context.Context, filter AccessLogFilter) (uint64, error) { + return CountAccessLogs(ctx, filter) +} + +func (s *clickhouseUserAccessLogStore) List(ctx context.Context, filter AccessLogFilter, page, pageSize int) ([]UserAccessLog, uint64, error) { + return ListAccessLogs(ctx, filter, page, pageSize) +} + +func (s *clickhouseUserAccessLogStore) GetDailyTrend(ctx context.Context, days int) ([]DailyTrend, error) { + return GetDailyTrend(ctx, days) +} + +func (s *clickhouseUserAccessLogStore) GetBrowserDistribution(ctx context.Context, startTime time.Time) ([]BrowserShare, error) { + return GetBrowserDistribution(ctx, startTime) +} + +func (s *clickhouseUserAccessLogStore) GetTopActiveUsers(ctx context.Context, startTime time.Time, limit int) ([]TopUser, error) { + return GetTopActiveUsers(ctx, startTime, limit) +} + +func (s *clickhouseUserAccessLogStore) EnsurePartitions(_ context.Context, _, _ time.Time) error { + return nil +} + +func (s *clickhouseUserAccessLogStore) DropEmptyPartitions(_ context.Context, _ time.Time) error { + return nil +} + +func (s *clickhouseUserAccessLogStore) DropExpiredPartitions(_ context.Context, _ time.Time) error { + return nil +} + +func (s *clickhouseUserAccessLogStore) MigrationRange(ctx context.Context) (time.Time, time.Time, error) { + conn := getChConn() + if conn == nil { + return time.Time{}, time.Time{}, fmt.Errorf("clickhouse connection is not initialized") + } + table := UserAccessLog{}.TableName() + var minTime, maxTime *time.Time + if err := conn.QueryRow(ctx, "SELECT min(created_at), max(created_at) FROM "+table).Scan(&minTime, &maxTime); err != nil { + return time.Time{}, time.Time{}, fmt.Errorf("query migration range %s: %w", table, err) + } + if minTime == nil || maxTime == nil { + return time.Time{}, time.Time{}, nil + } + return minTime.UTC(), maxTime.UTC(), nil +} + +func (s *clickhouseUserAccessLogStore) ListForMigration(ctx context.Context, afterID uint64, limit int) ([]UserAccessLog, error) { + conn := getChConn() + if conn == nil { + return nil, fmt.Errorf("clickhouse connection is not initialized") + } + if limit <= 0 { + limit = migrationPageSize + } + table := UserAccessLog{}.TableName() + columns := UserAccessLog{}.InsertColumns() + rows, err := conn.Query(ctx, fmt.Sprintf( + "SELECT %s FROM %s WHERE id > ? ORDER BY id ASC LIMIT ?", + columns, table, + ), afterID, limit) + if err != nil { + return nil, fmt.Errorf("list user access logs for migration: %w", err) + } + defer func() { _ = rows.Close() }() + return scanUserAccessLogs(rows) +} + +func scanUserAccessLogs(rows driver.Rows) ([]UserAccessLog, error) { + var result []UserAccessLog + for rows.Next() { + var item UserAccessLog + if err := rows.Scan( + &item.ID, + &item.UserID, + &item.Path, + &item.Method, + &item.IP, + &item.UserAgent, + &item.Headers, + &item.Status, + &item.Latency, + &item.CreatedAt, + ); err != nil { + return nil, fmt.Errorf("scan user access log row: %w", err) + } + item.CreatedAt = item.CreatedAt.UTC() + result = append(result, item) + } + return result, nil +} diff --git a/backend/plugins/domain/risk_control/logstore/db_helper.go b/backend/plugins/domain/risk_control/logstore/db_helper.go new file mode 100644 index 00000000..af540ef9 --- /dev/null +++ b/backend/plugins/domain/risk_control/logstore/db_helper.go @@ -0,0 +1,79 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package logstore + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "context" + "sync" + + "github.com/ClickHouse/clickhouse-go/v2/lib/driver" + "gorm.io/gorm" +) + +var ( + dbMu sync.RWMutex + dbSvc contracts.DBService + chConn driver.Conn + chDB *gorm.DB +) + +// SetDBService configures the DBService instance for logstore. +func SetDBService(s contracts.DBService) { + dbMu.Lock() + defer dbMu.Unlock() + dbSvc = s +} + +// SetChConnForTest configures ClickHouse native connection for test or runtime. +func SetChConnForTest(conn driver.Conn) { + dbMu.Lock() + defer dbMu.Unlock() + chConn = conn +} + +// SetChDBForTest configures ClickHouse GORM DB for test or runtime. +func SetChDBForTest(db *gorm.DB) { + dbMu.Lock() + defer dbMu.Unlock() + chDB = db +} + +func getDB(ctx context.Context) *gorm.DB { + if c, ok := ctx.(*core.Context); ok && c != nil { + if s, err := core.Inject[contracts.DBService](c); err == nil && s != nil { + return s.DB(ctx) + } + } + dbMu.RLock() + s := dbSvc + dbMu.RUnlock() + if s != nil { + return s.DB(ctx) + } + return nil +} + +func getChDB(ctx context.Context) *gorm.DB { + dbMu.RLock() + customCh := chDB + s := dbSvc + dbMu.RUnlock() + if customCh != nil { + return customCh.WithContext(ctx) + } + if s != nil { + if ch := s.Named("clickhouse"); ch != nil { + return ch.WithContext(ctx) + } + } + return nil +} + +func getChConn() driver.Conn { + dbMu.RLock() + defer dbMu.RUnlock() + return chConn +} diff --git a/backend/plugins/domain/risk_control/logstore/gorm.go b/backend/plugins/domain/risk_control/logstore/gorm.go new file mode 100644 index 00000000..a6ed8990 --- /dev/null +++ b/backend/plugins/domain/risk_control/logstore/gorm.go @@ -0,0 +1,326 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package logstore + +import ( + "Wavelet/pkg/idgen" + "Wavelet/pkg/util" + "context" + "errors" + "fmt" + "sort" + "strings" + "time" + + "gorm.io/gorm" +) + +const ( + insertBatchSize = 500 + migrationPageSize = 100 + defaultPageSize = 20 + defaultTopN = 10 + topUserAgents = 100 + dayDuration = 24 * time.Hour +) + +type gormLogStore struct { + db *gorm.DB + skipFreeze bool +} + +func newGormStore(db *gorm.DB) *gormLogStore { return &gormLogStore{db: db} } + +type userAccessLogGormStore struct { + *gormLogStore +} + +func newUserAccessLogGormStore(db *gorm.DB) *userAccessLogGormStore { + return &userAccessLogGormStore{gormLogStore: newGormStore(db)} +} + +var ( + _ UserAccessLogStore = (*userAccessLogGormStore)(nil) + _ StatusStore = (*userAccessLogGormStore)(nil) +) + +func (s *gormLogStore) ActiveDatabase(_ context.Context) (string, error) { + if isPostgresDialect(s.db) { + return dbNamePostgres, nil + } + return dbNameSQLite, nil +} + +func (s *gormLogStore) ensureWritable(ctx context.Context) error { + if !s.skipFreeze && Migrating(ctx) { + return ErrMigrating + } + return nil +} + +func (s *userAccessLogGormStore) BatchInsert(ctx context.Context, logs []UserAccessLog) error { + if len(logs) == 0 { + return nil + } + if err := s.ensureWritable(ctx); err != nil { + return err + } + for i := range logs { + if logs[i].ID == 0 { + logs[i].ID = idgen.NextUint64ID() + } + } + return s.db.WithContext(ctx).CreateInBatches(logs, insertBatchSize).Error +} + +func (s *userAccessLogGormStore) DeleteAll(ctx context.Context) (int64, error) { + if err := s.ensureWritable(ctx); err != nil { + return 0, err + } + res := s.db.WithContext(ctx).Where("1 = 1").Delete(&UserAccessLog{}) + return res.RowsAffected, res.Error +} + +func (s *userAccessLogGormStore) DeleteBefore(ctx context.Context, cutoff time.Time) (int64, error) { + if err := s.ensureWritable(ctx); err != nil { + return 0, err + } + res := s.db.WithContext(ctx).Where("created_at < ?", cutoff).Delete(&UserAccessLog{}) + if res.Error != nil && isMissingRelation(res.Error) { + return 0, nil + } + return res.RowsAffected, res.Error +} + +func (s *userAccessLogGormStore) ListForMigration(ctx context.Context, afterID uint64, limit int) ([]UserAccessLog, error) { + var rows []UserAccessLog + q := s.db.WithContext(ctx).Model(&UserAccessLog{}). + Where("id > ?", afterID). + Order("id ASC"). + Limit(limitOr(limit, migrationPageSize)) + if err := q.Find(&rows).Error; err != nil { + return nil, err + } + return rows, nil +} + +func (s *userAccessLogGormStore) MigrationRange(ctx context.Context) (time.Time, time.Time, error) { + return gormMigrationRange(ctx, s.db, "created_at", UserAccessLog{}, func(v *UserAccessLog) time.Time { + return v.CreatedAt + }) +} + +func (s *userAccessLogGormStore) Count(ctx context.Context, filter AccessLogFilter) (uint64, error) { + where, args, ok := buildUserAccessLogWhere(filter) + if !ok { + return 0, nil + } + var total int64 + if err := s.db.WithContext(ctx).Model(&UserAccessLog{}).Where(where, args...).Count(&total).Error; err != nil { + return 0, err + } + return countToUint64(total), nil +} + +func (s *userAccessLogGormStore) List(ctx context.Context, filter AccessLogFilter, page, pageSize int) ([]UserAccessLog, uint64, error) { + where, args, ok := buildUserAccessLogWhere(filter) + if !ok { + return []UserAccessLog{}, 0, nil + } + var total int64 + if err := s.db.WithContext(ctx).Model(&UserAccessLog{}).Where(where, args...).Count(&total).Error; err != nil { + return nil, 0, err + } + if total == 0 { + return []UserAccessLog{}, 0, nil + } + var rows []UserAccessLog + q := s.db.WithContext(ctx).Where(where, args...).Order("created_at DESC, id DESC") + if err := q.Limit(limitOr(pageSize, defaultPageSize)).Offset(offsetOf(page, pageSize)).Find(&rows).Error; err != nil { + return nil, 0, err + } + return rows, countToUint64(total), nil +} + +func buildUserAccessLogWhere(filter AccessLogFilter) (string, []any, bool) { + if filter.UserIDs != nil && len(filter.UserIDs) == 0 { + return "", nil, false + } + var parts []string + var args []any + if filter.UserIDs != nil { + parts = append(parts, "user_id IN ?") + args = append(args, filter.UserIDs) + } + if trimmed := strings.TrimSpace(filter.Path); trimmed != "" { + parts = append(parts, "path LIKE ? ESCAPE '\\'") + args = append(args, "%"+util.EscapeLike(trimmed)+"%") + } + if filter.StartTime != nil { + parts = append(parts, "created_at >= ?") + args = append(args, *filter.StartTime) + } + if filter.EndTime != nil { + parts = append(parts, "created_at <= ?") + args = append(args, *filter.EndTime) + } + if len(parts) == 0 { + return "1 = 1", args, true + } + return strings.Join(parts, " AND "), args, true +} + +func (s *userAccessLogGormStore) GetDailyTrend(ctx context.Context, days int) ([]DailyTrend, error) { + if days <= 0 { + days = 7 + } + start := time.Now().AddDate(0, 0, -(days - 1)).Truncate(dayDuration) + type row struct { + Date string + Cnt uint64 + } + var rows []row + err := s.db.WithContext(ctx).Model(&UserAccessLog{}). + Select(dailyTrendDateSQL(s.db)+" AS date, COUNT(*) AS cnt"). + Where("created_at >= ?", start). + Group("date").Order("date ASC").Scan(&rows).Error + if err != nil { + return nil, err + } + counts := make(map[string]uint64, len(rows)) + for _, r := range rows { + counts[r.Date] = r.Cnt + } + out := make([]DailyTrend, 0, days) + for i := 0; i < days; i++ { + d := start.AddDate(0, 0, i).Format("2006-01-02") + out = append(out, DailyTrend{Date: d, Count: counts[d]}) + } + return out, nil +} + +func (s *userAccessLogGormStore) GetBrowserDistribution(ctx context.Context, startTime time.Time) ([]BrowserShare, error) { + type row struct { + UserAgent string + Cnt uint64 + } + var rows []row + err := s.db.WithContext(ctx).Model(&UserAccessLog{}). + Select("user_agent, COUNT(*) AS cnt"). + Where("created_at >= ?", startTime). + Group("user_agent").Order("cnt DESC").Limit(topUserAgents).Scan(&rows).Error + if err != nil { + return nil, err + } + counts := make(map[string]uint64) + for _, r := range rows { + counts[ParseBrowserName(r.UserAgent)] += r.Cnt + } + out := make([]BrowserShare, 0, len(counts)) + for label, count := range counts { + out = append(out, BrowserShare{Browser: label, Count: count}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Count > out[j].Count }) + return out, nil +} + +func (s *userAccessLogGormStore) GetTopActiveUsers(ctx context.Context, startTime time.Time, limit int) ([]TopUser, error) { + type row struct { + UserID uint64 + Cnt uint64 + } + var rows []row + err := s.db.WithContext(ctx).Model(&UserAccessLog{}). + Select("user_id, COUNT(*) AS cnt"). + Where("user_id <> 0 AND created_at >= ?", startTime). + Group("user_id").Order("cnt DESC").Limit(limitOr(limit, defaultTopN)).Scan(&rows).Error + if err != nil { + return nil, err + } + out := make([]TopUser, len(rows)) + for i, r := range rows { + out[i] = TopUser{UserID: r.UserID, Count: r.Cnt} + } + return out, nil +} + +func (s *userAccessLogGormStore) EnsurePartitions(ctx context.Context, from, to time.Time) error { + if !isPostgresDialect(s.db) { + return nil + } + for _, sql := range partitionStatementsRange(from, to) { + if err := s.db.WithContext(ctx).Exec(sql).Error; err != nil { + return fmt.Errorf("ensure partition: %w", err) + } + } + return nil +} + +func gormMigrationRange[T any]( + ctx context.Context, + gdb *gorm.DB, + column string, + model T, + timeOf func(*T) time.Time, +) (time.Time, time.Time, error) { + var first, last T + found := false + for _, order := range []string{"ASC", "DESC"} { + out := &first + if order == "DESC" { + out = &last + } + res := gdb.WithContext(ctx).Model(model).Order(column + " " + order).Limit(1).Take(out) + if res.Error != nil && !errors.Is(res.Error, gorm.ErrRecordNotFound) { + return time.Time{}, time.Time{}, fmt.Errorf("query migration range %s: %w", column, res.Error) + } + if res.Error == nil { + found = true + } + } + if !found { + return time.Time{}, time.Time{}, nil + } + return timeOf(&first).UTC(), timeOf(&last).UTC(), nil +} + +func limitOr(v, def int) int { + if v <= 0 { + return def + } + return v +} + +func offsetOf(page, pageSize int) int { + if page < 1 { + page = 1 + } + return (page - 1) * limitOr(pageSize, defaultPageSize) +} + +func countToUint64(v int64) uint64 { + if v < 0 { + return 0 + } + return uint64(v) +} + +func isPostgresDialect(db *gorm.DB) bool { + return db != nil && db.Dialector != nil && db.Name() == "postgres" +} + +func dailyTrendDateSQL(db *gorm.DB) string { + if isPostgresDialect(db) { + return "to_char(created_at, 'YYYY-MM-DD')" + } + return "strftime('%Y-%m-%d', created_at)" +} + +func isMissingRelation(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "no such table") || strings.Contains(msg, "does not exist") +} diff --git a/backend/plugins/domain/risk_control/logstore/gorm_test.go b/backend/plugins/domain/risk_control/logstore/gorm_test.go new file mode 100644 index 00000000..e2eaf6a9 --- /dev/null +++ b/backend/plugins/domain/risk_control/logstore/gorm_test.go @@ -0,0 +1,60 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package logstore + +import ( + "Wavelet/pkg/idgen" + "context" + "testing" + "time" + + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func newTestUserAccessStore(t *testing.T) *userAccessLogGormStore { + t.Helper() + _ = idgen.Init(1) + gdb, err := gorm.Open(sqlite.Open("file:logstore-"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, gdb.AutoMigrate(&UserAccessLog{})) + return newUserAccessLogGormStore(gdb) +} + +func TestGormUserAccessLogCountList(t *testing.T) { + ua := newTestUserAccessStore(t) + ctx := context.Background() + now := time.Now().UTC().Truncate(time.Second) + require.NoError(t, ua.BatchInsert(ctx, []UserAccessLog{ + {UserID: 10, Path: "/api/v1/users", Method: "GET", Status: 200, CreatedAt: now}, + {UserID: 20, Path: "/api/v1/admin", Method: "GET", Status: 200, CreatedAt: now}, + {UserID: 10, Path: "/api/v1/other", Method: "POST", Status: 201, CreatedAt: now}, + })) + + count, err := ua.Count(ctx, AccessLogFilter{UserIDs: []uint64{10}, Path: "users"}) + require.NoError(t, err) + require.Equal(t, uint64(1), count) + + rows, total, err := ua.List(ctx, AccessLogFilter{UserIDs: []uint64{10}, Path: "users"}, 1, 10) + require.NoError(t, err) + require.Equal(t, uint64(1), total) + require.Len(t, rows, 1) + require.Equal(t, "/api/v1/users", rows[0].Path) + require.NotZero(t, rows[0].ID) +} + +func TestGormUserAccessLogFreeze(t *testing.T) { + ua := newTestUserAccessStore(t) + SetConfigReader(func(_ context.Context, key string) (string, error) { + if key == logMigrationKey { + return "migrating", nil + } + return "", nil + }) + t.Cleanup(ResetForTest) + + err := ua.BatchInsert(context.Background(), []UserAccessLog{{UserID: 1, CreatedAt: time.Now()}}) + require.ErrorIs(t, err, ErrMigrating) +} diff --git a/backend/plugins/domain/risk_control/logstore/imports_test.go b/backend/plugins/domain/risk_control/logstore/imports_test.go new file mode 100644 index 00000000..fa34edc1 --- /dev/null +++ b/backend/plugins/domain/risk_control/logstore/imports_test.go @@ -0,0 +1,41 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package logstore + +import ( + "os/exec" + "strings" + "testing" +) + +// plugins/domain 不得直接 import 已废弃的 internal。 +var forbiddenImports = []string{ + "Wavelet/internal", + "Wavelet/internal", +} + +func TestAppsMustNotImportPersistenceDirectly(t *testing.T) { + t.Chdir("../../../..") + out, err := exec.Command("go", "list", "-test", "-f", `{{.ImportPath}} {{join .Imports " "}}`, "./plugins/domain/...").Output() + if err != nil { + t.Fatalf("go list: %v", err) + } + for _, line := range strings.Split(string(out), "\n") { + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + pkg := fields[0] + if !strings.HasPrefix(pkg, "Wavelet/plugins/domain") { + continue + } + for _, imp := range fields[1:] { + for _, forbidden := range forbiddenImports { + if imp == forbidden { + t.Errorf("%s must not import forbidden package %s", pkg, forbidden) + } + } + } + } +} diff --git a/backend/plugins/domain/risk_control/logstore/logstore.go b/backend/plugins/domain/risk_control/logstore/logstore.go new file mode 100644 index 00000000..21804def --- /dev/null +++ b/backend/plugins/domain/risk_control/logstore/logstore.go @@ -0,0 +1,46 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package logstore abstracts user access-log storage across ClickHouse, PostgreSQL and SQLite. +package logstore + +import ( + "context" + "errors" + "time" +) + +// ErrMigrating 表示日志数据库正在迁移,当前禁止写入。 +var ErrMigrating = errors.New("log database is migrating, writes are disabled") + +// UserAccessLogStore 用户访问日志(w_user_access_logs)。 +type UserAccessLogStore interface { + BatchInsert(ctx context.Context, logs []UserAccessLog) error + DeleteAll(ctx context.Context) (int64, error) + DeleteBefore(ctx context.Context, cutoff time.Time) (int64, error) + Count(ctx context.Context, filter AccessLogFilter) (uint64, error) + List(ctx context.Context, filter AccessLogFilter, page, pageSize int) ([]UserAccessLog, uint64, error) + GetDailyTrend(ctx context.Context, days int) ([]DailyTrend, error) + GetBrowserDistribution(ctx context.Context, startTime time.Time) ([]BrowserShare, error) + GetTopActiveUsers(ctx context.Context, startTime time.Time, limit int) ([]TopUser, error) + ListForMigration(ctx context.Context, afterID uint64, limit int) ([]UserAccessLog, error) + MigrationRange(ctx context.Context) (from, to time.Time, err error) + EnsurePartitions(ctx context.Context, from, to time.Time) error + // DropEmptyPartitions 幂等清理 PG 空分区表:删除 before 月份之前、且无任何数据的按月分区; + // CH/SQLite 为 no-op。 + DropEmptyPartitions(ctx context.Context, before time.Time) error + // DropExpiredPartitions 直接删除完全过期的 PG 整月分区(候选为月份早于 cutoff 月的分区, + // 删除前校验分区内无保留期内数据,避免时区偏移下误删;迁移冻结期间拒绝执行);CH/SQLite 为 no-op。 + DropExpiredPartitions(ctx context.Context, cutoff time.Time) error +} + +// StatusStore 日志库状态。 +type StatusStore interface { + ActiveDatabase(ctx context.Context) (string, error) +} + +// Store 当前生效日志库。 +type Store struct { + UserAccessLogs UserAccessLogStore + Status StatusStore +} diff --git a/backend/plugins/domain/risk_control/logstore/migrations-clickhouse/00001_initial.sql b/backend/plugins/domain/risk_control/logstore/migrations-clickhouse/00001_initial.sql new file mode 100644 index 00000000..578912b8 --- /dev/null +++ b/backend/plugins/domain/risk_control/logstore/migrations-clickhouse/00001_initial.sql @@ -0,0 +1,21 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS w_user_access_logs +( + id UInt64, + user_id UInt64, + path String, + method String, + ip String, + user_agent String, + headers String, + status Int32, + latency Int64, + created_at DateTime +) +ENGINE = MergeTree() +PARTITION BY toYYYYMM(created_at) +ORDER BY (created_at, ip, user_id) +SETTINGS index_granularity = 8192; + +-- +goose Down +DROP TABLE IF EXISTS w_user_access_logs; \ No newline at end of file diff --git a/backend/plugins/domain/risk_control/logstore/migrations/postgres/00001_initial.sql b/backend/plugins/domain/risk_control/logstore/migrations/postgres/00001_initial.sql new file mode 100644 index 00000000..2cc6d07d --- /dev/null +++ b/backend/plugins/domain/risk_control/logstore/migrations/postgres/00001_initial.sql @@ -0,0 +1,24 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS w_user_access_logs ( + id BIGINT NOT NULL, + user_id BIGINT NOT NULL DEFAULT 0, + path VARCHAR(2048) NOT NULL DEFAULT '', + method VARCHAR(16) NOT NULL DEFAULT '', + ip VARCHAR(128) NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + headers TEXT NOT NULL DEFAULT '', + status INTEGER NOT NULL DEFAULT 0, + latency BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id, created_at) +); + +CREATE INDEX IF NOT EXISTS idx_w_user_access_logs_user_id ON w_user_access_logs (user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_w_user_access_logs_created_at ON w_user_access_logs (created_at DESC); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS w_user_access_logs; +-- +goose StatementEnd diff --git a/backend/plugins/domain/risk_control/logstore/migrations/sqlite/00001_initial.sql b/backend/plugins/domain/risk_control/logstore/migrations/sqlite/00001_initial.sql new file mode 100644 index 00000000..d928b8d1 --- /dev/null +++ b/backend/plugins/domain/risk_control/logstore/migrations/sqlite/00001_initial.sql @@ -0,0 +1,24 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS w_user_access_logs ( + id BIGINT NOT NULL, + user_id BIGINT NOT NULL DEFAULT 0, + path VARCHAR(2048) NOT NULL DEFAULT '', + method VARCHAR(16) NOT NULL DEFAULT '', + ip VARCHAR(128) NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + headers TEXT NOT NULL DEFAULT '', + status INTEGER NOT NULL DEFAULT 0, + latency BIGINT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id, created_at) +); + +CREATE INDEX IF NOT EXISTS idx_w_user_access_logs_user_id ON w_user_access_logs (user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_w_user_access_logs_created_at ON w_user_access_logs (created_at DESC); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS w_user_access_logs; +-- +goose StatementEnd diff --git a/backend/plugins/domain/risk_control/logstore/models.go b/backend/plugins/domain/risk_control/logstore/models.go new file mode 100644 index 00000000..2aa8079b --- /dev/null +++ b/backend/plugins/domain/risk_control/logstore/models.go @@ -0,0 +1,44 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package logstore abstracts user access-log storage across ClickHouse, PostgreSQL and SQLite. +package logstore + +import ( + "fmt" + "time" +) + +const ( + userAccessLogTableName = "w_user_access_logs" + userAccessLogInsertColumns = "id, user_id, path, method, ip, user_agent, headers, status, latency, created_at" +) + +// UserAccessLog stores HTTP access records in ClickHouse, PostgreSQL and SQLite. +type UserAccessLog struct { + ID uint64 `gorm:"column:id"` + UserID uint64 `gorm:"column:user_id"` + Path string `gorm:"column:path"` + Method string `gorm:"column:method"` + IP string `gorm:"column:ip"` + UserAgent string `gorm:"column:user_agent"` + Headers string `gorm:"column:headers"` + Status int32 `gorm:"column:status"` + Latency int64 `gorm:"column:latency"` + CreatedAt time.Time `gorm:"column:created_at"` +} + +// TableName returns the table name. +func (UserAccessLog) TableName() string { + return userAccessLogTableName +} + +// InsertColumns returns comma-separated column names for batch insert. +func (UserAccessLog) InsertColumns() string { + return userAccessLogInsertColumns +} + +// BatchInsertSQL returns the INSERT prefix used by native batch writers. +func (UserAccessLog) BatchInsertSQL() string { + return fmt.Sprintf("INSERT INTO %s (%s)", userAccessLogTableName, userAccessLogInsertColumns) +} diff --git a/backend/plugins/domain/risk_control/logstore/partition_cleanup.go b/backend/plugins/domain/risk_control/logstore/partition_cleanup.go new file mode 100644 index 00000000..aa9d663f --- /dev/null +++ b/backend/plugins/domain/risk_control/logstore/partition_cleanup.go @@ -0,0 +1,115 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package logstore + +import ( + "context" + "fmt" + "strings" + "time" + + "gorm.io/gorm" +) + +// listPartitionNames 列出 table 在当前 schema 下的全部直接分区表名(pg_inherits)。 +func listPartitionNames(ctx context.Context, gdb *gorm.DB, table string) ([]string, error) { + var names []string + if err := gdb.WithContext(ctx).Raw(` +SELECT c.relname +FROM pg_inherits i +JOIN pg_class c ON c.oid = i.inhrelid +JOIN pg_class p ON p.oid = i.inhparent +JOIN pg_namespace n ON n.oid = p.relnamespace AND n.nspname = current_schema() +WHERE p.relname = ?`, table).Scan(&names).Error; err != nil { + return nil, fmt.Errorf("list partitions of %s: %w", table, err) + } + return names, nil +} + +// partitionNameMonth 解析按月分区表名 _YYYYMM 的所属月份;命名不匹配返回 (零值, false)。 +func partitionNameMonth(table, name string) (time.Time, bool) { + suffix, ok := strings.CutPrefix(name, table+"_") + if !ok || len(suffix) != 6 { + return time.Time{}, false + } + m, err := time.Parse("200601", suffix) + if err != nil { + return time.Time{}, false + } + return m, true +} + +// dropEligiblePartitionNames 返回 before 月份之前、命名合法的分区表名(是否为空由调用方校验)。 +func dropEligiblePartitionNames(table string, names []string, before time.Time) []string { + beforeMonth := time.Date(before.Year(), before.Month(), 1, 0, 0, 0, 0, time.UTC) + out := make([]string, 0, len(names)) + for _, name := range names { + month, ok := partitionNameMonth(table, name) + if !ok || !month.Before(beforeMonth) { + continue + } + out = append(out, name) + } + return out +} + +// DropEmptyPartitions 幂等清理 PG 空分区表:仅删除 before 月份之前、且无任何数据的分区。 +// 非 PG 方言为 no-op。 +func (s *gormLogStore) DropEmptyPartitions(ctx context.Context, before time.Time) error { + if !isPostgresDialect(s.db) { + return nil + } + names, err := listPartitionNames(ctx, s.db, userAccessLogTable) + if err != nil { + return err + } + for _, name := range dropEligiblePartitionNames(userAccessLogTable, names, before) { + var one int + if err := s.db.WithContext(ctx).Raw("SELECT 1 FROM " + name + " LIMIT 1").Scan(&one).Error; err != nil { + return fmt.Errorf("check partition %s empty: %w", name, err) + } + if one == 1 { + continue + } + if err := s.db.WithContext(ctx).Exec("DROP TABLE IF EXISTS " + name).Error; err != nil { + return fmt.Errorf("drop empty partition %s: %w", name, err) + } + } + return nil +} + +// DropExpiredPartitions 直接删除完全过期的 PG 整月分区(避免 retention 清理逐行 DELETE)。 +// 候选 = 月份早于 cutoff 月(按 cutoff 的 UTC 时刻取月);删除前校验分区内不存在 created_at >= cutoff 的行。 +// 迁移冻结期间返回 ErrMigrating。CH/SQLite 为 no-op。 +func (s *gormLogStore) DropExpiredPartitions(ctx context.Context, cutoff time.Time) error { + if !isPostgresDialect(s.db) { + return nil + } + if err := s.ensureWritable(ctx); err != nil { + return err + } + names, err := listPartitionNames(ctx, s.db, userAccessLogTable) + if err != nil { + return err + } + cu := cutoff.UTC() + cutoffMonth := time.Date(cu.Year(), cu.Month(), 1, 0, 0, 0, 0, time.UTC) + for _, name := range names { + month, ok := partitionNameMonth(userAccessLogTable, name) + if !ok || !month.Before(cutoffMonth) { + continue + } + var hasRetained int + if err := s.db.WithContext(ctx).Raw("SELECT 1 FROM "+name+" WHERE created_at >= ? LIMIT 1", cu).Scan(&hasRetained).Error; err != nil { + return fmt.Errorf("check partition %s retained rows: %w", name, err) + } + if hasRetained == 1 { + continue + } + if err := s.db.WithContext(ctx).Exec("DROP TABLE IF EXISTS " + name).Error; err != nil { + return fmt.Errorf("drop expired partition %s: %w", name, err) + } + } + return nil +} diff --git a/backend/plugins/domain/risk_control/logstore/partition_cleanup_test.go b/backend/plugins/domain/risk_control/logstore/partition_cleanup_test.go new file mode 100644 index 00000000..50d6de81 --- /dev/null +++ b/backend/plugins/domain/risk_control/logstore/partition_cleanup_test.go @@ -0,0 +1,69 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package logstore + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestPartitionNameMonth(t *testing.T) { + cases := []struct { + table string + name string + want string + }{ + {"w_user_access_logs", "w_user_access_logs_202612", "2026-12"}, + {"w_user_access_logs", "w_user_access_logs_202608", "2026-08"}, + {"w_user_access_logs", "of_node_access_logs_202608", ""}, + {"w_user_access_logs", "w_user_access_logs_20268", ""}, + {"w_user_access_logs", "w_user_access_logs_202613", ""}, + {"w_user_access_logs", "w_user_access_logs_default", ""}, + } + for _, c := range cases { + got, ok := partitionNameMonth(c.table, c.name) + if c.want == "" { + if ok { + t.Fatalf("partitionNameMonth(%q, %q) ok = true, want false", c.table, c.name) + } + continue + } + if !ok || got.Format("2006-01") != c.want { + t.Fatalf("partitionNameMonth(%q, %q) = %v, want %s", c.table, c.name, got, c.want) + } + } +} + +func TestDropEligiblePartitionNames(t *testing.T) { + before := time.Date(2026, 10, 15, 0, 0, 0, 0, time.UTC) + names := []string{ + "w_user_access_logs_202608", + "w_user_access_logs_202609", + "w_user_access_logs_202610", + "w_user_access_logs_202611", + "w_user_access_logs_default", + } + got := dropEligiblePartitionNames(userAccessLogTable, names, before) + want := []string{"w_user_access_logs_202608", "w_user_access_logs_202609"} + require.Equal(t, want, got) + + first := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + require.Empty(t, dropEligiblePartitionNames(userAccessLogTable, []string{"w_user_access_logs_202610"}, first)) +} + +func TestDropPartitionHelpersSQLiteNoop(t *testing.T) { + ua := newTestUserAccessStore(t) + ctx := context.Background() + require.NoError(t, ua.BatchInsert(ctx, []UserAccessLog{ + {UserID: 1, Path: "/x", CreatedAt: time.Now().UTC()}, + })) + require.NoError(t, ua.DropExpiredPartitions(ctx, time.Now().AddDate(0, 0, -90))) + require.NoError(t, ua.DropEmptyPartitions(ctx, time.Now())) + count, err := ua.Count(ctx, AccessLogFilter{}) + require.NoError(t, err) + require.Equal(t, uint64(1), count) +} diff --git a/backend/plugins/domain/risk_control/logstore/provider.go b/backend/plugins/domain/risk_control/logstore/provider.go new file mode 100644 index 00000000..ed16fc43 --- /dev/null +++ b/backend/plugins/domain/risk_control/logstore/provider.go @@ -0,0 +1,201 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package logstore + +import ( + "Wavelet/pkg/logger" + "context" + "errors" + "fmt" + "sync" + "time" +) + +const ( + logDatabaseKey = "log_database" + logMigrationKey = "log_db_migration" +) + +const ( + dbNamePostgres = "postgres" + dbNameSQLite = "sqlite" + dbNameClickHouse = "clickhouse" +) + +var errConfigReaderNotWired = errors.New("logstore: config reader not wired") + +// ConfigReader 读取系统配置字符串值,由 bootstrap 注入(避免 logstore ↔ repository 循环依赖)。 +type ConfigReader func(ctx context.Context, key string) (string, error) + +const resolveCacheTTL = 1 * time.Second + +var ( + configReader ConfigReader + + defaultDBMu sync.RWMutex + defaultDB = dbNameSQLite + + storeMu sync.RWMutex + active *Store + activeDB string + lastResolveDB string + lastResolveTime time.Time +) + +// SetDefaultDatabases configures the fallback database based on database and clickhouse enablement. +func SetDefaultDatabases(dbEnabled, chEnabled bool) { + defaultDBMu.Lock() + defer defaultDBMu.Unlock() + defaultDB = dbNameSQLite + if dbEnabled { + defaultDB = dbNamePostgres + } + if chEnabled { + defaultDB = dbNameClickHouse + } +} + +func getDefaultDatabase() string { + defaultDBMu.RLock() + defer defaultDBMu.RUnlock() + return defaultDB +} + +// SetConfigReader 注入系统配置读取函数(bootstrap 调用,测试可注入内存实现)。 +func SetConfigReader(fn ConfigReader) { configReader = fn } + +func getConfig(ctx context.Context, key string) (string, error) { + if configReader == nil { + return "", errConfigReaderNotWired + } + return configReader(ctx, key) +} + +// Active 返回当前生效的日志库 Store。 +func Active(ctx context.Context) (*Store, error) { + current, err := resolveDatabase(ctx) + if err != nil { + return nil, err + } + storeMu.RLock() + if active != nil && activeDB == current { + s := active + storeMu.RUnlock() + return s, nil + } + storeMu.RUnlock() + + storeMu.Lock() + defer storeMu.Unlock() + if active != nil && activeDB == current { + return active, nil + } + s, err := buildStore(ctx, current, false) + if err != nil { + return nil, err + } + active = s + activeDB = current + return s, nil +} + +// Build 直接按目标构造 store(不经 Active 缓存)。 +func Build(ctx context.Context, database string) (*Store, error) { + return buildStore(ctx, database, false) +} + +// BuildForMigration 构造迁移目标 store,跳过冻结检查。 +func BuildForMigration(ctx context.Context, database string) (*Store, error) { + return buildStore(ctx, database, true) +} + +func buildStore(ctx context.Context, database string, skipFreeze bool) (*Store, error) { + switch database { + case dbNameClickHouse: + ual := newClickHouseUserAccessLogStore() + ual.skipFreeze = skipFreeze + return &Store{UserAccessLogs: ual, Status: ual}, nil + case dbNamePostgres, dbNameSQLite: + gdb := getDB(ctx) + ual := newUserAccessLogGormStore(gdb) + ual.skipFreeze = skipFreeze + return &Store{UserAccessLogs: ual, Status: ual}, nil + default: + return nil, fmt.Errorf("unsupported log database: %s", database) + } +} + +// Migrating 返回日志库是否处于迁移冻结状态。 +func Migrating(ctx context.Context) bool { + v, err := getConfig(ctx, logMigrationKey) + if err != nil { + if !errors.Is(err, errConfigReaderNotWired) { + logger.ErrorF(ctx, "read log migration config failed: %v", err) + } + return false + } + return v == "migrating" +} + +// Init 预热激活 store,并兜底预建当前月及未来分区。 +func Init(ctx context.Context) { + s, err := Active(ctx) + if err != nil { + return + } + now := time.Now().UTC() + if err := s.UserAccessLogs.EnsurePartitions(ctx, now, now.AddDate(0, partitionLeadMonths, 0)); err != nil { + logger.WarnF(ctx, "logstore: ensure startup partitions failed: %v", err) + } +} + +// InvalidateCache 清空日志库解析缓存。 +func InvalidateCache() { + storeMu.Lock() + defer storeMu.Unlock() + lastResolveTime = time.Time{} + lastResolveDB = "" +} + +// ResetForTest 清空缓存的激活 store 与 config reader。 +func ResetForTest() { + storeMu.Lock() + active = nil + activeDB = "" + lastResolveDB = "" + lastResolveTime = time.Time{} + storeMu.Unlock() + configReader = nil +} + +// ActiveDatabase 返回当前日志主库名。 +func ActiveDatabase(ctx context.Context) (string, error) { + return resolveDatabase(ctx) +} + +func resolveDatabase(ctx context.Context) (string, error) { + storeMu.RLock() + if active != nil && time.Since(lastResolveTime) < resolveCacheTTL { + name := lastResolveDB + storeMu.RUnlock() + return name, nil + } + storeMu.RUnlock() + + v, err := getConfig(ctx, logDatabaseKey) + if err != nil && !errors.Is(err, errConfigReaderNotWired) { + return "", err + } + + resolved := v + if resolved == "" { + resolved = getDefaultDatabase() + } + + storeMu.Lock() + lastResolveDB = resolved + lastResolveTime = time.Now() + storeMu.Unlock() + return resolved, nil +} diff --git a/backend/plugins/domain/risk_control/middleware.go b/backend/plugins/domain/risk_control/middleware.go new file mode 100644 index 00000000..7bf9c2fa --- /dev/null +++ b/backend/plugins/domain/risk_control/middleware.go @@ -0,0 +1,103 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package risk_control provides the access control, IP rate limiting, and telemetry risk analysis domain plugin for Cordis. +package risk_control + +import ( + "Wavelet/core/contracts" + "Wavelet/pkg/ginutil" + "Wavelet/pkg/idgen" + "Wavelet/pkg/response" + "Wavelet/plugins/domain/risk_control/logstore" + "encoding/json" + "net/http" + "sync/atomic" + "time" + + "github.com/gin-gonic/gin" +) + +var accessLogEnabled atomic.Bool + +// SetAccessLogEnabled enables or disables access log collection. +func SetAccessLogEnabled(enabled bool) { + accessLogEnabled.Store(enabled) +} + +// IsAccessLogEnabled reports whether access log collection is enabled. +func IsAccessLogEnabled() bool { + return accessLogEnabled.Load() +} + +// Middleware is an alias for RiskControlMiddleware. +var Middleware = RiskControlMiddleware + +// RiskControlMiddleware 全局日志采集中间件 +func RiskControlMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + // 如果未启用日志采集,直接放行 + if !IsAccessLogEnabled() { + c.Next() + return + } + + // 1. 限流背压检测(检测本地缓冲队列是否已满) + if IsBufferFull() { + response.AbortTooManyRequests(c, errSystemBusy) + return + } + + start := time.Now() + + // 2. 执行后续请求(穿过业务处理和认证中间件) + c.Next() + + // 3. 后置身份检查:仅记录通过认证的请求 + userObj, exists := ginutil.GetFromContext[*contracts.UserDTO](c, contracts.AuthUserObjKey) + if !exists || userObj == nil { + return + } + + // 4. 计算耗时并异步推送到缓冲队列 + latency := time.Since(start).Milliseconds() + + var headersStr string + if c.Request.Header != nil { + // 克隆 Header,避免污染原 HTTP 请求的 Header 对象 + clonedHeaders := make(http.Header) + for k, v := range c.Request.Header { + clonedHeaders[k] = v + } + clonedHeaders.Del("Cookie") + + if headersBytes, err := json.Marshal(clonedHeaders); err == nil { + headersStr = string(headersBytes) + } + } + + const maxHTTPStatus = 999 + status := c.Writer.Status() + if status < 0 { + status = 0 + } else if status > maxHTTPStatus { + status = maxHTTPStatus + } + + logItem := &logstore.UserAccessLog{ + ID: idgen.NextUint64ID(), + UserID: userObj.ID, // 直接从 Context 获取已登录用户ID,避免数据库查询 + Path: c.Request.URL.Path, + Method: c.Request.Method, + IP: c.ClientIP(), + UserAgent: c.Request.UserAgent(), + Headers: headersStr, + Status: int32(status), + Latency: latency, + CreatedAt: time.Now(), + } + + // 非阻塞地推入缓存队列 + QueueAccessLog(logItem) + } +} diff --git a/backend/plugins/domain/risk_control/middleware_test.go b/backend/plugins/domain/risk_control/middleware_test.go new file mode 100644 index 00000000..e9319004 --- /dev/null +++ b/backend/plugins/domain/risk_control/middleware_test.go @@ -0,0 +1,227 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package risk_control_test + +import ( + "Wavelet/core/contracts" + "Wavelet/pkg/batchwriter" + "Wavelet/pkg/ginutil" + "Wavelet/pkg/idgen" + "Wavelet/pkg/testhelper" + "Wavelet/plugins/domain/risk_control" + "Wavelet/plugins/domain/risk_control/logstore" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +func init() { + _ = idgen.Init(1) +} + +func newTestAccessLogWriter(t *testing.T, cfg batchwriter.Config) (*batchwriter.Writer[*logstore.UserAccessLog], func() []*logstore.UserAccessLog) { + t.Helper() + + var ( + mu sync.Mutex + captured []*logstore.UserAccessLog + ) + writer, err := batchwriter.New(cfg, func(_ context.Context, items []*logstore.UserAccessLog) error { + mu.Lock() + captured = append(captured, items...) + mu.Unlock() + return nil + }) + if err != nil { + t.Fatalf("batchwriter.New() error = %v", err) + } + + writer.Start(context.Background()) + restore := risk_control.SetLogWriterForTest(writer) + t.Cleanup(func() { + restore() + stopCtx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _ = writer.Stop(stopCtx) + }) + + return writer, func() []*logstore.UserAccessLog { + mu.Lock() + defer mu.Unlock() + return append([]*logstore.UserAccessLog(nil), captured...) + } +} + +func drainAccessLogWriter(t *testing.T, writer *batchwriter.Writer[*logstore.UserAccessLog]) { + t.Helper() + + stopCtx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := writer.Stop(stopCtx); err != nil { + t.Fatalf("writer.Stop() error = %v", err) + } +} + +func TestRiskControlMiddleware(t *testing.T) { + gin.SetMode(gin.TestMode) + + t.Run("ClickHouse disabled", func(t *testing.T) { + risk_control.SetAccessLogEnabled(false) + defer risk_control.SetAccessLogEnabled(false) + + r := testhelper.NewTestGinEngine(risk_control.RiskControlMiddleware()) + r.GET("/test", func(c *gin.Context) { + c.String(http.StatusOK, "ok") + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/test", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "ok", w.Body.String()) + }) + + t.Run("ClickHouse enabled - Normal Authenticated Request", func(t *testing.T) { + risk_control.SetAccessLogEnabled(true) + defer risk_control.SetAccessLogEnabled(false) + + cfg := batchwriter.DefaultConfig() + cfg.MaxBatchSize = 100 + cfg.FlushInterval = time.Hour + + writer, getCaptured := newTestAccessLogWriter(t, cfg) + + r := gin.New() + r.Use(func(c *gin.Context) { + user := &contracts.UserDTO{ID: 12345} + ginutil.SetToContext[*contracts.UserDTO](c, contracts.AuthUserObjKey, user) + c.Next() + }) + r.Use(risk_control.RiskControlMiddleware()) + r.GET("/test", func(c *gin.Context) { + c.String(http.StatusOK, "ok") + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("X-Test-Header", "hello") + req.Header.Set("Cookie", "session_id=abcdef123456") + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "ok", w.Body.String()) + + drainAccessLogWriter(t, writer) + + captured := getCaptured() + if len(captured) != 1 { + t.Fatalf("captured access logs = %d, want 1", len(captured)) + } + logItem := captured[0] + assert.Equal(t, uint64(12345), logItem.UserID) + assert.Equal(t, "/test", logItem.Path) + assert.Equal(t, http.MethodGet, logItem.Method) + assert.Equal(t, int32(http.StatusOK), logItem.Status) + assert.NotEmpty(t, logItem.Headers) + assert.Contains(t, logItem.Headers, "X-Test-Header") + assert.NotContains(t, logItem.Headers, "Cookie") + }) + + t.Run("ClickHouse enabled - Unauthenticated Request", func(t *testing.T) { + risk_control.SetAccessLogEnabled(true) + defer risk_control.SetAccessLogEnabled(false) + + cfg := batchwriter.DefaultConfig() + cfg.MaxBatchSize = 100 + cfg.FlushInterval = time.Hour + + writer, getCaptured := newTestAccessLogWriter(t, cfg) + + r := testhelper.NewTestGinEngine(risk_control.RiskControlMiddleware()) + r.GET("/test", func(c *gin.Context) { + c.String(http.StatusOK, "ok") + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/test", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "ok", w.Body.String()) + + drainAccessLogWriter(t, writer) + + if len(getCaptured()) != 0 { + t.Fatal("expected no log item for unauthenticated request") + } + }) + + t.Run("ClickHouse enabled - Buffer Full Rate Limiting", func(t *testing.T) { + risk_control.SetAccessLogEnabled(true) + defer risk_control.SetAccessLogEnabled(false) + + cfg := batchwriter.DefaultConfig() + cfg.QueueSize = 2 + cfg.MaxBatchSize = 1 + cfg.FlushInterval = time.Hour + + blockCh := make(chan struct{}) + enteredCh := make(chan struct{}) + + writer, err := batchwriter.New(cfg, func(_ context.Context, items []*logstore.UserAccessLog) error { + select { + case enteredCh <- struct{}{}: + default: + } + <-blockCh + return nil + }) + assert.NoError(t, err) + writer.Start(context.Background()) + restore := risk_control.SetLogWriterForTest(writer) + defer func() { + close(blockCh) + restore() + stopCtx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _ = writer.Stop(stopCtx) + }() + + // 1. 推入 1 个 item,worker 立即取走并触发 flush(),阻塞在 <-blockCh + writer.TryEnqueue(&logstore.UserAccessLog{}) + <-enteredCh + + // 2. 此时 worker 卡在 flush(),无法从 channel 取数据,推入 2 个 item 填满 channel + for range cfg.QueueSize { + writer.TryEnqueue(&logstore.UserAccessLog{}) + } + if !risk_control.IsBufferFull() { + t.Fatal("IsBufferFull() = false, want true") + } + + r := testhelper.NewTestGinEngine(risk_control.RiskControlMiddleware()) + r.GET("/test", func(c *gin.Context) { + c.String(http.StatusOK, "ok") + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/test", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusTooManyRequests, w.Code) + + var resp map[string]interface{} + err = json.Unmarshal(w.Body.Bytes(), &resp) + assert.NoError(t, err) + assert.Contains(t, resp["error_msg"], "系统繁忙") + }) +} diff --git a/backend/plugins/domain/risk_control/models.go b/backend/plugins/domain/risk_control/models.go new file mode 100644 index 00000000..bab163b0 --- /dev/null +++ b/backend/plugins/domain/risk_control/models.go @@ -0,0 +1,43 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package risk_control + +import ( + "fmt" + "time" +) + +const ( + userAccessLogTableName = "w_user_access_logs" + userAccessLogInsertColumns = "id, user_id, path, method, ip, user_agent, headers, status, latency, created_at" +) + +// UserAccessLog stores HTTP access records in ClickHouse/database. +type UserAccessLog struct { + ID uint64 `gorm:"column:id"` + UserID uint64 `gorm:"column:user_id"` + Path string `gorm:"column:path"` + Method string `gorm:"column:method"` + IP string `gorm:"column:ip"` + UserAgent string `gorm:"column:user_agent"` + Headers string `gorm:"column:headers"` + Status int32 `gorm:"column:status"` + Latency int64 `gorm:"column:latency"` + CreatedAt time.Time `gorm:"column:created_at"` +} + +// TableName returns the table name. +func (UserAccessLog) TableName() string { + return userAccessLogTableName +} + +// InsertColumns returns comma-separated column names for batch insert. +func (UserAccessLog) InsertColumns() string { + return userAccessLogInsertColumns +} + +// BatchInsertSQL returns the INSERT prefix used by native batch writers. +func (UserAccessLog) BatchInsertSQL() string { + return fmt.Sprintf("INSERT INTO %s (%s)", userAccessLogTableName, userAccessLogInsertColumns) +} diff --git a/backend/plugins/domain/risk_control/plugin.go b/backend/plugins/domain/risk_control/plugin.go new file mode 100644 index 00000000..3976fc3a --- /dev/null +++ b/backend/plugins/domain/risk_control/plugin.go @@ -0,0 +1,148 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package risk_control provides the access control, IP rate limiting, and telemetry risk analysis domain plugin for Cordis. +package risk_control + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "Wavelet/core/extpoints" + "Wavelet/plugins/domain/risk_control/logstore" + "context" + "embed" + "reflect" + + "github.com/gin-gonic/gin" +) + +//go:embed logstore/migrations/*/*.sql +var riskControlMigrations embed.FS + +// Option configures the risk_control plugin. +type Option func(*Plugin) + +// WithMiddleware configures a custom risk control middleware. +func WithMiddleware(mw gin.HandlerFunc) Option { + return func(p *Plugin) { + p.middleware = mw + } +} + +// Plugin implements core.Plugin to provide risk control and access logging middleware. +type Plugin struct { + middleware gin.HandlerFunc +} + +// New creates a new risk_control domain plugin. +func New(opts ...Option) *Plugin { + p := &Plugin{} + for _, opt := range opts { + if opt != nil { + opt(p) + } + } + return p +} + +// Name returns the unique identifier for the risk_control domain plugin. +func (p *Plugin) Name() string { + return "risk_control" +} + +// Inject declares required dependencies for the risk_control domain plugin. +func (p *Plugin) Inject() []reflect.Type { + return []reflect.Type{ + reflect.TypeFor[contracts.DBService](), + reflect.TypeFor[contracts.CacheService](), + } +} + +// Manifest returns the plugin metadata. +func (p *Plugin) Manifest() core.Manifest { + return core.Manifest{ + Name: "risk_control", + Version: "1.0.0", + Description: "Access control, IP rate limiting, and access log telemetry domain plugin", + Author: "Wavelet Team", + } +} + +type rcClickHouseConfig struct { + Enabled bool `config:"enabled" env:"CLICKHOUSE_ENABLED" default:"false" autoEnable:"CLICKHOUSE_HOST"` +} + +type rcDBConfig struct { + Enabled bool `config:"enabled" env:"DB_ENABLED" default:"false" autoEnable:"DB_HOST"` +} + +// DeclareConfig declares configuration bindings for the risk_control plugin. +func (p *Plugin) DeclareConfig() []core.ConfigBinding { + return []core.ConfigBinding{ + {Prefix: "clickhouse", Target: &rcClickHouseConfig{}}, + {Prefix: "database", Target: &rcDBConfig{}}, + } +} + +// Apply registers risk control middlewares, settings, and cleanup hooks into the Context. +func (p *Plugin) Apply(ctx *core.Context) error { + var chCfg rcClickHouseConfig + _ = ctx.Config().Bind("clickhouse", &chCfg) + var dbCfg rcDBConfig + _ = ctx.Config().Bind("database", &dbCfg) + + SetAccessLogEnabled(chCfg.Enabled) + logstore.SetDefaultDatabases(dbCfg.Enabled, chCfg.Enabled) + + // 0. Bind DBService + if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil { + logstore.SetDBService(db) + } else { + core.When[contracts.DBService](ctx, func(db contracts.DBService) { + logstore.SetDBService(db) + }) + } + ctx.OnDispose(func() error { + logstore.SetDBService(nil) + return nil + }) + + // 0. Register user access log table migrations + ctx.Migrations().Register("risk_control/logstore", riskControlMigrations) + + // 1. Initialize LogWriter if needed + InitLogWriter(ctx.GoContext()) + + // 2. Register router middleware + mw := p.middleware + if mw == nil { + mw = RiskControlMiddleware() + } + ctx.Router().Use(mw) + + // 3. Register Settings Schemas + ctx.Settings().Register(extpoints.SettingSchema{ + Key: "risk_control.ip_rate_limit_per_minute", + Default: 60, + Description: "Maximum requests allowed per IP per minute", + Type: "integer", + Category: "security", + }) + ctx.Settings().Register(extpoints.SettingSchema{ + Key: "risk_control.enable_access_log", + Default: true, + Description: "Enable structured access log auditing and backpressure queueing", + Type: "boolean", + Category: "security", + }) + + // 4. Register RiskControlService contract + core.Provide[contracts.RiskControlService](ctx, &riskControlServiceImpl{}) + + // 5. Register lifecycle disposal cleanup + ctx.OnDispose(func() error { + return StopLogWriter(context.Background()) + }) + + return nil +} diff --git a/backend/plugins/domain/risk_control/plugin_test.go b/backend/plugins/domain/risk_control/plugin_test.go new file mode 100644 index 00000000..fbc37a6d --- /dev/null +++ b/backend/plugins/domain/risk_control/plugin_test.go @@ -0,0 +1,41 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package risk_control_test + +import ( + "Wavelet/core" + "Wavelet/plugins/domain/risk_control" + "context" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRiskControlPluginUnit(t *testing.T) { + ctx := core.NewContext(context.Background()) + var customMWCalled bool + customMW := func(c *gin.Context) { + customMWCalled = true + c.Next() + } + + p := risk_control.New(risk_control.WithMiddleware(customMW)) + assert.Equal(t, "risk_control", p.Name()) + assert.Equal(t, "1.0.0", p.Manifest().Version) + require.NoError(t, p.Apply(ctx)) + + // Verify middlewares registered + mws := ctx.Router().Middlewares() + assert.NotEmpty(t, mws) + + // Verify settings + setting, ok := ctx.Settings().Get("risk_control.enable_access_log") + require.True(t, ok) + assert.Equal(t, true, setting.Default) + + require.NoError(t, ctx.Dispose()) + assert.False(t, customMWCalled) // not dispatched via gin engine here +} diff --git a/backend/plugins/domain/risk_control/repository.go b/backend/plugins/domain/risk_control/repository.go new file mode 100644 index 00000000..6c7d474b --- /dev/null +++ b/backend/plugins/domain/risk_control/repository.go @@ -0,0 +1,124 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package risk_control + +import ( + "Wavelet/plugins/domain/risk_control/logstore" + "context" +) + +// Repository 层:本插件根包内唯一的持久化访问入口。 +// +// 真正的 SQL / 驱动实现位于 logstore 子包(受 logstore skill 约束的存储抽象), +// 本文件负责解析当前生效日志库并转发读写、迁移与查询,使 service.go 只做用例编排。 + +// accessLogMigrationBatchSize 是日志引擎迁移时单批搬运的行数。 +const accessLogMigrationBatchSize = 1000 + +// writeAccessLogBatch 持久化批写缓冲队列中取出的一批访问日志。 +func writeAccessLogBatch(ctx context.Context, items []*logstore.UserAccessLog) error { + rows := make([]logstore.UserAccessLog, 0, len(items)) + for _, item := range items { + if item == nil { + continue + } + rows = append(rows, *item) + } + store, err := logstore.Active(ctx) + if err != nil { + return err + } + return store.UserAccessLogs.BatchInsert(ctx, rows) +} + +// listAccessLogs 按过滤条件读取一页访问日志。 +func listAccessLogs(ctx context.Context, filter logstore.AccessLogFilter, page, pageSize int) ([]logstore.UserAccessLog, uint64, error) { + store, err := logstore.Active(ctx) + if err != nil { + return nil, 0, err + } + return store.UserAccessLogs.List(ctx, filter, page, pageSize) +} + +// accessLogDailyTrend 读取最近 days 天的按天访问趋势。 +func accessLogDailyTrend(ctx context.Context, days int) ([]logstore.DailyTrend, error) { + store, err := logstore.Active(ctx) + if err != nil { + return nil, err + } + return store.UserAccessLogs.GetDailyTrend(ctx, days) +} + +// activeLogDatabase 返回当前生效日志库的引擎标识。 +func activeLogDatabase(ctx context.Context) (string, error) { + store, err := logstore.Active(ctx) + if err != nil { + return "", err + } + return store.Status.ActiveDatabase(ctx) +} + +// logStoreMigrating 报告日志库是否处于迁移冻结期。 +func logStoreMigrating(ctx context.Context) bool { + return logstore.Migrating(ctx) +} + +// loadMigrationStores 解析迁移源(当前生效库)与目标引擎库。 +func loadMigrationStores(ctx context.Context, targetEngine string) (src, dst *logstore.Store, err error) { + src, err = logstore.Active(ctx) + if err != nil { + return nil, nil, err + } + dst, err = logstore.BuildForMigration(ctx, targetEngine) + if err != nil { + return nil, nil, err + } + return src, dst, nil +} + +// copyAccessLogs 清空目标库、按源库时间范围预建分区后分批搬运全部源数据, +// 并在每批完成后通过 reportProgress 回调累计已搬运行数。 +func copyAccessLogs(ctx context.Context, src, dst *logstore.Store, reportProgress func(copied int)) error { + if _, err := dst.UserAccessLogs.DeleteAll(ctx); err != nil { + return err + } + from, to, err := src.UserAccessLogs.MigrationRange(ctx) + if err != nil { + return err + } + if !from.IsZero() && !to.IsZero() { + if err := dst.UserAccessLogs.EnsurePartitions(ctx, from, to); err != nil { + return err + } + } + + var afterID uint64 + var copied int + for { + rows, err := src.UserAccessLogs.ListForMigration(ctx, afterID, accessLogMigrationBatchSize) + if err != nil { + return err + } + if len(rows) == 0 { + break + } + if err := dst.UserAccessLogs.BatchInsert(ctx, rows); err != nil { + return err + } + afterID = rows[len(rows)-1].ID + copied += len(rows) + if reportProgress != nil { + reportProgress(copied) + } + if len(rows) < accessLogMigrationBatchSize { + break + } + } + return nil +} + +// resetLogStoreCache 丢弃缓存的生效日志库,使下一次访问重新解析。 +func resetLogStoreCache() { + logstore.InvalidateCache() +} diff --git a/backend/plugins/domain/risk_control/service.go b/backend/plugins/domain/risk_control/service.go new file mode 100644 index 00000000..d25c84fd --- /dev/null +++ b/backend/plugins/domain/risk_control/service.go @@ -0,0 +1,213 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package risk_control + +import ( + "Wavelet/core/contracts" + "Wavelet/pkg/batchwriter" + "Wavelet/pkg/logger" + "Wavelet/plugins/domain/risk_control/logstore" + "context" + "sync" + "time" +) + +// fallbackLogEngine 是日志库状态不可得时对外暴露的引擎标识。 +const fallbackLogEngine = "sqlite" + +var ( + logWriterMu sync.RWMutex + logWriter *batchwriter.Writer[*logstore.UserAccessLog] +) + +// InitLogWriter initializes the access-log batch writer for the active log database. +func InitLogWriter(ctx context.Context) { + logWriterMu.Lock() + defer logWriterMu.Unlock() + if logWriter != nil { + return + } + + cfg := batchwriter.DefaultConfig() + writer, err := batchwriter.New[*logstore.UserAccessLog](cfg, writeAccessLogBatch, + batchwriter.WithDropHandler[*logstore.UserAccessLog](func(item *logstore.UserAccessLog) { + path := "" + if item != nil { + path = item.Path + } + logger.WarnF(context.Background(), "[RiskControl] Log queue full, dropping log item for path: %s", path) + }), + batchwriter.WithFlushErrorHandler[*logstore.UserAccessLog](func(ctx context.Context, items []*logstore.UserAccessLog, err error) { + logger.ErrorF(ctx, "[RiskControl] flush access-log batch failed (batch=%d): %v", len(items), err) + }), + ) + if err != nil { + logger.ErrorF(ctx, "[RiskControl] init log writer failed: %v", err) + return + } + + writer.Start(ctx) + logWriter = writer +} + +// StopLogWriter stops the ClickHouse access-log batch writer and drains pending logs. +func StopLogWriter(ctx context.Context) error { + writer := currentLogWriter() + if writer == nil { + return nil + } + return writer.Stop(ctx) +} + +// IsBufferFull reports whether the access-log queue has no remaining capacity. +func IsBufferFull() bool { + writer := currentLogWriter() + if writer == nil { + return false + } + return writer.IsFull() +} + +// QueueAccessLog enqueues an access log without blocking. +func QueueAccessLog(logItem *logstore.UserAccessLog) { + writer := currentLogWriter() + if writer == nil || logItem == nil { + return + } + writer.TryEnqueue(logItem) +} + +// SetLogWriterForTest swaps the access-log writer for unit tests. +func SetLogWriterForTest(writer *batchwriter.Writer[*logstore.UserAccessLog]) func() { + logWriterMu.Lock() + previous := logWriter + logWriter = writer + logWriterMu.Unlock() + return func() { + logWriterMu.Lock() + logWriter = previous + logWriterMu.Unlock() + } +} + +func currentLogWriter() *batchwriter.Writer[*logstore.UserAccessLog] { + logWriterMu.RLock() + defer logWriterMu.RUnlock() + return logWriter +} + +const drainPollInterval = 50 * time.Millisecond + +// Drain waits until the in-memory access-log queue has been empty for one flush interval. +func Drain(ctx context.Context) error { + writer := currentLogWriter() + if writer == nil { + return nil + } + quietPeriod := batchwriter.DefaultConfig().FlushInterval + if quietPeriod <= 0 { + quietPeriod = time.Second + } + ticker := time.NewTicker(drainPollInterval) + defer ticker.Stop() + var quietSince time.Time + for { + if writer.Len() == 0 { + if quietSince.IsZero() { + quietSince = time.Now() + } else if time.Since(quietSince) >= quietPeriod { + return nil + } + } else { + quietSince = time.Time{} + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } +} + +// MigrateAndSwitchEngine migrates access logs to target database and switches the active store. +func MigrateAndSwitchEngine(ctx context.Context, targetEngine string, reportProgress func(copied int)) error { + if err := Drain(ctx); err != nil { + return err + } + src, dst, err := loadMigrationStores(ctx, targetEngine) + if err != nil { + return err + } + if err := copyAccessLogs(ctx, src, dst, reportProgress); err != nil { + return err + } + resetLogStoreCache() + return nil +} + +// riskControlServiceImpl implements contracts.RiskControlService by orchestrating +// the repository layer and mapping persistence rows into contract DTOs. +type riskControlServiceImpl struct{} + +func (s *riskControlServiceImpl) QueryAccessLogs(ctx context.Context, filter contracts.AccessLogFilterDTO, page, pageSize int) ([]contracts.AccessLogDTO, uint64, error) { + list, total, err := listAccessLogs(ctx, logstore.AccessLogFilter{ + UserIDs: filter.UserIDs, + Path: filter.Path, + StartTime: filter.StartTime, + EndTime: filter.EndTime, + }, page, pageSize) + if err != nil { + return nil, 0, err + } + items := make([]contracts.AccessLogDTO, len(list)) + for i, item := range list { + items[i] = contracts.AccessLogDTO{ + ID: item.ID, + UserID: item.UserID, + IP: item.IP, + UserAgent: item.UserAgent, + Method: item.Method, + Path: item.Path, + Status: item.Status, + Latency: item.Latency, + CreatedAt: item.CreatedAt, + } + } + return items, total, nil +} + +func (s *riskControlServiceImpl) QueryAccessLogStats(ctx context.Context, days int) ([]contracts.AccessLogDailyStatsDTO, error) { + trend, err := accessLogDailyTrend(ctx, days) + if err != nil { + return nil, err + } + res := make([]contracts.AccessLogDailyStatsDTO, len(trend)) + for i, t := range trend { + res[i] = contracts.AccessLogDailyStatsDTO{ + Date: t.Date, + PV: t.Count, + } + } + return res, nil +} + +func (s *riskControlServiceImpl) ActiveLogEngine(ctx context.Context) string { + engine, err := activeLogDatabase(ctx) + if err != nil { + return fallbackLogEngine + } + return engine +} + +func (s *riskControlServiceImpl) IsLogEngineMigrating(ctx context.Context) bool { + return logStoreMigrating(ctx) +} + +func (s *riskControlServiceImpl) Drain(ctx context.Context) error { + return Drain(ctx) +} + +func (s *riskControlServiceImpl) SwitchLogEngine(ctx context.Context, targetEngine string) error { + return MigrateAndSwitchEngine(ctx, targetEngine, nil) +} diff --git a/backend/plugins/domain/system/plugin.go b/backend/plugins/domain/system/plugin.go new file mode 100644 index 00000000..97a3b35d --- /dev/null +++ b/backend/plugins/domain/system/plugin.go @@ -0,0 +1,80 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package system provides core system health probes, public config endpoints, and static frontend assets dispatch plugin for Cordis. +package system + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "Wavelet/pkg/logger" + "Wavelet/pkg/response" + "net/http" + "reflect" + + "github.com/gin-gonic/gin" +) + +// Plugin implements core.Plugin to provide system-level basic routes. +type Plugin struct{} + +// New creates a new system domain plugin. +func New() *Plugin { + return &Plugin{} +} + +// Name returns the unique identifier for the system domain plugin. +func (p *Plugin) Name() string { + return "system" +} + +// Inject declares required dependencies for the system domain plugin. +func (p *Plugin) Inject() []reflect.Type { + return []reflect.Type{ + reflect.TypeFor[contracts.DBService](), + } +} + +// Manifest returns the plugin metadata. +func (p *Plugin) Manifest() core.Manifest { + return core.Manifest{ + Name: "system", + Version: "1.0.0", + Description: "System health check, public config, and assets domain plugin", + Author: "Wavelet Team", + } +} + +// Apply registers system routes. +func (p *Plugin) Apply(ctx *core.Context) error { + appName := ctx.Config().String("app.app_name", "Wavelet") + + // 1. Health check + ctx.Router().GET("/healthz", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) + ctx.Router().GET("/api/healthz", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) + + // 2. Public config + ctx.Router().GET("/api/v1/config/public", func(c *gin.Context) { + configs, err := listPublicSystemConfigs(c.Request.Context(), ctx) + if err != nil { + logger.ErrorF(c.Request.Context(), "[System] query public system configs failed: %v", err) + } + c.JSON(http.StatusOK, response.OK(gin.H{ + "configs": configs, + "app": gin.H{ + "name": appName, + }, + })) + }) + + // 3. Custom injection + ctx.Router().GET("/custom", func(c *gin.Context) { + c.JSON(http.StatusOK, response.OK(gin.H{"custom": true})) + }) + + return nil +} diff --git a/backend/plugins/domain/system/repository.go b/backend/plugins/domain/system/repository.go new file mode 100644 index 00000000..612feb4c --- /dev/null +++ b/backend/plugins/domain/system/repository.go @@ -0,0 +1,35 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package system + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "context" +) + +// publicSystemConfig 前端公共配置接口的只读投影。 +type publicSystemConfig struct { + Key string `json:"key"` + Value string `json:"value"` +} + +// listPublicSystemConfigs 读取对前端可见的系统配置项。 +// +// 注意:w_system_configs 的所有者插件是 admin,contracts 目前尚未暴露读取契约, +// 因此此处仍只能直连只读查询;待 admin 提供 SettingsService 契约后应改为调用契约。 +func listPublicSystemConfigs(ctx context.Context, appCtx *core.Context) ([]publicSystemConfig, error) { + dbSvc, err := core.Inject[contracts.DBService](appCtx) + if err != nil { + return nil, err + } + if dbSvc == nil { + return nil, nil + } + var configs []publicSystemConfig + err = dbSvc.DB(ctx).Table("w_system_configs"). + Where("visibility = ?", "visible"). + Find(&configs).Error + return configs, err +} diff --git a/backend/plugins/domain/upload/cache/access_cache.go b/backend/plugins/domain/upload/cache/access_cache.go new file mode 100644 index 00000000..134a3a49 --- /dev/null +++ b/backend/plugins/domain/upload/cache/access_cache.go @@ -0,0 +1,154 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package cache provides in-process upload access-control caches. +package cache + +import ( + "Wavelet/pkg/logger" + "Wavelet/plugins/domain/upload/shared" + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + "time" + + "gorm.io/gorm" + + uploadstorage "Wavelet/plugins/domain/upload/storage" +) + +const fileAccessInvalidationChannel = "upload:file_access_invalidation" + +var ( + fileAccessWhitelistMu sync.RWMutex + fileAccessWhitelistTypes map[string]struct{} + fileAccessWhitelistValid bool + fileAccessWhitelistCheckedAt time.Time +) + +// ResetAccessCaches clears in-process upload access caches. +func ResetAccessCaches() { + uploadstorage.ResetMigrationAccessCache() + + fileAccessWhitelistMu.Lock() + fileAccessWhitelistValid = false + fileAccessWhitelistTypes = nil + fileAccessWhitelistMu.Unlock() +} + +// PublishAccessCacheInvalidation broadcasts upload access cache eviction to all nodes. +func PublishAccessCacheInvalidation(ctx context.Context) { + if cache := shared.GetCache(ctx); cache != nil { + _ = cache.Invalidate(ctx, fileAccessInvalidationChannel) + } + ResetAccessCaches() +} + +// IsFilePublic reports whether uploadType is in the public access whitelist. +func IsFilePublic(ctx context.Context, uploadType string) bool { + whitelist := loadFileAccessWhitelist(ctx) + _, ok := whitelist[strings.ToLower(uploadType)] + return ok +} + +func loadFileAccessWhitelist(ctx context.Context) map[string]struct{} { + fileAccessWhitelistMu.RLock() + if fileAccessWhitelistValid && time.Since(fileAccessWhitelistCheckedAt) < time.Duration(shared.AccessCacheTTL)*time.Second { + types := fileAccessWhitelistTypes + fileAccessWhitelistMu.RUnlock() + return types + } + fileAccessWhitelistMu.RUnlock() + + fileAccessWhitelistMu.Lock() + defer fileAccessWhitelistMu.Unlock() + + if fileAccessWhitelistValid && time.Since(fileAccessWhitelistCheckedAt) < time.Duration(shared.AccessCacheTTL)*time.Second { + return fileAccessWhitelistTypes + } + + types, err := fetchFileAccessWhitelist(ctx) + if err != nil { + logger.ErrorF(ctx, "[Upload] 读取公开访问白名单失败: %v", err) + if fileAccessWhitelistTypes != nil { + // A previously loaded list is still the best answer; keep it until its + // TTL rather than narrowing access because one read failed. + fileAccessWhitelistValid = true + fileAccessWhitelistCheckedAt = time.Now() + return fileAccessWhitelistTypes + } + // Nothing cached yet: serve the restricted default but stay invalid so the + // next request retries instead of pinning it for the whole TTL. + return fallbackFileAccessWhitelist() + } + + fileAccessWhitelistTypes = types + fileAccessWhitelistValid = true + fileAccessWhitelistCheckedAt = time.Now() + return types +} + +// fallbackFileAccessWhitelist is the restricted default used when nothing better is known. +func fallbackFileAccessWhitelist() map[string]struct{} { + return map[string]struct{}{strings.ToLower(shared.DefaultPublicUploadType): {}} +} + +func fetchFileAccessWhitelist(ctx context.Context) (map[string]struct{}, error) { + whitelist, err := parseFileAccessWhitelist(ctx) + if err != nil { + return nil, err + } + types := make(map[string]struct{}, len(whitelist)) + for _, item := range whitelist { + types[strings.ToLower(item)] = struct{}{} + } + return types, nil +} + +// parseFileAccessWhitelist reads the configured public access types. +// +// A missing row or an empty value is a real answer and yields the default; only a +// read that actually fails is reported as an error, so a database outage is no +// longer mistaken for "the admin never configured a whitelist". +func parseFileAccessWhitelist(ctx context.Context) ([]string, error) { + var sc struct{ Value string } + db := shared.GetDB(ctx) + if db == nil { + return nil, errors.New("database not available") + } + if err := db.Table("w_system_configs").Where("key = ?", "file_access_whitelist").First(&sc).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return []string{shared.DefaultPublicUploadType}, nil + } + return nil, fmt.Errorf("read file_access_whitelist: %w", err) + } + if sc.Value == "" { + return []string{shared.DefaultPublicUploadType}, nil + } + + var whitelist []string + if err := json.Unmarshal([]byte(sc.Value), &whitelist); err == nil && len(whitelist) > 0 { + return whitelist, nil + } + + whitelist = parseCommaSeparatedWhitelist(sc.Value) + if len(whitelist) == 0 { + return []string{shared.DefaultPublicUploadType}, nil + } + return whitelist, nil +} + +func parseCommaSeparatedWhitelist(value string) []string { + parts := strings.Split(value, ",") + whitelist := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + whitelist = append(whitelist, part) + } + } + return whitelist +} diff --git a/backend/plugins/domain/upload/cache/access_cache_retry_test.go b/backend/plugins/domain/upload/cache/access_cache_retry_test.go new file mode 100644 index 00000000..432c0ed8 --- /dev/null +++ b/backend/plugins/domain/upload/cache/access_cache_retry_test.go @@ -0,0 +1,56 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package cache + +import ( + "Wavelet/plugins/domain/upload/shared" + "context" + "testing" +) + +// configRow mirrors just enough of w_system_configs to rebuild it mid-test. +type configRow struct { + Key string `gorm:"primaryKey;size:64"` + Value string `gorm:"type:text;not null"` + Type string `gorm:"size:32;not null"` +} + +// TableName returns the system config table. +func (configRow) TableName() string { return "w_system_configs" } + +// TestWhitelistReadFailureIsNotCachedAsConfigured pins the defect where a failed +// read of the public access whitelist was discarded and the resulting restricted +// default was then cached as though the admin had configured it, narrowing access +// for the whole TTL with nothing to retry it sooner. +func TestWhitelistReadFailureIsNotCachedAsConfigured(t *testing.T) { + db, cleanup := shared.SetupTestEnv(t) + defer cleanup() + ResetAccessCaches() + t.Cleanup(ResetAccessCaches) + + ctx := context.Background() + + // Make the config unreadable, then take one sample through the failure path. + if err := db.Exec("DROP TABLE w_system_configs").Error; err != nil { + t.Fatalf("drop config table: %v", err) + } + if IsFilePublic(ctx, "document") { + t.Fatal(`document reported public while the whitelist cannot be read`) + } + + // Restore a configured whitelist that includes document. + if err := db.AutoMigrate(&configRow{}); err != nil { + t.Fatalf("recreate config table: %v", err) + } + row := configRow{Key: "file_access_whitelist", Value: `["avatar","document"]`, Type: "system"} + if err := db.Create(&row).Error; err != nil { + t.Fatalf("seed whitelist: %v", err) + } + + // A failed first read must not have been cached as a real answer, so this call + // has to re-read and see the configured list. + if !IsFilePublic(ctx, "document") { + t.Error("whitelist stayed pinned to the default after a read failure; the failed lookup must be retried") + } +} diff --git a/backend/plugins/domain/upload/cache/access_cache_test.go b/backend/plugins/domain/upload/cache/access_cache_test.go new file mode 100644 index 00000000..e4d47833 --- /dev/null +++ b/backend/plugins/domain/upload/cache/access_cache_test.go @@ -0,0 +1,88 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package cache + +import ( + "Wavelet/plugins/domain/upload/shared" + "context" + "testing" + "time" + + uploadstorage "Wavelet/plugins/domain/upload/storage" +) + +func TestLoadMigrationAccessStateCachesResult(t *testing.T) { + _, cleanup := shared.SetupTestEnv(t) + defer cleanup() + ResetAccessCaches() + + ctx := context.Background() + first := uploadstorage.LoadMigrationAccessState(ctx) + second := uploadstorage.LoadMigrationAccessState(ctx) + + if first.ReadOnly != second.ReadOnly { + t.Fatalf("readOnly mismatch: first=%v second=%v", first.ReadOnly, second.ReadOnly) + } + if first.HasTarget != second.HasTarget { + t.Fatalf("hasTarget mismatch: first=%v second=%v", first.HasTarget, second.HasTarget) + } +} + +func TestIsFilePublicUsesCachedWhitelist(t *testing.T) { + _, cleanup := shared.SetupTestEnv(t) + defer cleanup() + ResetAccessCaches() + + ctx := context.Background() + if !IsFilePublic(ctx, "avatar") { + t.Fatal("expected avatar to be public by default") + } + if IsFilePublic(ctx, "attachment") { + t.Fatal("expected attachment to be private by default") + } + if !IsFilePublic(ctx, "AVATAR") { + t.Fatal("expected whitelist lookup to be case-insensitive") + } +} + +func TestResetAccessCachesRefreshesWhitelist(t *testing.T) { + dbConn, cleanup := shared.SetupTestEnv(t) + defer cleanup() + ResetAccessCaches() + + ctx := context.Background() + if !IsFilePublic(ctx, "avatar") { + t.Fatal("expected seeded avatar whitelist before reset") + } + + if err := dbConn.Table("w_system_configs").Where("key = ?", "file_access_whitelist").Update("value", `["attachment"]`).Error; err != nil { + t.Fatalf("update whitelist config: %v", err) + } + + ResetAccessCaches() + if !IsFilePublic(ctx, "attachment") { + t.Fatal("expected attachment to be public after whitelist refresh") + } + if IsFilePublic(ctx, "avatar") { + t.Fatal("expected avatar to be private after whitelist refresh") + } +} + +func TestAccessCacheTTLExpires(t *testing.T) { + _, cleanup := shared.SetupTestEnv(t) + defer cleanup() + ResetAccessCaches() + + ctx := context.Background() + _ = loadFileAccessWhitelist(ctx) + + fileAccessWhitelistMu.Lock() + fileAccessWhitelistCheckedAt = time.Now().Add(-time.Duration(shared.AccessCacheTTL)*time.Second - time.Second) + fileAccessWhitelistMu.Unlock() + + // Should still work after TTL by reloading from config. + if !IsFilePublic(ctx, "avatar") { + t.Fatal("expected whitelist reload after TTL expiration") + } +} diff --git a/backend/plugins/domain/upload/cache/meta_cache.go b/backend/plugins/domain/upload/cache/meta_cache.go new file mode 100644 index 00000000..c9fe703b --- /dev/null +++ b/backend/plugins/domain/upload/cache/meta_cache.go @@ -0,0 +1,131 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package cache + +import ( + "Wavelet/pkg/cache/ram" + "Wavelet/plugins/domain/upload/models" + "Wavelet/plugins/domain/upload/shared" + "context" + "fmt" + "time" + + "gorm.io/gorm" +) + +const ( + uploadMetaRedisCacheTTL = 30 * 60 // seconds + uploadMetaRAMMaximumSize = 4096 + uploadMetaInvalidationChan = "upload:meta_invalidation" +) + +var uploadMetaRAM = ram.MustNew[uint64, models.Upload](ram.Options{MaximumSize: uploadMetaRAMMaximumSize}) + +func uploadMetaRedisKey(id uint64) string { + return fmt.Sprintf("upload:meta:%d", id) +} + +func cloneUpload(u models.Upload) models.Upload { + return u +} + +// PublishUploadMetaInvalidation broadcasts upload metadata cache eviction. +func PublishUploadMetaInvalidation(ctx context.Context, id uint64) { + if cache := shared.GetCache(ctx); cache != nil { + _ = cache.Invalidate(ctx, uploadMetaInvalidationChan) + } + EvictUploadMetaLocal(id) +} + +// GetUploadByID loads upload metadata from RAM, Redis, or the database. +func GetUploadByID(ctx context.Context, id uint64) (models.Upload, error) { + if id == 0 { + return models.Upload{}, gorm.ErrRecordNotFound + } + + // 1. RAM L1 Cache + if u, ok := uploadMetaRAM.GetIfPresent(id); ok { + return cloneUpload(u), nil + } + + key := uploadMetaRedisKey(id) + + // 2. Redis L2 Cache + if cache := shared.GetCache(ctx); cache != nil { + var u models.Upload + if err := cache.Get(ctx, key, &u); err == nil { + uploadMetaRAM.Set(id, u) + return cloneUpload(u), nil + } + } + + // 3. Database L3 Source of Truth + var upload models.Upload + db := shared.GetDB(ctx) + if db == nil { + return models.Upload{}, gorm.ErrRecordNotFound + } + if err := db. + Where("id = ? AND status != ?", id, models.UploadStatusDeleted). + First(&upload).Error; err != nil { + return models.Upload{}, err + } + + SetUploadMeta(ctx, upload) + return cloneUpload(upload), nil +} + +// SetUploadMeta populates RAM and Redis caches with the provided upload metadata. +func SetUploadMeta(ctx context.Context, u models.Upload) { + if u.ID == 0 { + return + } + cloned := cloneUpload(u) + uploadMetaRAM.Set(u.ID, cloned) + + if cache := shared.GetCache(ctx); cache != nil { + _ = cache.Set(ctx, uploadMetaRedisKey(u.ID), cloned, uploadMetaRedisCacheTTL*time.Second) + } +} + +// EvictUploadMeta evicts an upload metadata record from RAM, Redis, and broadcasts eviction. +func EvictUploadMeta(ctx context.Context, id uint64) { + EvictUploadMetaLocal(id) + + if cache := shared.GetCache(ctx); cache != nil { + _ = cache.Delete(ctx, uploadMetaRedisKey(id)) + } + + PublishUploadMetaInvalidation(ctx, id) +} + +// EvictUploadMetaLocal removes upload metadata from the local process RAM cache only. +func EvictUploadMetaLocal(id uint64) { + uploadMetaRAM.Invalidate(id) +} + +// ResetUploadMetaCache cleans up local memory cache. +func ResetUploadMetaCache() { + uploadMetaRAM.InvalidateAll() +} + +// ResetUploadMetaCacheForTest clears the in-memory cache for tests. +func ResetUploadMetaCacheForTest() { + ResetUploadMetaCache() +} + +// SetUploadMetaCache is a backward-compatible alias for SetUploadMeta. +func SetUploadMetaCache(ctx context.Context, u *models.Upload) { + if u != nil { + SetUploadMeta(ctx, *u) + } +} + +// InvalidateUploadMetaCache is an alias for EvictUploadMeta. +func InvalidateUploadMetaCache(ctx context.Context, id uint64) { + EvictUploadMeta(ctx, id) +} + +// StopUploadMetaCacheListener stops listener for tests. +func StopUploadMetaCacheListener() {} diff --git a/backend/plugins/domain/upload/cache/meta_cache_test.go b/backend/plugins/domain/upload/cache/meta_cache_test.go new file mode 100644 index 00000000..1fc3e719 --- /dev/null +++ b/backend/plugins/domain/upload/cache/meta_cache_test.go @@ -0,0 +1,160 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package cache + +import ( + "Wavelet/pkg/testhelper" + "Wavelet/plugins/domain/upload/models" + "Wavelet/plugins/domain/upload/shared" + "context" + "testing" + + "gorm.io/gorm" +) + +func init() { + testhelper.RegisterCleanup(func() { + ResetUploadMetaCacheForTest() + }) +} + +func seedUpload(t *testing.T, dbConn *gorm.DB, upload models.Upload) { + t.Helper() + if err := dbConn.Create(&upload).Error; err != nil { + t.Fatalf("create upload: %v", err) + } +} + +func TestGetUploadByIDLoadsFromDBAndPopulatesCache(t *testing.T) { + dbConn, cleanup := shared.SetupTestEnv(t) + defer cleanup() + ResetUploadMetaCacheForTest() + + ctx := context.Background() + upload := models.Upload{ + ID: 91001, + UserID: 1, + FileName: "cached.png", + FilePath: "cached.png", + FileSize: 12, + MimeType: "image/png", + Extension: "png", + Type: "avatar", + Status: models.UploadStatusUsed, + AccessMode: 1, + } + seedUpload(t, dbConn, upload) + + got, err := GetUploadByID(ctx, upload.ID) + if err != nil { + t.Fatalf("GetUploadByID: %v", err) + } + if got.ID != upload.ID || got.FileName != upload.FileName { + t.Fatalf("unexpected upload: %+v", got) + } + + if err := dbConn.Delete(&models.Upload{}, upload.ID).Error; err != nil { + t.Fatalf("delete upload from db: %v", err) + } + + gotCached, err := GetUploadByID(ctx, upload.ID) + if err != nil { + t.Fatalf("GetUploadByID from RAM cache: %v", err) + } + if gotCached.ID != upload.ID { + t.Fatalf("expected RAM cache hit for upload %d", upload.ID) + } +} + +func TestGetUploadByIDReadsFromRedisWhenRAMEmpty(t *testing.T) { + dbConn, cleanup := shared.SetupTestEnv(t) + defer cleanup() + ResetUploadMetaCacheForTest() + + ctx := context.Background() + upload := models.Upload{ + ID: 91002, + UserID: 1, + FileName: "redis.png", + FilePath: "redis.png", + FileSize: 8, + MimeType: "image/png", + Extension: "png", + Type: "avatar", + Status: models.UploadStatusPending, + AccessMode: 0, + } + seedUpload(t, dbConn, upload) + SetUploadMetaCache(ctx, &upload) + ResetUploadMetaCacheForTest() + + if err := dbConn.Delete(&models.Upload{}, upload.ID).Error; err != nil { + t.Fatalf("delete upload from db: %v", err) + } + + got, err := GetUploadByID(ctx, upload.ID) + if err != nil { + t.Fatalf("GetUploadByID from redis: %v", err) + } + if got.ID != upload.ID || got.FileName != upload.FileName { + t.Fatalf("unexpected upload from redis: %+v", got) + } +} + +func TestInvalidateUploadMetaCacheClearsRAMAndRedis(t *testing.T) { + dbConn, cleanup := shared.SetupTestEnv(t) + defer cleanup() + ResetUploadMetaCacheForTest() + + ctx := context.Background() + upload := models.Upload{ + ID: 91003, + UserID: 1, + FileName: "invalidate.png", + FilePath: "invalidate.png", + FileSize: 4, + MimeType: "image/png", + Extension: "png", + Type: "avatar", + Status: models.UploadStatusUsed, + AccessMode: 1, + } + seedUpload(t, dbConn, upload) + SetUploadMetaCache(ctx, &upload) + + InvalidateUploadMetaCache(ctx, upload.ID) + + got, err := GetUploadByID(ctx, upload.ID) + if err != nil { + t.Fatalf("GetUploadByID after invalidate should reload from DB: %v", err) + } + if got.ID != upload.ID { + t.Fatalf("unexpected upload reloaded from DB: %+v", got) + } +} + +func TestGetUploadByIDSkipsDeletedUploads(t *testing.T) { + dbConn, cleanup := shared.SetupTestEnv(t) + defer cleanup() + ResetUploadMetaCacheForTest() + + ctx := context.Background() + upload := models.Upload{ + ID: 91004, + UserID: 1, + FileName: "deleted.png", + FilePath: "deleted.png", + FileSize: 4, + MimeType: "image/png", + Extension: "png", + Type: "avatar", + Status: models.UploadStatusDeleted, + AccessMode: 1, + } + seedUpload(t, dbConn, upload) + + if _, err := GetUploadByID(ctx, upload.ID); err == nil { + t.Fatal("expected error for deleted upload") + } +} diff --git a/backend/plugins/domain/upload/errs.go b/backend/plugins/domain/upload/errs.go new file mode 100644 index 00000000..b8d727e1 --- /dev/null +++ b/backend/plugins/domain/upload/errs.go @@ -0,0 +1,38 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package upload 提供文件上传与下载功能 +package upload + +import "Wavelet/plugins/domain/upload/shared" + +// 文件管理常量 +const ( + ErrNoFileSelected = shared.ErrNoFileSelected + ErrUnsupportedFormat = shared.ErrUnsupportedFormat + ErrProcessFileFailed = shared.ErrProcessFileFailed + ErrSaveFileFailed = shared.ErrSaveFileFailed + ErrOpenFileFailed = shared.ErrOpenFileFailed + ErrSaveUploadRecordFailed = shared.ErrSaveUploadRecordFailed + ErrGenericFileTooLarge = shared.ErrGenericFileTooLarge + ErrFileContentExtensionMismatch = shared.ErrFileContentExtensionMismatch + ErrFileValidationFailed = shared.ErrFileValidationFailed + ErrInvalidMetadataJSON = shared.ErrInvalidMetadataJSON + ErrInvalidFileID = shared.ErrInvalidFileID + ErrQueryUploadRecordFailed = shared.ErrQueryUploadRecordFailed + ErrInvalidBatchDownloadRequest = shared.ErrInvalidBatchDownloadRequest + ErrInvalidIDValueFormat = shared.ErrInvalidIDValueFormat + ErrRetrieveUploadRecordsFailed = shared.ErrRetrieveUploadRecordsFailed + ErrNoValidFilesForArchive = shared.ErrNoValidFilesForArchive + ErrInvalidParams = shared.ErrInvalidParams + ErrQueryFileCountFailed = shared.ErrQueryFileCountFailed + ErrQueryFileListFailed = shared.ErrQueryFileListFailed + ErrDeleteFileFailed = shared.ErrDeleteFileFailed + ErrStorageReadOnly = shared.ErrStorageReadOnly + ErrS3KeyRequired = shared.ErrS3KeyRequired + ErrS3KeyTooLongFormat = shared.ErrS3KeyTooLongFormat + ErrS3KeyStartsWithSlash = shared.ErrS3KeyStartsWithSlash + ErrS3KeyContainsNullBytes = shared.ErrS3KeyContainsNullBytes + ErrQueryUnusedUploadsFailed = shared.ErrQueryUnusedUploadsFailed + ErrUnauthorized = shared.ErrUnauthorized +) diff --git a/backend/plugins/domain/upload/exports.go b/backend/plugins/domain/upload/exports.go new file mode 100644 index 00000000..dc789f5f --- /dev/null +++ b/backend/plugins/domain/upload/exports.go @@ -0,0 +1,114 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package upload + +import ( + "Wavelet/plugins/domain/upload/cache" + "Wavelet/plugins/domain/upload/filesrv" + "Wavelet/plugins/domain/upload/handler" + "Wavelet/plugins/domain/upload/ingest" + uploadstats "Wavelet/plugins/domain/upload/stats" + uploadtask "Wavelet/plugins/domain/upload/task" + "Wavelet/plugins/domain/upload/util" +) + +// HTTP handlers +var ( + UploadFile = handler.UploadFile + DownloadFile = handler.DownloadFile + BatchDownloadFiles = handler.BatchDownloadFiles + ListFiles = handler.ListFiles + DeleteFile = handler.DeleteFile + GetDistinctUploadTypes = handler.GetDistinctUploadTypes + ListMyFiles = handler.ListMyFiles + DeleteMyFile = handler.DeleteMyFile + UpdateMyFile = handler.UpdateMyFile + GetFileStats = handler.GetFileStats + ServeFileByID = filesrv.ServeFileByID +) + +// Programmatic ingest API +var ( + Ingest = ingest.Ingest + Remove = ingest.Remove + RemoveOwned = ingest.RemoveOwned + FindByHash = ingest.FindByHash +) + +// Ingest policy constants +const ( + PolicyCreate = ingest.PolicyCreate + PolicyDedupNewRecord = ingest.PolicyDedupNewRecord + PolicyResolveExisting = ingest.PolicyResolveExisting +) + +type ( + // IngestRequest is the programmatic upload ingest payload. + IngestRequest = ingest.Request + // IngestResult reports ingest side effects. + IngestResult = ingest.Result + // IngestPolicy controls hash-collision behavior during ingest. + IngestPolicy = ingest.Policy +) + +// Ingest errors +var ( + ErrIngestForbidden = ingest.ErrForbidden + ErrIngestStorageReadOnly = ingest.ErrStorageReadOnly +) + +// Cache management +var ( + ResetAccessCaches = cache.ResetAccessCaches + PublishAccessCacheInvalidation = cache.PublishAccessCacheInvalidation +) + +// Stats +var ( + // Deprecated: use upload.Ingest or upload.Remove; stats are applied internally. + ApplyUploadStatsAdd = uploadstats.ApplyUploadStatsAdd + // Deprecated: use upload.Ingest or upload.Remove; stats are applied internally. + ApplyUploadStatsRemove = uploadstats.ApplyUploadStatsRemove + RebuildUploadStats = uploadstats.RebuildUploadStats +) + +// Utilities +var ( + CompressImageToWebP = util.CompressImageToWebP + ValidateS3Key = util.ValidateS3Key +) + +// Task identifiers and metadata +const ( + StorageMigrationTask = uploadtask.StorageMigrationTask + SystemCleanupTask = uploadtask.SystemCleanupTask + WarmImageCacheTask = uploadtask.WarmImageCacheTask + RebuildUploadStatsTask = uploadtask.RebuildUploadStatsTask +) + +var ( + // StorageMigrationMeta describes the storage migration async task. + StorageMigrationMeta = uploadtask.StorageMigrationMeta + // SystemCleanupMeta describes the orphaned upload cleanup task. + SystemCleanupMeta = uploadtask.SystemCleanupMeta + // WarmImageCacheMeta describes the image compression cache warmup task. + WarmImageCacheMeta = uploadtask.WarmImageCacheMeta + // RebuildUploadStatsMeta describes the upload stats rebuild task. + RebuildUploadStatsMeta = uploadtask.RebuildUploadStatsMeta +) + +// MigrationHandler executes storage migration tasks. +type MigrationHandler = uploadtask.MigrationHandler + +// SystemCleanupHandler removes orphaned upload files. +type SystemCleanupHandler = uploadtask.SystemCleanupHandler + +// WarmImageCacheHandler pre-warms compressed image caches. +type WarmImageCacheHandler = uploadtask.WarmImageCacheHandler + +// RebuildUploadStatsHandler rebuilds upload stats from active records. +type RebuildUploadStatsHandler = uploadtask.RebuildUploadStatsHandler + +// WarmImageCachePayload is the payload for image cache warmup tasks. +type WarmImageCachePayload = uploadtask.WarmImageCachePayload diff --git a/backend/plugins/domain/upload/filesrv/file_server.go b/backend/plugins/domain/upload/filesrv/file_server.go new file mode 100644 index 00000000..ef1a541f --- /dev/null +++ b/backend/plugins/domain/upload/filesrv/file_server.go @@ -0,0 +1,361 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package filesrv serves uploaded files with access control and image compression. +package filesrv + +import ( + "Wavelet/core/contracts" + "Wavelet/pkg/logger" + "Wavelet/pkg/response" + "Wavelet/plugins/domain/upload/cache" + "Wavelet/plugins/domain/upload/models" + "Wavelet/plugins/domain/upload/shared" + "Wavelet/plugins/domain/upload/util" + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "sync" + + pkgcache "Wavelet/pkg/cache/disk" + "Wavelet/pkg/ginutil" + + "Wavelet/plugins/domain/upload/repository" + uploadstorage "Wavelet/plugins/domain/upload/storage" + + "github.com/gin-gonic/gin" + "golang.org/x/sync/singleflight" +) + +var ( + compressedImageFlight singleflight.Group + globalDiskCache *pkgcache.Cache + globalDiskCacheOnce sync.Once +) + +func getGlobalDiskCache() *pkgcache.Cache { + globalDiskCacheOnce.Do(func() { + globalDiskCache = pkgcache.New("uploads/diskcache") + }) + return globalDiskCache +} + +type compressedImageCacheResult struct { + bytes []byte + cached bool + err error +} + +type fileTypeCategory string + +const ( + fileTypeImage fileTypeCategory = "image" + fileTypeVideo fileTypeCategory = "video" + fileTypeAudio fileTypeCategory = "audio" + fileTypeOther fileTypeCategory = "other" +) + +// ServeFileByID 根据 ID 获取并提供已上传的文件 +// @Summary 获取已上传文件 +// @Description 根据文件 ID 获取并提供已上传的临时或正式文件,若配置了缓存则优先走本地缓存,否则从 S3 等后端存储读取并流式返回 +// @Tags upload +// @Produce octet-stream +// @Param id path string true "文件 ID" +// @Param quality query string false "图片质量 (low, medium, high, origin),默认为 origin" +// @Success 200 {file} file "成功获取文件内容" +// @Failure 400 {object} response.Any "文件 ID 格式错误" +// @Failure 401 {object} response.Any "未登录" +// @Failure 404 {object} response.Any "文件未找到" +// @Failure 500 {object} response.Any "服务内部错误" +// @Router /f/{id} [get] +func ServeFileByID(c *gin.Context) { + upload, err := GetUploadRecordByID(c) + if err != nil { + if AbortUploadRecordError(c, err) { + return + } + response.AbortInternal(c, shared.ErrInternalServerError) + return + } + + if err := CheckFileAccessPermission(c, upload); err != nil { + response.AbortUnauthorized(c, shared.ErrUnauthorized) + return + } + + ServeUpload(c, upload) +} + +// AbortUploadRecordError maps an upload record lookup failure to its HTTP +// response and reports whether it handled the error. A missing record is 404 +// and a malformed path ID is 400; anything else is left to the caller. +func AbortUploadRecordError(c *gin.Context, err error) bool { + var numErr *strconv.NumError + switch { + case repository.IsRecordNotFound(err): + response.AbortNotFound(c, shared.ErrFileRecordNotFound) + return true + case errors.As(err, &numErr): + response.AbortBadRequest(c, shared.ErrInvalidFileID) + return true + default: + return false + } +} + +// GetUploadRecordByID 从请求路径参数中解析文件 ID 并从数据库中检索处于 Pending 或 Used 状态的上传记录。 +func GetUploadRecordByID(c *gin.Context) (*models.Upload, error) { + c.Header("X-Content-Type-Options", "nosniff") + c.Header("Content-Security-Policy", "sandbox") + + idStr := c.Param("id") + uploadID, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + return nil, err + } + + upload, err := cache.GetUploadByID(c.Request.Context(), uploadID) + if err != nil { + return nil, err + } + + return &upload, nil +} + +func getFileTypeCategory(upload *models.Upload) fileTypeCategory { + mime := strings.ToLower(upload.MimeType) + ext := strings.ToLower(upload.Extension) + + if strings.HasPrefix(mime, "image/") || util.IsImageExtension(ext) { + return fileTypeImage + } + if strings.HasPrefix(mime, "video/") { + return fileTypeVideo + } + if strings.HasPrefix(mime, "audio/") { + return fileTypeAudio + } + return fileTypeOther +} + +// ServeUpload 将已存在的文件内容读取并流式响应给客户端。 +func ServeUpload(c *gin.Context, upload *models.Upload) { + setCacheHeaders(c, upload) + + category := getFileTypeCategory(upload) + quality := util.NormalizeImageQuality(c.Query("quality")) + + switch category { + case fileTypeImage: + if quality != shared.ImageQualityOrigin { + serveCompressedImage(c, upload, quality) + return + } + fallthrough + default: + serveOriginalWithConditionalCheck(c, upload) + } +} + +func setCacheHeaders(c *gin.Context, upload *models.Upload) { + if cache.IsFilePublic(c.Request.Context(), upload.Type) { + c.Header("Cache-Control", "public, max-age=31536000") + } else { + c.Header("Cache-Control", "private, no-cache") + } +} + +func serveOriginalWithConditionalCheck(c *gin.Context, upload *models.Upload) { + etag := fmt.Sprintf(`W/"%s"`, upload.Hash) + c.Header("ETag", etag) + + if c.GetHeader("If-None-Match") == etag { + c.AbortWithStatus(http.StatusNotModified) + return + } + + serveOriginal(c, upload) +} + +func serveCompressedImage(c *gin.Context, upload *models.Upload, quality string) { + etag := fmt.Sprintf(`W/"%s-%s"`, upload.Hash, quality) + c.Header("ETag", etag) + + if c.GetHeader("If-None-Match") == etag { + c.AbortWithStatus(http.StatusNotModified) + return + } + + webpBytes, hit, err := EnsureCompressedImageCache(c.Request.Context(), upload, quality) + if hit { + c.Header("X-Cache", "HIT") + } else { + c.Header("X-Cache", "MISS") + } + if err != nil { + if len(webpBytes) > 0 { + logger.WarnF(c.Request.Context(), "failed to cache compressed image: %v", err) + c.Data(http.StatusOK, "image/webp", webpBytes) + return + } + logger.ErrorF(c.Request.Context(), "failed to prepare compressed image cache: %v", err) + serveOriginal(c, upload) + return + } + + c.Data(http.StatusOK, "image/webp", webpBytes) +} + +// EnsureCompressedImageCache returns cached or freshly generated WebP bytes for an upload. +func EnsureCompressedImageCache( + ctx context.Context, + upload *models.Upload, + quality string, +) ([]byte, bool, error) { + cacheStore := getGlobalDiskCache() + cacheKey := ImageCompressionCacheKey(upload, quality) + webpBytes, err := cacheStore.Get(cacheKey) + if err == nil { + return webpBytes, true, nil + } + if !errors.Is(err, pkgcache.ErrCacheMiss) { + return nil, false, fmt.Errorf("read compressed image cache: %w", err) + } + + // The flight body serves every concurrent requester for this key, so it must + // not die when whichever caller happened to arrive first disconnects. + flightCtx := context.WithoutCancel(ctx) + result, err, _ := compressedImageFlight.Do(cacheKey, func() (any, error) { + return generateCompressedImageCache(flightCtx, upload, quality, cacheKey) + }) + if err != nil { + return nil, false, err + } + + res := result.(compressedImageCacheResult) + return res.bytes, res.cached, res.err +} + +func generateCompressedImageCache( + ctx context.Context, + upload *models.Upload, + quality string, + cacheKey string, +) (compressedImageCacheResult, error) { + cacheStore := getGlobalDiskCache() + + webpBytes, err := cacheStore.Get(cacheKey) + if err == nil { + return compressedImageCacheResult{bytes: webpBytes, cached: true}, nil + } + if !errors.Is(err, pkgcache.ErrCacheMiss) { + return compressedImageCacheResult{}, fmt.Errorf("read compressed image cache: %w", err) + } + + origBytes, err := getOriginalFileBytes(ctx, upload) + if err != nil { + return compressedImageCacheResult{}, fmt.Errorf("read original image: %w", err) + } + + webpBytes, err = util.CompressImageToWebP(bytes.NewReader(origBytes), quality) + if err != nil { + return compressedImageCacheResult{}, fmt.Errorf("compress image to WebP: %w", err) + } + + if err := cacheStore.Set(cacheKey, webpBytes, pkgcache.NoExpiration); err != nil { + return compressedImageCacheResult{ + bytes: webpBytes, + err: fmt.Errorf("write compressed image cache: %w", err), + }, nil + } + + return compressedImageCacheResult{bytes: webpBytes}, nil +} + +// ImageCompressionCacheKey returns the disk cache key for a compressed upload image. +func ImageCompressionCacheKey(upload *models.Upload, quality string) string { + return fmt.Sprintf( + "upload_webp_v1_%d_%d_%d_%s_%s", + upload.ID, + upload.UpdatedAt.UnixNano(), + upload.FileSize, + upload.Hash, + quality, + ) +} + +func serveOriginal(c *gin.Context, upload *models.Upload) { + obj, err := uploadstorage.OpenStoredObject(c.Request.Context(), upload) + if err != nil { + response.AbortNotFound(c, shared.ErrFileNotFound) + return + } + defer func() { _ = obj.Body.Close() }() + contentType := obj.ContentType + if upload.MimeType != "" { + contentType = upload.MimeType + } + c.DataFromReader(http.StatusOK, obj.ContentLength, contentType, obj.Body, nil) +} + +func getOriginalFileBytes(ctx context.Context, upload *models.Upload) ([]byte, error) { + obj, err := uploadstorage.OpenStoredObject(ctx, upload) + if err != nil { + return nil, err + } + defer func() { _ = obj.Body.Close() }() + return io.ReadAll(obj.Body) +} + +func checkPrivateFileOwner(c *gin.Context, ownerID uint64) error { + var currUserID uint64 + var isAdmin bool + if u, ok := ginutil.GetFromContext[*contracts.UserDTO](c, contracts.AuthUserObjKey); ok && u != nil { + currUserID = u.ID + isAdmin = u.IsAdmin + } else if authSvc := shared.GetAuthService(c); authSvc != nil { + u, err := authSvc.GetCurrentUser(c) + if err != nil { + return err + } + currUserID = u.ID + isAdmin = u.IsAdmin + } else { + return errors.New("unauthorized") + } + if isAdmin { + return nil + } + if currUserID != ownerID { + return errors.New("forbidden: cross-user access denied") + } + return nil +} + +// CheckFileAccessPermission 校验文件是否可以被当前请求访问 +func CheckFileAccessPermission(c *gin.Context, upload *models.Upload) error { + if upload.AccessMode == 0 { + return checkPrivateFileOwner(c, upload.UserID) + } + + if cache.IsFilePublic(c.Request.Context(), upload.Type) { + return nil + } + + if _, ok := ginutil.GetFromContext[*contracts.UserDTO](c, contracts.AuthUserObjKey); ok { + return nil + } + + authSvc := shared.GetAuthService(c) + if authSvc == nil { + return nil + } + + _, err := authSvc.GetCurrentUser(c) + return err +} diff --git a/backend/plugins/domain/upload/filesrv/file_server_test.go b/backend/plugins/domain/upload/filesrv/file_server_test.go new file mode 100644 index 00000000..ebd51f5c --- /dev/null +++ b/backend/plugins/domain/upload/filesrv/file_server_test.go @@ -0,0 +1,432 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package filesrv + +import ( + "Wavelet/core/contracts" + "Wavelet/pkg/response" + "Wavelet/pkg/testhelper" + "Wavelet/plugins/domain/upload/cache" + "Wavelet/plugins/domain/upload/models" + "Wavelet/plugins/domain/upload/shared" + "bytes" + "context" + "crypto/sha256" + "fmt" + "image" + "image/color" + "image/png" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/cookie" + "github.com/gin-gonic/gin" + + uploadutil "Wavelet/plugins/domain/upload/util" +) + +func init() { + testhelper.RegisterCleanup(cache.ResetUploadMetaCacheForTest) +} + +type localTestStorageService struct { + mu sync.RWMutex + root string +} + +func (s *localTestStorageService) Put(_ context.Context, key string, body io.Reader, _ int64, _ string) (contracts.StoragePutResult, error) { + s.mu.Lock() + defer s.mu.Unlock() + path := filepath.Join(s.root, key) + _ = os.MkdirAll(filepath.Dir(path), 0o755) + f, err := os.Create(path) + if err != nil { + return contracts.StoragePutResult{}, err + } + defer f.Close() + _, err = io.Copy(f, body) + return contracts.StoragePutResult{Key: key, Bucket: "local"}, err +} + +func (s *localTestStorageService) Get(ctx context.Context, key string) (*contracts.StorageObject, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + s.mu.RLock() + defer s.mu.RUnlock() + path := filepath.Join(s.root, key) + f, err := os.Open(path) + if err != nil { + return nil, err + } + info, _ := f.Stat() + return &contracts.StorageObject{ + Key: key, + Body: f, + ContentLength: info.Size(), + ContentType: "image/png", + }, nil +} + +func (s *localTestStorageService) Delete(_ context.Context, key string) error { + s.mu.Lock() + defer s.mu.Unlock() + return os.Remove(filepath.Join(s.root, key)) +} + +func (s *localTestStorageService) Ingest(_ context.Context, _ io.Reader, _ contracts.IngestOptions) (*contracts.IngestResult, error) { + return nil, nil +} + +func TestServeFileByIDAccessControl(t *testing.T) { + dbConn, cleanup := shared.SetupTestEnv(t) + defer cleanup() + cache.ResetAccessCaches() + + tempDir := t.TempDir() + storageSvc := &localTestStorageService{root: tempDir} + shared.SetStorageService(storageSvc) + + // Create a user in DB + user := contracts.UserDTO{ + ID: 12345, + Username: "file_test_user", + IsActive: true, + } + if err := dbConn.Table("w_users").Create(&user).Error; err != nil { + t.Fatalf("failed to create user: %v", err) + } + + // Create an access token for this user + tokenStr := "test-secret-token-123" + tokenHash := fmt.Sprintf("%x", sha256.Sum256([]byte(tokenStr))) + tokenRecord := map[string]any{ + "user_id": user.ID, + "name": "test_token", + "token_hash": tokenHash, + "masked_token": "test-***", + } + if err := dbConn.Table("w_access_tokens").Create(&tokenRecord).Error; err != nil { + t.Fatalf("failed to create token: %v", err) + } + + // Create two files: one in whitelist (avatar), one not in whitelist (attachment) + avatarFile := models.Upload{ + ID: 8001, + UserID: user.ID, + FileName: "avatar.png", + FilePath: "avatar.png", + FileSize: 5, + MimeType: "image/png", + Extension: "png", + Type: "avatar", + Status: models.UploadStatusUsed, + AccessMode: 1, + } + attachmentFile := models.Upload{ + ID: 8002, + UserID: user.ID, + FileName: "doc.pdf", + FilePath: "doc.pdf", + FileSize: 5, + MimeType: "application/pdf", + Extension: "pdf", + Type: "attachment", + Status: models.UploadStatusUsed, + AccessMode: 1, + } + + if err := os.WriteFile(filepath.Join(tempDir, "avatar.png"), []byte("image"), 0o644); err != nil { + t.Fatalf("failed to write avatar file: %v", err) + } + if err := os.WriteFile(filepath.Join(tempDir, "doc.pdf"), []byte("bytes"), 0o644); err != nil { + t.Fatalf("failed to write attachment file: %v", err) + } + + dbConn.Create(&avatarFile) + dbConn.Create(&attachmentFile) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(response.ErrorHandlerMiddleware()) + store := cookie.NewStore([]byte("secret")) + r.Use(sessions.Sessions("wavelet_session_id", store)) + r.GET("/f/:id", ServeFileByID) + + t.Run("public access allowed for whitelist type (avatar)", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/f/8001", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200 for public file, got %d", w.Code) + } + }) + + t.Run("public access rejected for non-whitelist type (attachment)", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/f/8002", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected status 401 for private file without auth, got %d", w.Code) + } + }) + + t.Run("authenticated access allowed for non-whitelist type (attachment)", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/f/8002", nil) + req.Header.Set("Authorization", "Bearer "+tokenStr) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200 for authenticated request, got %d", w.Code) + } + }) + + t.Run("non-existent file returns 404", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/f/99999", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected status 404 for non-existent file, got %d", w.Code) + } + }) + + t.Run("invalid id format returns 400", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/f/invalid_id", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400 for invalid id format, got %d", w.Code) + } + }) +} + +func TestServeFileByIDImageCompression(t *testing.T) { + dbConn, cleanup := shared.SetupTestEnv(t) + defer cleanup() + cache.ResetAccessCaches() + + tempDir := t.TempDir() + storageSvc := &localTestStorageService{root: tempDir} + shared.SetStorageService(storageSvc) + + // Create test user + user := contracts.UserDTO{ + ID: 54321, + Username: "compress_test_user", + IsActive: true, + } + dbConn.Table("w_users").Create(&user) + + // Create a small 1x1 test image + img := image.NewRGBA(image.Rect(0, 0, 1, 1)) + img.Set(0, 0, color.RGBA{R: 255, G: 0, B: 0, A: 255}) + var pngBuf bytes.Buffer + if err := png.Encode(&pngBuf, img); err != nil { + t.Fatalf("failed to encode test png: %v", err) + } + + filePath := filepath.Join(tempDir, "test_image.png") + if err := os.WriteFile(filePath, pngBuf.Bytes(), 0o644); err != nil { + t.Fatalf("failed to write test png: %v", err) + } + + // Save upload record to DB + uploadRecord := models.Upload{ + ID: 3001, + UserID: user.ID, + FileName: "test_image.png", + FilePath: "test_image.png", + FileSize: int64(pngBuf.Len()), + MimeType: "image/png", + Extension: "png", + Type: "avatar", // Whitelisted by default + Status: models.UploadStatusUsed, + AccessMode: 1, + } + dbConn.Create(&uploadRecord) + + // Setup Router + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/f/:id", ServeFileByID) + + t.Run("serve original file without compress parameter", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/f/3001", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + if w.Header().Get("Content-Type") != "image/png" { + t.Errorf("expected Content-Type image/png, got %s", w.Header().Get("Content-Type")) + } + if w.Header().Get("X-Cache") != "" { + t.Errorf("expected no X-Cache header for original file, got %s", w.Header().Get("X-Cache")) + } + if w.Header().Get("ETag") == "" { + t.Errorf("expected ETag header for original file") + } + }) + + t.Run("first request with quality=medium produces cache MISS and converts to WebP", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/f/3001?quality=medium", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + if w.Header().Get("Content-Type") != "image/webp" { + t.Errorf("expected Content-Type image/webp, got %s", w.Header().Get("Content-Type")) + } + if w.Header().Get("X-Cache") != "MISS" { + t.Errorf("expected X-Cache MISS on first compress request, got %s", w.Header().Get("X-Cache")) + } + if w.Header().Get("ETag") == "" { + t.Errorf("expected ETag header") + } + if len(w.Body.Bytes()) == 0 { + t.Errorf("expected non-empty body") + } + }) + + t.Run("second request with quality=medium produces cache HIT", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/f/3001?quality=medium", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + if w.Header().Get("Content-Type") != "image/webp" { + t.Errorf("expected Content-Type image/webp, got %s", w.Header().Get("Content-Type")) + } + if w.Header().Get("X-Cache") != "HIT" { + t.Errorf("expected X-Cache HIT on second compress request, got %s", w.Header().Get("X-Cache")) + } + }) + + t.Run("request with quality=origin behaves like original request", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/f/3001?quality=origin", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + if w.Header().Get("Content-Type") != "image/png" { + t.Errorf("expected Content-Type image/png, got %s", w.Header().Get("Content-Type")) + } + if w.Header().Get("X-Cache") != "" { + t.Errorf("expected no X-Cache header for origin quality, got %s", w.Header().Get("X-Cache")) + } + }) + + t.Run("conditional GET with matching If-None-Match returns 304", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/f/3001?quality=medium", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + etag := w.Header().Get("ETag") + if etag == "" { + t.Fatalf("expected ETag header from initial request") + } + + // Second request with If-None-Match + req2, _ := http.NewRequest("GET", "/f/3001?quality=medium", nil) + req2.Header.Set("If-None-Match", etag) + w2 := httptest.NewRecorder() + r.ServeHTTP(w2, req2) + + if w2.Code != http.StatusNotModified { + t.Fatalf("expected status 304 Not Modified, got %d", w2.Code) + } + if w2.Body.Len() != 0 { + t.Errorf("expected empty body on 304 response, got %d bytes", w2.Body.Len()) + } + }) +} + +// The singleflight body generates once on behalf of every concurrent requester +// sharing a cache key, so the caller that happens to arrive first must not be +// able to fail the others by disconnecting. +func TestEnsureCompressedImageCacheSurvivesCallerCancellation(t *testing.T) { + tempDir := t.TempDir() + shared.SetStorageService(&localTestStorageService{root: tempDir}) + + img := image.NewRGBA(image.Rect(0, 0, 1, 1)) + img.Set(0, 0, color.RGBA{R: 0, G: 255, B: 0, A: 255}) + var pngBuf bytes.Buffer + if err := png.Encode(&pngBuf, img); err != nil { + t.Fatalf("failed to encode test png: %v", err) + } + const filePath = "cancel_probe.png" + if err := os.WriteFile(filepath.Join(tempDir, filePath), pngBuf.Bytes(), 0o600); err != nil { + t.Fatalf("failed to write test png: %v", err) + } + + upload := &models.Upload{ + ID: 990001, + FilePath: filePath, + FileSize: int64(pngBuf.Len()), + MimeType: "image/png", + Extension: "png", + // Unique per run so the persistent disk cache can never serve this key. + Hash: fmt.Sprintf("cancel-probe-%d", time.Now().UnixNano()), + UpdatedAt: time.Now(), + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + webpBytes, cached, err := EnsureCompressedImageCache(ctx, upload, "medium") + if err != nil { + t.Fatalf("compressed image generation failed with a canceled caller context: %v", err) + } + if cached { + t.Errorf("expected a freshly generated image, got a cache hit") + } + if len(webpBytes) == 0 { + t.Errorf("expected non-empty webp bytes") + } +} + +func TestNormalizeImageQuality(t *testing.T) { + tests := []struct { + name string + quality string + want string + }{ + {name: "empty quality returns origin", quality: "", want: shared.ImageQualityOrigin}, + {name: "origin returns origin", quality: "origin", want: shared.ImageQualityOrigin}, + {name: "ORIGIN case-insensitive returns origin", quality: "ORIGIN", want: shared.ImageQualityOrigin}, + {name: "low returns low", quality: "low", want: shared.ImageQualityLow}, + {name: "LOW returns low", quality: "LOW", want: shared.ImageQualityLow}, + {name: "medium returns medium", quality: "medium", want: shared.ImageQualityMedium}, + {name: "high returns high", quality: "high", want: shared.ImageQualityHigh}, + {name: "unknown quality defaults to origin", quality: "ultra_hd", want: shared.ImageQualityOrigin}, + {name: "whitespace padded quality is trimmed", quality: " medium ", want: shared.ImageQualityMedium}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := uploadutil.NormalizeImageQuality(tt.quality); got != tt.want { + t.Errorf("NormalizeImageQuality(%q) = %q, want %q", tt.quality, got, tt.want) + } + }) + } +} diff --git a/backend/plugins/domain/upload/handler/file_management.go b/backend/plugins/domain/upload/handler/file_management.go new file mode 100644 index 00000000..69f9aecf --- /dev/null +++ b/backend/plugins/domain/upload/handler/file_management.go @@ -0,0 +1,300 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package handler + +import ( + "Wavelet/core/contracts" + "Wavelet/pkg/ginutil" + "Wavelet/pkg/response" + "Wavelet/plugins/domain/upload/ingest" + "Wavelet/plugins/domain/upload/models" + "Wavelet/plugins/domain/upload/repository" + "Wavelet/plugins/domain/upload/shared" + "errors" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + uploadstorage "Wavelet/plugins/domain/upload/storage" +) + +type listFilesRequest struct { + Page int `form:"page"` + PageSize int `form:"page_size"` + Keyword string `form:"keyword"` + Type string `form:"type"` + Extension string `form:"extension"` + UserID uint64 `form:"user_id"` +} + +type listFilesResponse struct { + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + Items []models.Upload `json:"items"` +} + +// ListFiles 获取系统上传的文件列表 +// @Summary 获取文件列表 +// @Description 分页获取系统上传的文件列表,支持文件名关键词、业务类型、扩展名、上传用户ID过滤 +// @Tags admin +// @Produce json +// @Param page query int false "页码(默认 1)" +// @Param page_size query int false "每页数量(默认 20,最大 100)" +// @Param keyword query string false "文件名关键词(模糊匹配)" +// @Param type query string false "业务分类过滤" +// @Param extension query string false "扩展名过滤" +// @Param user_id query int false "上传用户 ID 过滤" +// @Security SessionCookie +// @Success 200 {object} response.Any{data=listFilesResponse} "查询成功" +// @Failure 400 {object} response.Any "参数错误" +// @Router /api/v1/admin/uploads/files [get] +func ListFiles(c *gin.Context) { + ctx := c.Request.Context() + + var req listFilesRequest + if err := c.ShouldBindQuery(&req); err != nil { + response.AbortBadRequest(c, shared.ErrInvalidParams) + return + } + if req.Page <= 0 { + req.Page = 1 + } + if req.PageSize <= 0 || req.PageSize > 100 { + req.PageSize = 20 + } + + total, items, err := listUploadFiles(ctx, repository.UploadListFilter{ + UserID: req.UserID, + Keyword: req.Keyword, + Type: req.Type, + Extension: req.Extension, + Page: req.Page, + PageSize: req.PageSize, + }) + if err != nil { + response.AbortBadRequest(c, shared.ErrQueryFileListFailed) + return + } + + c.JSON(http.StatusOK, response.OK(listFilesResponse{ + Total: total, + Page: req.Page, + PageSize: req.PageSize, + Items: items, + })) +} + +// DeleteFile 软删除指定的文件记录 +// @Summary 删除文件 +// @Description 将指定 ID 的文件状态置为 deleted(软删除) +// @Tags admin +// @Produce json +// @Param id path string true "文件 ID" +// @Security SessionCookie +// @Success 200 {object} response.Any "删除成功" +// @Failure 404 {object} response.Any "文件不存在" +// @Router /api/v1/admin/uploads/files/{id} [delete] +func DeleteFile(c *gin.Context) { + ctx := c.Request.Context() + if uploadstorage.ReadOnly(ctx) { + response.AbortConflict(c, shared.ErrStorageReadOnly) + return + } + + uploadID, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.AbortBadRequest(c, shared.ErrInvalidFileID) + return + } + + if _, err := softDeleteUpload(ctx, uploadID); err != nil { + if isRecordNotFound(err) { + response.AbortNotFound(c, shared.ErrFileRecordNotFound) + return + } + response.AbortBadRequest(c, shared.ErrDeleteFileFailed) + return + } + c.JSON(http.StatusOK, response.OKNil()) +} + +// GetDistinctUploadTypes 获取所有已存在的文件业务分类列表 +// @Summary 获取业务分类列表 +// @Description 查询系统内所有不重复的上传业务分类标识(如 avatar, doc 等) +// @Tags admin +// @Produce json +// @Security SessionCookie +// @Success 200 {object} response.Any{data=[]string} "查询成功" +// @Router /api/v1/admin/uploads/types [get] +func GetDistinctUploadTypes(c *gin.Context) { + ctx := c.Request.Context() + types, err := listDistinctUploadTypes(ctx) + if err != nil { + response.AbortBadRequest(c, shared.ErrQueryTypeListFailed) + return + } + c.JSON(http.StatusOK, response.OK(types)) +} + +type listMyFilesRequest struct { + Page int `form:"page"` + PageSize int `form:"page_size"` + Keyword string `form:"keyword"` + Type string `form:"type"` + Extension string `form:"extension"` +} + +type listMyFilesResponse struct { + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + Items []models.Upload `json:"items"` +} + +// ListMyFiles 获取当前用户上传的文件列表 +// @Summary 获取我的文件列表 +// @Description 分页获取当前登录用户上传的文件,支持文件名关键词、业务类型、扩展名过滤 +// @Tags upload +// @Produce json +// @Param page query int false "页码(默认 1)" +// @Param page_size query int false "每页数量(默认 20,最大 100)" +// @Param keyword query string false "文件名关键词(模糊匹配)" +// @Param type query string false "业务分类过滤" +// @Param extension query string false "扩展名过滤" +// @Security SessionCookie +// @Success 200 {object} response.Any{data=listMyFilesResponse} "查询成功" +// @Failure 401 {object} response.Any "未登录" +// @Router /api/v1/upload/my [get] +func ListMyFiles(c *gin.Context) { + currUser, _ := ginutil.GetFromContext[*contracts.UserDTO](c, contracts.AuthUserObjKey) + ctx := c.Request.Context() + + var req listMyFilesRequest + if err := c.ShouldBindQuery(&req); err != nil { + response.AbortBadRequest(c, shared.ErrInvalidParams) + return + } + if req.Page <= 0 { + req.Page = 1 + } + if req.PageSize <= 0 || req.PageSize > 100 { + req.PageSize = 20 + } + + total, items, err := listMyUploadFiles(ctx, currUser.ID, repository.UploadListFilter{ + Keyword: req.Keyword, + Type: req.Type, + Extension: req.Extension, + Page: req.Page, + PageSize: req.PageSize, + }) + if err != nil { + response.AbortBadRequest(c, shared.ErrQueryFileListFailed) + return + } + + c.JSON(http.StatusOK, response.OK(listMyFilesResponse{ + Total: total, + Page: req.Page, + PageSize: req.PageSize, + Items: items, + })) +} + +// DeleteMyFile 软删除当前用户本人的文件 +// @Summary 删除我的文件 +// @Description 将当前用户本人的文件状态置为 deleted(软删除) +// @Tags upload +// @Produce json +// @Param id path string true "文件 ID" +// @Security SessionCookie +// @Success 200 {object} response.Any "删除成功" +// @Failure 403 {object} response.Any "无权操作" +// @Failure 404 {object} response.Any "文件不存在" +// @Router /api/v1/upload/{id} [delete] +func DeleteMyFile(c *gin.Context) { + currUser, _ := ginutil.GetFromContext[*contracts.UserDTO](c, contracts.AuthUserObjKey) + ctx := c.Request.Context() + if uploadstorage.ReadOnly(ctx) { + response.AbortConflict(c, shared.ErrStorageReadOnly) + return + } + + uploadID, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.AbortBadRequest(c, shared.ErrInvalidFileID) + return + } + + if _, err := softDeleteOwnedUpload(ctx, currUser.ID, uploadID); err != nil { + if isRecordNotFound(err) { + response.AbortNotFound(c, shared.ErrFileRecordNotFound) + return + } + if errors.Is(err, ingest.ErrForbidden) { + response.AbortForbidden(c, shared.ErrOperationForbidden) + return + } + response.AbortBadRequest(c, shared.ErrDeleteFileFailed) + return + } + c.JSON(http.StatusOK, response.OKNil()) +} + +type updateMyFileRequest struct { + FileName string `json:"file_name" binding:"max=255"` + AccessMode *int `json:"access_mode" binding:"omitempty,oneof=0 1"` +} + +// UpdateMyFile 更新当前用户本人的文件信息 +// @Summary 更新我的文件信息 +// @Description 更新当前用户本人的文件名或访问权限模式 (AccessMode) +// @Tags upload +// @Accept json +// @Produce json +// @Param id path string true "文件 ID" +// @Param request body updateMyFileRequest true "更新字段" +// @Security SessionCookie +// @Success 200 {object} response.Any{data=models.Upload} "更新成功" +// @Failure 403 {object} response.Any "无权操作" +// @Failure 404 {object} response.Any "文件不存在" +// @Router /api/v1/upload/{id} [put] +func UpdateMyFile(c *gin.Context) { + currUser, _ := ginutil.GetFromContext[*contracts.UserDTO](c, contracts.AuthUserObjKey) + ctx := c.Request.Context() + if uploadstorage.ReadOnly(ctx) { + response.AbortConflict(c, shared.ErrStorageReadOnly) + return + } + + uploadID, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + response.AbortBadRequest(c, shared.ErrInvalidFileID) + return + } + + var req updateMyFileRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortBadRequest(c, shared.ErrInvalidParams) + return + } + + updated, err := updateOwnedUpload(ctx, currUser.ID, uploadID, updateMyUploadInput(req)) + if err != nil { + if isRecordNotFound(err) { + response.AbortNotFound(c, shared.ErrFileRecordNotFound) + return + } + if errors.Is(err, ingest.ErrForbidden) { + response.AbortForbidden(c, shared.ErrOperationForbidden) + return + } + response.AbortBadRequest(c, shared.ErrUpdateFileFailed) + return + } + + c.JSON(http.StatusOK, response.OK(updated)) +} diff --git a/backend/plugins/domain/upload/handler/file_management_test.go b/backend/plugins/domain/upload/handler/file_management_test.go new file mode 100644 index 00000000..1cf77e2b --- /dev/null +++ b/backend/plugins/domain/upload/handler/file_management_test.go @@ -0,0 +1,65 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package handler + +import ( + "Wavelet/core/contracts" + "Wavelet/plugins/domain/upload/models" + "Wavelet/plugins/domain/upload/shared" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestGetDistinctUploadTypes(t *testing.T) { + dbConn, cleanup := shared.SetupTestEnv(t) + defer cleanup() + + user := contracts.UserDTO{ID: 2222, Username: "test_user_2"} + dbConn.Table("w_users").Create(&user) + + customUpload := models.Upload{ + ID: 9001, + UserID: user.ID, + FileName: "custom.txt", + FilePath: "uploads/custom.txt", + FileSize: 10, + MimeType: "text/plain", + Extension: "txt", + Type: "custom_type_xyz", + Status: models.UploadStatusUsed, + } + dbConn.Create(&customUpload) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/api/v1/admin/uploads/types", GetDistinctUploadTypes) + + req, _ := http.NewRequest("GET", "/api/v1/admin/uploads/types", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp struct { + ErrorMsg string `json:"error_msg"` + Data []string `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse JSON: %v", err) + } + + if resp.ErrorMsg != "" { + t.Fatalf("unexpected error: %s", resp.ErrorMsg) + } + + if len(resp.Data) != 1 || resp.Data[0] != "custom_type_xyz" { + t.Fatalf("expected ['custom_type_xyz'], got %v", resp.Data) + } +} diff --git a/backend/plugins/domain/upload/handler/logics.go b/backend/plugins/domain/upload/handler/logics.go new file mode 100644 index 00000000..835e4c8f --- /dev/null +++ b/backend/plugins/domain/upload/handler/logics.go @@ -0,0 +1,83 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package handler + +import ( + "Wavelet/plugins/domain/upload/ingest" + "Wavelet/plugins/domain/upload/models" + "Wavelet/plugins/domain/upload/repository" + "context" + "sort" +) + +func listUploadFiles(ctx context.Context, filter repository.UploadListFilter) (int64, []models.Upload, error) { + return repository.ListUploads(ctx, filter) +} + +func listMyUploadFiles(ctx context.Context, userID uint64, filter repository.UploadListFilter) (int64, []models.Upload, error) { + filter.UserID = userID + return repository.ListUploads(ctx, filter) +} + +func softDeleteUpload(ctx context.Context, uploadID uint64) (models.Upload, error) { + return ingest.Remove(ctx, uploadID) +} + +func softDeleteOwnedUpload(ctx context.Context, userID, uploadID uint64) (models.Upload, error) { + return ingest.RemoveOwned(ctx, userID, uploadID) +} + +func listDistinctUploadTypes(ctx context.Context) ([]string, error) { + types, err := repository.ListDistinctUploadTypes(ctx) + if err != nil { + return nil, err + } + sort.Strings(types) + return types, nil +} + +type updateMyUploadInput struct { + FileName string + AccessMode *int +} + +func updateOwnedUpload(ctx context.Context, userID, uploadID uint64, input updateMyUploadInput) (models.Upload, error) { + u, err := repository.GetActiveUploadByID(ctx, uploadID) + if err != nil { + return models.Upload{}, err + } + if u.UserID != userID { + return models.Upload{}, ingest.ErrForbidden + } + + updates := make(map[string]any) + if input.FileName != "" { + updates["file_name"] = input.FileName + } + if input.AccessMode != nil { + updates["access_mode"] = *input.AccessMode + } + if err := repository.UpdateUpload(ctx, &u, updates); err != nil { + return models.Upload{}, err + } + if name, ok := updates["file_name"].(string); ok { + u.FileName = name + } + if mode, ok := updates["access_mode"].(int); ok { + u.AccessMode = mode + } + return u, nil +} + +func listUploadsForBatchDownload(ctx context.Context, ids []uint64) ([]models.Upload, error) { + return repository.ListUploadsByIDs(ctx, ids) +} + +func loadUploadStats(ctx context.Context) ([]models.UploadStat, error) { + return repository.ListUploadStats(ctx) +} + +func isRecordNotFound(err error) bool { + return repository.IsRecordNotFound(err) +} diff --git a/backend/plugins/domain/upload/handler/routers.go b/backend/plugins/domain/upload/handler/routers.go new file mode 100644 index 00000000..a02f5141 --- /dev/null +++ b/backend/plugins/domain/upload/handler/routers.go @@ -0,0 +1,326 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package handler provides upload HTTP API handlers. +package handler + +import ( + "Wavelet/core/contracts" + "Wavelet/pkg/logger" + "Wavelet/pkg/response" + "Wavelet/plugins/domain/upload/filesrv" + "Wavelet/plugins/domain/upload/ingest" + "Wavelet/plugins/domain/upload/models" + "Wavelet/plugins/domain/upload/shared" + "Wavelet/plugins/domain/upload/util" + "archive/zip" + "bufio" + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "path/filepath" + "strconv" + "strings" + + "Wavelet/pkg/ginutil" + + "github.com/gin-gonic/gin" + + uploadstorage "Wavelet/plugins/domain/upload/storage" +) + +type batchDownloadRequest struct { + IDs []string `json:"ids" binding:"required,min=1"` +} + +// UploadFile 通用上传文件接口 +// @Summary 上传文件 +// @Description 支持各种类型的通用文件上传,支持自动文件类型检测、哈希计算与“秒传”去重 +// @Tags upload +// @Accept multipart/form-data +// @Produce json +// @Param file formData file true "要上传的文件" +// @Param type formData string false "业务分类 (例如: avatar, attachment, doc,默认为 generic)" +// @Param metadata formData string false "额外的 JSON 格式元数据" +// @Security SessionCookie +// @Success 200 {object} response.Any{data=models.Upload} "上传成功" +// @Failure 400 {object} response.Any "请求参数错误或文件受限" +// @Failure 401 {object} response.Any "未登录" +// @Failure 500 {object} response.Any "内部错误" +// @Router /api/v1/upload [post] +func UploadFile(c *gin.Context) { + c.Header("X-Content-Type-Options", "nosniff") + c.Header("Content-Security-Policy", "sandbox") + + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, shared.MaxUploadSize) + + currUser, _ := ginutil.GetFromContext[*contracts.UserDTO](c, contracts.AuthUserObjKey) + ctx := c.Request.Context() + + header, err := c.FormFile("file") + if err != nil { + response.AbortBadRequest(c, shared.ErrNoFileSelected) + return + } + + file, err := header.Open() + if err != nil { + response.AbortBadRequest(c, shared.ErrOpenFileFailed) + return + } + defer func() { _ = file.Close() }() + + if header.Size > shared.MaxUploadSize { + response.AbortBadRequest(c, shared.ErrGenericFileTooLarge) + return + } + + origName := header.Filename + ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(origName), ".")) + if ext == "" { + ext = "bin" + } + + hashWriter := sha256.New() + var buf bytes.Buffer + size, err := io.Copy(&buf, io.TeeReader(file, hashWriter)) + if err != nil { + response.AbortBadRequest(c, shared.ErrProcessFileFailed) + return + } + + fileHash := hex.EncodeToString(hashWriter.Sum(nil)) + mimeType := detectMimeType(&buf, header, size) + + if util.IsImageExtension(ext) && !strings.HasPrefix(mimeType, "image/") { + response.AbortBadRequest(c, shared.ErrFileContentExtensionMismatch) + return + } + + uploadType := c.DefaultPostForm("type", "generic") + + accessMode, errMsg := resolveUploadAccessMode(c, uploadType) + if errMsg != "" { + response.AbortBadRequest(c, errMsg) + return + } + + meta, errMsg := parseUploadMetadata(c, mimeType) + if errMsg != "" { + response.AbortBadRequest(c, errMsg) + return + } + + result, err := ingest.Ingest(ctx, ingest.Request{ + UserID: currUser.ID, + Reader: bytes.NewReader(buf.Bytes()), + Size: size, + FileName: origName, + MimeType: mimeType, + Extension: ext, + Hash: fileHash, + Type: uploadType, + AccessMode: &accessMode, + Metadata: meta, + Policy: ingest.PolicyDedupNewRecord, + }) + if err != nil { + if errors.Is(err, ingest.ErrStorageReadOnly) { + response.AbortConflict(c, shared.ErrStorageReadOnly) + return + } + if err.Error() == shared.ErrUnsupportedFormat { + response.AbortBadRequest(c, shared.ErrUnsupportedFormat) + return + } + if err.Error() == shared.ErrSaveFileFailed { + response.AbortBadRequest(c, shared.ErrSaveFileFailed) + return + } + response.AbortBadRequest(c, shared.ErrSaveUploadRecordFailed) + return + } + + c.JSON(http.StatusOK, response.OK(result.Upload)) +} + +// DownloadFile 通用单文件下载接口 +// @Summary 下载单文件 +// @Description 根据文件 ID 获取文件,以附件形式 (Attachment) 强制开启客户端浏览器下载 +// @Tags admin +// @Produce octet-stream +// @Param id path string true "文件 ID" +// @Param quality query string false "图片质量 (low, medium, high, origin),默认为 origin" +// @Security SessionCookie +// @Success 200 {file} file "成功下载文件" +// @Failure 400 {object} response.Any "参数错误" +// @Failure 404 {object} response.Any "文件不存在" +// @Failure 500 {object} response.Any "服务内部错误" +// @Router /api/v1/admin/uploads/download/{id} [get] +func DownloadFile(c *gin.Context) { + upload, err := filesrv.GetUploadRecordByID(c) + if err != nil { + if filesrv.AbortUploadRecordError(c, err) { + return + } + response.AbortBadRequest(c, shared.ErrQueryUploadRecordFailed) + return + } + + if err := filesrv.CheckFileAccessPermission(c, upload); err != nil { + response.AbortUnauthorized(c, shared.ErrUnauthorized) + return + } + + fileName := upload.FileName + quality := util.NormalizeImageQuality(c.Query("quality")) + isImage := strings.HasPrefix(strings.ToLower(upload.MimeType), "image/") || util.IsImageExtension(strings.ToLower(upload.Extension)) + + if quality != shared.ImageQualityOrigin && isImage { + ext := filepath.Ext(fileName) + if ext != "" { + fileName = strings.TrimSuffix(fileName, ext) + ".webp" + } else { + fileName += ".webp" + } + } + + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename*=UTF-8''%s", url.PathEscape(fileName))) + filesrv.ServeUpload(c, upload) +} + +// BatchDownloadFiles 批量打包 ZIP 下载接口 +// @Summary 批量打包下载 +// @Description 传入多个文件 ID,后台实时将其打包压缩为 ZIP 流并输出,自动处理文件名重复冲突 +// @Tags admin +// @Accept json +// @Produce octet-stream +// @Param request body handler.batchDownloadRequest true "包含文件 ID 数组 of string 的请求体" +// @Security SessionCookie +// @Success 200 {file} file "成功下载打包后的 ZIP" +// @Failure 400 {object} response.Any "参数错误" +// @Failure 500 {object} response.Any "打包失败" +// @Router /api/v1/admin/uploads/download/batch [post] +func BatchDownloadFiles(c *gin.Context) { + ctx := c.Request.Context() + + var req batchDownloadRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortBadRequest(c, shared.ErrInvalidBatchDownloadRequest) + return + } + + var ids []uint64 + for _, idStr := range req.IDs { + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + response.AbortBadRequest(c, fmt.Sprintf(shared.ErrInvalidIDValueFormat, idStr)) + return + } + ids = append(ids, id) + } + + uploads, err := listUploadsForBatchDownload(ctx, ids) + if err != nil { + response.AbortBadRequest(c, shared.ErrRetrieveUploadRecordsFailed) + return + } + + if len(uploads) == 0 { + response.AbortBadRequest(c, shared.ErrNoValidFilesForArchive) + return + } + + c.Header("Content-Type", "application/zip") + c.Header("Content-Disposition", "attachment; filename=\"batch_download.zip\"") + + bufferedWriter := bufio.NewWriter(c.Writer) + zipWriter := zip.NewWriter(bufferedWriter) + defer func() { + _ = zipWriter.Close() + _ = bufferedWriter.Flush() + }() + + usedNames := make(map[string]int) + + for _, upload := range uploads { + if err := filesrv.CheckFileAccessPermission(c, &upload); err != nil { + logger.WarnF(ctx, "Batch download: skip file %d due to permission denied: %v", upload.ID, err) + continue + } + + fileName := upload.FileName + if count, exists := usedNames[fileName]; exists { + usedNames[fileName] = count + 1 + ext := filepath.Ext(fileName) + base := strings.TrimSuffix(fileName, ext) + fileName = fmt.Sprintf("%s_%d%s", base, count, ext) + } else { + usedNames[fileName] = 1 + } + + zipFileEntry, err := zipWriter.Create(fileName) + if err != nil { + logger.ErrorF(ctx, "ZIP 添加条目失败 [%s]: %v", fileName, err) + continue + } + + obj, err := uploadstorage.OpenStoredObject(ctx, &upload) + if err != nil { + logger.ErrorF(ctx, "打包时读取文件失败: %v", err) + continue + } + rc := obj.Body + + _, err = io.Copy(zipFileEntry, rc) + _ = rc.Close() + if err != nil { + logger.ErrorF(ctx, "写入 ZIP 流失败: %v", err) + } + } +} + +func resolveUploadAccessMode(c *gin.Context, uploadType string) (int, string) { + accessModeStr := c.PostForm("access_mode") + if accessModeStr == "" { + if uploadType == shared.DefaultPublicUploadType { + return 1, "" + } + return 0, "" + } + + accessMode, err := strconv.Atoi(accessModeStr) + if err != nil || (accessMode != 0 && accessMode != 1) { + return 0, shared.ErrInvalidAccessModeParam + } + return accessMode, "" +} + +func parseUploadMetadata(c *gin.Context, mimeType string) (models.UploadMetadata, string) { + var meta models.UploadMetadata + metadataStr := c.DefaultPostForm("metadata", "") + if metadataStr != "" { + if err := json.Unmarshal([]byte(metadataStr), &meta); err != nil { + return meta, shared.ErrInvalidMetadataJSON + } + } + meta.OriginalMime = mimeType + meta.UserAgent = c.Request.UserAgent() + meta.ClientIP = c.ClientIP() + return meta, "" +} + +func detectMimeType(buf *bytes.Buffer, header *multipart.FileHeader, size int64) string { + mimeType := http.DetectContentType(buf.Bytes()[:min(shared.DetectContentBytes, int(size))]) + if mimeType == "application/octet-stream" && header.Header.Get("Content-Type") != "" { + mimeType = header.Header.Get("Content-Type") + } + return mimeType +} diff --git a/backend/plugins/domain/upload/handler/routers_test.go b/backend/plugins/domain/upload/handler/routers_test.go new file mode 100644 index 00000000..afdcab30 --- /dev/null +++ b/backend/plugins/domain/upload/handler/routers_test.go @@ -0,0 +1,983 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package handler + +import ( + "Wavelet/core/contracts" + "Wavelet/pkg/ginutil" + "Wavelet/pkg/response" + "Wavelet/plugins/domain/upload/models" + "Wavelet/plugins/domain/upload/shared" + "archive/zip" + "bytes" + "context" + "encoding/json" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/gin-gonic/gin" + + "Wavelet/pkg/idgen" + uploadstats "Wavelet/plugins/domain/upload/stats" +) + +type testResponse struct { + ErrorMsg string `json:"error_msg"` + Data json.RawMessage `json:"data"` +} + +func setupTestRouter(authUser *contracts.UserDTO) *gin.Engine { + _ = idgen.Init(1) + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(response.ErrorHandlerMiddleware()) + + authMiddleware := func(c *gin.Context) { + if authUser != nil { + ginutil.SetToContext[*contracts.UserDTO](c, contracts.AuthUserObjKey, authUser) + } + c.Next() + } + + uploadGroup := r.Group("/api/v1/upload") + uploadGroup.Use(authMiddleware) + { + uploadGroup.POST("", UploadFile) + uploadGroup.GET("/my", ListMyFiles) + uploadGroup.DELETE("/:id", DeleteMyFile) + uploadGroup.PUT("/:id", UpdateMyFile) + uploadGroup.GET("/download/:id", DownloadFile) + uploadGroup.POST("/download/batch", BatchDownloadFiles) + } + + adminGroup := r.Group("/api/v1/admin/uploads") + adminGroup.Use(authMiddleware) + { + adminGroup.GET("", ListFiles) + adminGroup.GET("/stats", GetFileStats) + adminGroup.DELETE("/:id", DeleteFile) + adminGroup.GET("/download/:id", DownloadFile) + adminGroup.POST("/download/batch", BatchDownloadFiles) + } + + return r +} + +func createMultipartRequest(t *testing.T, fieldName, fileName string, fileContent []byte, extraFields map[string]string) (string, *bytes.Buffer) { + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + part, err := writer.CreateFormFile(fieldName, fileName) + if err != nil { + t.Fatalf("failed to create form file: %v", err) + } + + _, err = part.Write(fileContent) + if err != nil { + t.Fatalf("failed to write file content: %v", err) + } + + for k, v := range extraFields { + err = writer.WriteField(k, v) + } + + err = writer.Close() + if err != nil { + t.Fatalf("failed to close multipart writer: %v", err) + } + + return writer.FormDataContentType(), body +} + +type handlerTestStorage struct { + mu sync.RWMutex + mockFiles map[string][]byte + putCount *int +} + +func (s *handlerTestStorage) Put(_ context.Context, key string, body io.Reader, _ int64, _ string) (contracts.StoragePutResult, error) { + s.mu.Lock() + defer s.mu.Unlock() + data, _ := io.ReadAll(body) + s.mockFiles[key] = data + if s.putCount != nil { + *s.putCount++ + } + if strings.HasPrefix(key, "uploads/") { + _ = os.MkdirAll(filepath.Dir(key), 0o755) + _ = os.WriteFile(key, data, 0o644) + } + return contracts.StoragePutResult{Key: key, Bucket: "test-bucket"}, nil +} + +func (s *handlerTestStorage) Get(_ context.Context, key string) (*contracts.StorageObject, error) { + s.mu.RLock() + defer s.mu.RUnlock() + data, ok := s.mockFiles[key] + if ok { + return &contracts.StorageObject{ + Key: key, + Body: io.NopCloser(bytes.NewReader(data)), + ContentLength: int64(len(data)), + ContentType: "application/octet-stream", + }, nil + } + if f, err := os.Open(key); err == nil { + info, _ := f.Stat() + return &contracts.StorageObject{ + Key: key, + Body: f, + ContentLength: info.Size(), + ContentType: "application/octet-stream", + }, nil + } + return nil, os.ErrNotExist +} + +func (s *handlerTestStorage) Delete(_ context.Context, key string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.mockFiles, key) + return nil +} + +func (s *handlerTestStorage) Ingest(_ context.Context, _ io.Reader, _ contracts.IngestOptions) (*contracts.IngestResult, error) { + return nil, nil +} + +func TestUploadFile(t *testing.T) { + dbConn, cleanup := shared.SetupTestEnv(t) + defer cleanup() + defer func() { _ = os.RemoveAll("uploads") }() // Clean up local files created during tests + + authUser := &contracts.UserDTO{ID: 1001, Username: "test_user"} + router := setupTestRouter(authUser) + + var putCount int + mockStorage := &handlerTestStorage{ + mockFiles: make(map[string][]byte), + putCount: &putCount, + } + shared.SetStorageService(mockStorage) + + t.Run("upload allowed image file successfully", func(t *testing.T) { + putCount = 0 + imgContent := []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89") // Valid PNG header + contentType, body := createMultipartRequest(t, "file", "test.png", imgContent, map[string]string{ + "type": "avatar", + "metadata": `{"extra":{"source":"test_runner"}}`, + }) + + req, _ := http.NewRequest("POST", "/api/v1/upload", body) + req.Header.Set("Content-Type", contentType) + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. Body: %s", w.Code, w.Body.String()) + } + + var resp testResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if resp.ErrorMsg != "" { + t.Fatalf("expected success response, got failure: %s", resp.ErrorMsg) + } + + // Verify database record + var uploadRecord models.Upload + if err := json.Unmarshal(resp.Data, &uploadRecord); err != nil { + t.Fatalf("failed to unmarshal upload record: %v", err) + } + + var dbRecord models.Upload + if err := dbConn.First(&dbRecord, uploadRecord.ID).Error; err != nil { + t.Fatalf("failed to retrieve database record: %v", err) + } + + if dbRecord.FileName != "test.png" || dbRecord.Extension != "png" { + t.Errorf("incorrect filename or extension: %s, %s", dbRecord.FileName, dbRecord.Extension) + } + + if dbRecord.MimeType != "image/png" { + t.Errorf("incorrect mime type detected: %s", dbRecord.MimeType) + } + + if dbRecord.Metadata.Extra["source"] != "test_runner" { + t.Errorf("expected extra meta 'source' to be 'test_runner', got %v", dbRecord.Metadata.Extra) + } + + if putCount != 1 { + t.Errorf("expected 1 storage Put operation, got %d", putCount) + } + }) + + t.Run("upload blocked extension file", func(t *testing.T) { + // System config allowed: jpg,png,webp. Uploading docx should be blocked. + contentType, body := createMultipartRequest(t, "file", "contract.docx", []byte("fake docx content"), nil) + req, _ := http.NewRequest("POST", "/api/v1/upload", body) + req.Header.Set("Content-Type", contentType) + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d. Body: %s", w.Code, w.Body.String()) + } + + var resp testResponse + _ = json.Unmarshal(w.Body.Bytes(), &resp) + if resp.ErrorMsg == "" || !strings.Contains(resp.ErrorMsg, shared.ErrUnsupportedFormat) { + t.Errorf("expected unsupported format error, got: %v", resp) + } + }) + + t.Run("instant upload deduplication (秒传)", func(t *testing.T) { + putCount = 0 + imgContent := []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01") + + // Upload first time + contentType1, body1 := createMultipartRequest(t, "file", "avatar1.png", imgContent, map[string]string{"type": "avatar"}) + req1, _ := http.NewRequest("POST", "/api/v1/upload", body1) + req1.Header.Set("Content-Type", contentType1) + w1 := httptest.NewRecorder() + router.ServeHTTP(w1, req1) + + if w1.Code != http.StatusOK { + t.Fatalf("first upload failed: %s", w1.Body.String()) + } + if putCount != 1 { + t.Errorf("expected 1 put count on first upload, got %d", putCount) + } + + // Upload same file second time (different filename, same content) + contentType2, body2 := createMultipartRequest(t, "file", "avatar2.png", imgContent, map[string]string{"type": "avatar"}) + req2, _ := http.NewRequest("POST", "/api/v1/upload", body2) + req2.Header.Set("Content-Type", contentType2) + w2 := httptest.NewRecorder() + router.ServeHTTP(w2, req2) + + if w2.Code != http.StatusOK { + t.Fatalf("second upload failed: %s", w2.Body.String()) + } + + var resp2 testResponse + _ = json.Unmarshal(w2.Body.Bytes(), &resp2) + + if resp2.ErrorMsg != "" { + t.Fatalf("second upload was unsuccessful: %s", resp2.ErrorMsg) + } + + var uploadRecord2 models.Upload + if err := json.Unmarshal(resp2.Data, &uploadRecord2); err != nil { + t.Fatalf("failed to unmarshal second upload record: %v", err) + } + + // Check if it triggered another storage put + if putCount != 1 { + t.Errorf("PutObject was triggered again! Expected deduplication (putCount=1), got putCount=%d", putCount) + } + + // Check if database contains both records sharing the same FilePath + var records []models.Upload + dbConn.Where("hash = ?", uploadRecord2.Hash).Find(&records) + if len(records) != 2 { + t.Errorf("expected 2 database records sharing the same hash, got %d", len(records)) + } + if records[0].FilePath != records[1].FilePath { + t.Errorf("file paths are different: %s vs %s", records[0].FilePath, records[1].FilePath) + } + if records[0].ID == records[1].ID { + t.Error("database record IDs should be unique") + } + + t.Logf("Instant upload success. Record 1: %d, Record 2: %d", records[0].ID, records[1].ID) + }) + + t.Run("upload in local storage fallback mode", func(t *testing.T) { + // Seed allowed extensions configuration to allow txt files + dbConn.Table("w_system_configs").Where("key = ?", "upload_allowed_extensions").Update("value", "jpg,png,webp,txt") + + contentType, body := createMultipartRequest(t, "file", "doc.txt", []byte("hello world generic document file"), map[string]string{ + "type": "document", + }) + req, _ := http.NewRequest("POST", "/api/v1/upload", body) + req.Header.Set("Content-Type", contentType) + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. Body: %s", w.Code, w.Body.String()) + } + + var resp testResponse + _ = json.Unmarshal(w.Body.Bytes(), &resp) + + if resp.ErrorMsg != "" { + t.Fatalf("local upload failed: %s", resp.ErrorMsg) + } + + var localRecord models.Upload + if err := json.Unmarshal(resp.Data, &localRecord); err != nil { + t.Fatalf("failed to unmarshal local upload record: %v", err) + } + + // Confirm file was actually written to local disk + fileContent, err := os.ReadFile(localRecord.FilePath) + if err != nil { + t.Fatalf("failed to read local file: %v", err) + } + + if string(fileContent) != "hello world generic document file" { + t.Errorf("unexpected local file contents: %s", string(fileContent)) + } + }) +} + +func TestDownloadFile(t *testing.T) { + dbConn, cleanup := shared.SetupTestEnv(t) + defer cleanup() + + authUser := &contracts.UserDTO{ID: 1001, Username: "test_user"} + router := setupTestRouter(authUser) + + // Seed upload records in DB + localUpload := models.Upload{ + ID: 2001, + UserID: 1001, + FileName: "中文文件名.txt", + FilePath: "uploads/test_download.txt", + FileSize: 12, + MimeType: "text/plain", + Extension: "txt", + Status: models.UploadStatusUsed, + } + + // Seed file content into the in-memory mock storage service. + storageSvc := shared.NewMockStorageService() + storageSvc.PutRaw(localUpload.FilePath, []byte("hello download")) + shared.SetStorageService(storageSvc) + + dbConn.Create(&localUpload) + + t.Run("download file successfully", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/api/v1/admin/uploads/download/2001", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. Body: %s", w.Code, w.Body.String()) + } + + if w.Body.String() != "hello download" { + t.Errorf("expected body 'hello download', got '%s'", w.Body.String()) + } + + // Verify Content-Disposition header (supports UTF-8 escaping) + contentDisp := w.Header().Get("Content-Disposition") + expectedDisp := "attachment; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%96%87%E4%BB%B6%E5%90%8D.txt" + if contentDisp != expectedDisp { + t.Errorf("expected Content-Disposition header %q, got %q", expectedDisp, contentDisp) + } + + if !strings.HasPrefix(w.Header().Get("Content-Type"), "text/plain") { + t.Errorf("expected Content-Type starting with text/plain, got %s", w.Header().Get("Content-Type")) + } + }) + + t.Run("download non-existent file", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/api/v1/admin/uploads/download/9999", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected status 404, got %d", w.Code) + } + }) +} + +func TestListFiles(t *testing.T) { + dbConn, cleanup := shared.SetupTestEnv(t) + defer cleanup() + + authUser := &contracts.UserDTO{ID: 1001, Username: "test_user"} + router := setupTestRouter(authUser) + + uploads := []models.Upload{ + { + ID: 2101, + UserID: authUser.ID, + FileName: "first-report.txt", + FilePath: "uploads/first-report.txt", + FileSize: 10, + MimeType: "text/plain", + Extension: "txt", + Status: models.UploadStatusUsed, + }, + { + ID: 2102, + UserID: authUser.ID, + FileName: "Second-Photo.PNG", + FilePath: "uploads/second-photo.png", + FileSize: 20, + MimeType: "image/png", + Extension: "png", + Status: models.UploadStatusUsed, + }, + { + ID: 2103, + UserID: authUser.ID, + FileName: "third-notes.md", + FilePath: "uploads/third-notes.md", + FileSize: 30, + MimeType: "text/markdown", + Extension: "md", + Status: models.UploadStatusUsed, + }, + { + ID: 2104, + UserID: 2002, + FileName: "other-user.txt", + FilePath: "uploads/other-user.txt", + FileSize: 40, + MimeType: "text/plain", + Extension: "txt", + Status: models.UploadStatusUsed, + }, + } + for i := range uploads { + if err := dbConn.Create(&uploads[i]).Error; err != nil { + t.Fatalf("failed to create upload %d: %v", uploads[i].ID, err) + } + } + + t.Run("returns requested page", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/api/v1/admin/uploads?page=2&page_size=2", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + var resp testResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if resp.ErrorMsg != "" { + t.Fatalf("ListFiles() error = %q, want empty", resp.ErrorMsg) + } + + var got listFilesResponse + if err := json.Unmarshal(resp.Data, &got); err != nil { + t.Fatalf("failed to parse list response: %v", err) + } + if got.Page != 2 { + t.Errorf("ListFiles(page=2).Page = %d, want 2", got.Page) + } + if got.PageSize != 2 { + t.Errorf("ListFiles(page_size=2).PageSize = %d, want 2", got.PageSize) + } + if got.Total != 4 { + t.Errorf("ListFiles().Total = %d, want 4", got.Total) + } + if len(got.Items) != 2 { + t.Fatalf("ListFiles(page=2, page_size=2) returned %d items, want 2", len(got.Items)) + } + }) + + t.Run("filters filename case insensitively", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/api/v1/admin/uploads?keyword=photo", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + var resp testResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if resp.ErrorMsg != "" { + t.Fatalf("ListFiles(keyword=photo) error = %q, want empty", resp.ErrorMsg) + } + + var got listFilesResponse + if err := json.Unmarshal(resp.Data, &got); err != nil { + t.Fatalf("failed to parse list response: %v", err) + } + if got.Total != 1 { + t.Errorf("ListFiles(keyword=photo).Total = %d, want 1", got.Total) + } + if len(got.Items) != 1 { + t.Fatalf("ListFiles(keyword=photo) returned %d items, want 1", len(got.Items)) + } + if got.Items[0].FileName != "Second-Photo.PNG" { + t.Errorf("ListFiles(keyword=photo).Items[0].FileName = %q, want %q", got.Items[0].FileName, "Second-Photo.PNG") + } + }) + + t.Run("filters by user_id", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/api/v1/admin/uploads?user_id=1001", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + var resp testResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if resp.ErrorMsg != "" { + t.Fatalf("ListFiles(user_id=1001) error = %q, want empty", resp.ErrorMsg) + } + + var got listFilesResponse + if err := json.Unmarshal(resp.Data, &got); err != nil { + t.Fatalf("failed to parse list response: %v", err) + } + if got.Total != 3 { + t.Errorf("ListFiles(user_id=1001).Total = %d, want 3", got.Total) + } + if len(got.Items) != 3 { + t.Fatalf("ListFiles(user_id=1001) returned %d items, want 3", len(got.Items)) + } + }) +} + +func TestBatchDownloadFiles(t *testing.T) { + dbConn, cleanup := shared.SetupTestEnv(t) + defer cleanup() + + authUser := &contracts.UserDTO{ID: 1001, Username: "test_user"} + router := setupTestRouter(authUser) + + storageSvc := shared.NewMockStorageService() + storageSvc.PutRaw("uploads/f1.txt", []byte("file1 content")) + storageSvc.PutRaw("uploads/f2.txt", []byte("file2 content")) + storageSvc.PutRaw("uploads/f3.txt", []byte("duplicate name file content")) + shared.SetStorageService(storageSvc) + + // Seed upload records. Note f2 and f3 have the same FileName "file_a.txt" to trigger name collision resolution. + uploads := []models.Upload{ + { + ID: 3001, + UserID: 1001, + FileName: "file_a.txt", + FilePath: "uploads/f1.txt", + FileSize: 13, + MimeType: "text/plain", + Extension: "txt", + Status: models.UploadStatusUsed, + }, + { + ID: 3002, + UserID: 1001, + FileName: "file_b.txt", + FilePath: "uploads/f2.txt", + FileSize: 13, + MimeType: "text/plain", + Extension: "txt", + Status: models.UploadStatusUsed, + }, + { + ID: 3003, + UserID: 1001, + FileName: "file_a.txt", // COLLISION with 3001! + FilePath: "uploads/f3.txt", + FileSize: 28, + MimeType: "text/plain", + Extension: "txt", + Status: models.UploadStatusUsed, + }, + } + + for _, up := range uploads { + dbConn.Create(&up) + } + + t.Run("batch download zip successfully and check duplicate renaming", func(t *testing.T) { + reqBody, _ := json.Marshal(batchDownloadRequest{ + IDs: []string{"3001", "3002", "3003"}, + }) + req, _ := http.NewRequest("POST", "/api/v1/admin/uploads/download/batch", bytes.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. Body: %s", w.Code, w.Body.String()) + } + + if w.Header().Get("Content-Type") != "application/zip" { + t.Errorf("expected Content-Type application/zip, got %s", w.Header().Get("Content-Type")) + } + + // Unzip in-memory + zipReader, err := zip.NewReader(bytes.NewReader(w.Body.Bytes()), int64(w.Body.Len())) + if err != nil { + t.Fatalf("failed to read zip buffer: %v", err) + } + + if len(zipReader.File) != 3 { + t.Errorf("expected 3 files inside the ZIP, got %d", len(zipReader.File)) + } + + // Extract files to check their contents and name collision resolutions + extracted := make(map[string]string) + for _, f := range zipReader.File { + rc, err := f.Open() + if err != nil { + t.Fatalf("failed to open zip file entry %s: %v", f.Name, err) + } + content, _ := io.ReadAll(rc) + _ = rc.Close() + extracted[f.Name] = string(content) + } + + // Checks + if extracted["file_a.txt"] != "file1 content" { + t.Errorf("file_a.txt content incorrect: %q", extracted["file_a.txt"]) + } + if extracted["file_b.txt"] != "file2 content" { + t.Errorf("file_b.txt content incorrect: %q", extracted["file_b.txt"]) + } + // The second file_a.txt should be renamed to file_a_1.txt + if extracted["file_a_1.txt"] != "duplicate name file content" { + t.Errorf("file_a_1.txt content incorrect: %q. Extracted files: %v", extracted["file_a_1.txt"], extracted) + } + + t.Logf("Successfully unzipped batch. Extracted files: %+v", extracted) + }) +} + +func TestUploadAccessModeAccessControl(t *testing.T) { + dbConn, cleanup := shared.SetupTestEnv(t) + defer cleanup() + defer func() { _ = os.RemoveAll("uploads") }() + + user1 := &contracts.UserDTO{ID: 1001, Username: "user1"} + user2 := &contracts.UserDTO{ID: 1002, Username: "user2"} + + // Seed user1 + if err := dbConn.Table("w_users").Create(user1).Error; err != nil { + t.Fatalf("create test user1 failed: %v", err) + } + // Seed user2 + if err := dbConn.Table("w_users").Create(user2).Error; err != nil { + t.Fatalf("create test user2 failed: %v", err) + } + + router := setupTestRouter(user1) + + // 1. Upload private file for user1 (explicitly specifying access_mode = 0) + imgContent := []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89") + contentType, body := createMultipartRequest(t, "file", "private.png", imgContent, map[string]string{ + "type": "generic", + "access_mode": "0", + }) + req, _ := http.NewRequest("POST", "/api/v1/upload", body) + req.Header.Set("Content-Type", contentType) + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("Upload failed: %d, %s", w.Code, w.Body.String()) + } + t.Logf("Raw upload response: %s", w.Body.String()) + var resp1 testResponse + _ = json.Unmarshal(w.Body.Bytes(), &resp1) + var upload1 models.Upload + _ = json.Unmarshal(resp1.Data, &upload1) + + if upload1.AccessMode != 0 { + t.Errorf("expected access_mode 0, got %d", upload1.AccessMode) + } + + // 2. Upload public file for user1 (type avatar, should default to public 1) + contentType2, body2 := createMultipartRequest(t, "file", "public.png", []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89"), map[string]string{ + "type": "avatar", + }) + req2, _ := http.NewRequest("POST", "/api/v1/upload", body2) + req2.Header.Set("Content-Type", contentType2) + + w2 := httptest.NewRecorder() + router.ServeHTTP(w2, req2) + var resp2 testResponse + _ = json.Unmarshal(w2.Body.Bytes(), &resp2) + var upload2 models.Upload + _ = json.Unmarshal(resp2.Data, &upload2) + + if upload2.AccessMode != 1 { + t.Errorf("expected access_mode 1 (public) for avatar, got %d", upload2.AccessMode) + } + + // 3. Verify accessing private file as user1 (owner) succeeds + wAccessOwner := httptest.NewRecorder() + reqAccessOwner, _ := http.NewRequest("GET", "/api/v1/admin/uploads/download/"+strconv.FormatUint(upload1.ID, 10), nil) + router.ServeHTTP(wAccessOwner, reqAccessOwner) + if wAccessOwner.Code != http.StatusOK { + t.Errorf("owner should be allowed to download private file, got status %d", wAccessOwner.Code) + } + + // 4. Verify accessing private file as user2 (non-owner) fails + routerUser2 := setupTestRouter(user2) + wAccessOther := httptest.NewRecorder() + reqAccessOther, _ := http.NewRequest("GET", "/api/v1/admin/uploads/download/"+strconv.FormatUint(upload1.ID, 10), nil) + routerUser2.ServeHTTP(wAccessOther, reqAccessOther) + if wAccessOther.Code != http.StatusUnauthorized { + t.Errorf("non-owner should be denied download of private file, got status %d, want 401", wAccessOther.Code) + } + + // 5. Verify accessing public file as user2 (non-owner) succeeds + wAccessPublic := httptest.NewRecorder() + reqAccessPublic, _ := http.NewRequest("GET", "/api/v1/admin/uploads/download/"+strconv.FormatUint(upload2.ID, 10), nil) + routerUser2.ServeHTTP(wAccessPublic, reqAccessPublic) + if wAccessPublic.Code != http.StatusOK { + t.Errorf("any logged-in user should be allowed to download public file, got status %d", wAccessPublic.Code) + } +} + +func TestGetFileStats(t *testing.T) { + dbConn, cleanup := shared.SetupTestEnv(t) + defer cleanup() + + authUser := &contracts.UserDTO{ID: 1001, Username: "test_user"} + router := setupTestRouter(authUser) + + // Insert some dummy uploads + uploads := []models.Upload{ + { + ID: 3101, + UserID: authUser.ID, + FileName: "photo.png", + FilePath: "uploads/photo.png", + FileSize: 100, + MimeType: "image/png", + Extension: "png", + Type: "generic", + Status: models.UploadStatusUsed, + CreatedAt: time.Now(), + }, + { + ID: 3102, + UserID: authUser.ID, + FileName: "video.mp4", + FilePath: "uploads/video.mp4", + FileSize: 500, + MimeType: "video/mp4", + Extension: "mp4", + Type: "generic", + Status: models.UploadStatusUsed, + CreatedAt: time.Now().AddDate(0, 0, -2), // 2 days ago + }, + { + ID: 3103, + UserID: authUser.ID, + FileName: "document.pdf", + FilePath: "uploads/document.pdf", + FileSize: 200, + MimeType: "application/pdf", + Extension: "pdf", + Type: "avatar", // different type + Status: models.UploadStatusUsed, + CreatedAt: time.Now().AddDate(0, 0, -10), // older than 7 days + }, + } + + for i := range uploads { + if err := dbConn.Create(&uploads[i]).Error; err != nil { + t.Fatalf("failed to create upload: %v", err) + } + } + if err := uploadstats.RebuildUploadStats(context.Background()); err != nil { + t.Fatalf("failed to rebuild upload stats: %v", err) + } + + req, _ := http.NewRequest("GET", "/api/v1/admin/uploads/stats", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d, body: %s", w.Code, w.Body.String()) + } + + var resp struct { + ErrorMsg string `json:"error_msg"` + Data fileStatsResponse `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if resp.ErrorMsg != "" { + t.Fatalf("expected no error, got: %s", resp.ErrorMsg) + } + + // Verify total count and size + if resp.Data.TotalCount != 3 { + t.Errorf("expected 3 total files, got %d", resp.Data.TotalCount) + } + if resp.Data.TotalSize != 800 { + t.Errorf("expected 800 total size, got %d", resp.Data.TotalSize) + } + + // Verify trend (last 7 days should include photo.png (100) and video.mp4 (500), but NOT pdf (older)) + // Total size in trend should be 600 + var trendSizeSum int64 + for _, trendItem := range resp.Data.Trend { + trendSizeSum += trendItem.Size + } + if trendSizeSum != 600 { + t.Errorf("expected 7-day trend size sum to be 600, got %d", trendSizeSum) + } + + // Verify categories + categoryMap := make(map[string]int64) + for _, cat := range resp.Data.Categories { + categoryMap[cat.Name] = cat.Count + } + if categoryMap["图片"] != 1 { + t.Errorf("expected 1 image category, got %d", categoryMap["图片"]) + } + if categoryMap["视频"] != 1 { + t.Errorf("expected 1 video category, got %d", categoryMap["视频"]) + } + if categoryMap["文档"] != 1 { + t.Errorf("expected 1 document category, got %d", categoryMap["文档"]) + } +} + +func TestUserUploadManagement(t *testing.T) { + dbConn, cleanup := shared.SetupTestEnv(t) + defer cleanup() + + user1 := &contracts.UserDTO{ID: 1001, Username: "user1"} + user2 := &contracts.UserDTO{ID: 1002, Username: "user2"} + + _ = dbConn.Table("w_users").Create(user1) + _ = dbConn.Table("w_users").Create(user2) + + router1 := setupTestRouter(user1) + router2 := setupTestRouter(user2) + + // Seed upload records + upload1 := models.Upload{ + ID: 4001, + UserID: 1001, + FileName: "user1-file.txt", + FilePath: "uploads/user1-file.txt", + FileSize: 100, + MimeType: "text/plain", + Extension: "txt", + Status: models.UploadStatusUsed, + CreatedAt: time.Now(), + } + upload2 := models.Upload{ + ID: 4002, + UserID: 1002, + FileName: "user2-file.png", + FilePath: "uploads/user2-file.png", + FileSize: 200, + MimeType: "image/png", + Extension: "png", + Status: models.UploadStatusUsed, + CreatedAt: time.Now(), + } + + _ = dbConn.Create(&upload1) + _ = dbConn.Create(&upload2) + + t.Run("ListMyFiles only returns own files", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/api/v1/upload/my", nil) + w := httptest.NewRecorder() + router1.ServeHTTP(w, req) + + var resp struct { + ErrorMsg string `json:"error_msg"` + Data listMyFilesResponse `json:"data"` + } + _ = json.Unmarshal(w.Body.Bytes(), &resp) + + if resp.ErrorMsg != "" { + t.Fatalf("ListMyFiles error: %s", resp.ErrorMsg) + } + if resp.Data.Total != 1 { + t.Errorf("expected 1 file for user1, got %d", resp.Data.Total) + } + if len(resp.Data.Items) != 1 || resp.Data.Items[0].ID != 4001 { + t.Errorf("expected file 4001, got items: %+v", resp.Data.Items) + } + }) + + t.Run("UpdateMyFile updates file name and access mode successfully", func(t *testing.T) { + newMode := 1 + reqBody, _ := json.Marshal(updateMyFileRequest{ + FileName: "renamed.txt", + AccessMode: &newMode, + }) + req, _ := http.NewRequest("PUT", "/api/v1/upload/4001", bytes.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router1.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. Body: %s", w.Code, w.Body.String()) + } + + var updated models.Upload + dbConn.First(&updated, 4001) + if updated.FileName != "renamed.txt" { + t.Errorf("expected file name renamed.txt, got %s", updated.FileName) + } + if updated.AccessMode != 1 { + t.Errorf("expected access mode 1, got %d", updated.AccessMode) + } + }) + + t.Run("UpdateMyFile blocks non-owners", func(t *testing.T) { + reqBody, _ := json.Marshal(updateMyFileRequest{ + FileName: "hack.txt", + }) + req, _ := http.NewRequest("PUT", "/api/v1/upload/4001", bytes.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router2.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Errorf("expected status 403, got %d", w.Code) + } + }) + + t.Run("DeleteMyFile blocks non-owners", func(t *testing.T) { + req, _ := http.NewRequest("DELETE", "/api/v1/upload/4001", nil) + w := httptest.NewRecorder() + router2.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Errorf("expected status 403, got %d", w.Code) + } + }) + + t.Run("DeleteMyFile deletes file successfully", func(t *testing.T) { + req, _ := http.NewRequest("DELETE", "/api/v1/upload/4001", nil) + w := httptest.NewRecorder() + router1.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + + var deleted models.Upload + dbConn.First(&deleted, 4001) + if deleted.Status != models.UploadStatusDeleted { + t.Errorf("expected status deleted, got %s", deleted.Status) + } + }) +} diff --git a/backend/plugins/domain/upload/handler/stats.go b/backend/plugins/domain/upload/handler/stats.go new file mode 100644 index 00000000..e1a9a472 --- /dev/null +++ b/backend/plugins/domain/upload/handler/stats.go @@ -0,0 +1,125 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package handler + +import ( + "Wavelet/pkg/response" + "Wavelet/plugins/domain/upload/shared" + "net/http" + "time" + + "github.com/gin-gonic/gin" +) + +type trendItem struct { + Date string `json:"date"` + Count int64 `json:"count"` + Size int64 `json:"size"` +} + +type distributionItem struct { + Name string `json:"name"` + Count int64 `json:"count"` + Size int64 `json:"size"` +} + +type fileStatsResponse struct { + TotalCount int64 `json:"total_count"` + TotalSize int64 `json:"total_size"` + Trend []trendItem `json:"trend"` + Categories []distributionItem `json:"categories"` + Types []distributionItem `json:"types"` +} + +// GetFileStats 获取系统上传的文件统计数据 +// @Summary 获取文件统计数据 +// @Description 返回系统级的总文件数、占用大小、最近 7 天新增趋势、文件类型/格式分布等数据 +// @Tags admin +// @Produce json +// @Security SessionCookie +// @Success 200 {object} response.Any{data=fileStatsResponse} "获取成功" +// @Failure 401 {object} response.Any "未登录" +// @Failure 403 {object} response.Any "无管理员权限" +// @Failure 500 {object} response.Any "内部错误" +// @Router /api/v1/admin/uploads/stats [get] +func GetFileStats(c *gin.Context) { + ctx := c.Request.Context() + + stats, err := loadUploadStats(ctx) + if err != nil { + response.AbortBadRequest(c, err.Error()) + return + } + + now := time.Now() + trendDates := make([]string, 0, shared.FileStatsTrendDays) + trendCountMap := make(map[string]int64, shared.FileStatsTrendDays) + trendSizeMap := make(map[string]int64, shared.FileStatsTrendDays) + for i := shared.FileStatsTrendDays - 1; i >= 0; i-- { + date := now.AddDate(0, 0, -i).Format("2006-01-02") + trendDates = append(trendDates, date) + trendCountMap[date] = 0 + trendSizeMap[date] = 0 + } + + var ( + totalCount int64 + totalSize int64 + types []distributionItem + categories []distributionItem + ) + + categoriesList := []string{"图片", "视频", "音频", "文档", "压缩包", "其他"} + categoryMap := make(map[string]distributionItem, len(categoriesList)) + for _, cat := range categoriesList { + categoryMap[cat] = distributionItem{Name: cat} + } + + for _, stat := range stats { + switch stat.Dimension { + case shared.UploadStatDimensionTotal: + totalCount = stat.FileCount + totalSize = stat.FileSize + case shared.UploadStatDimensionType: + types = append(types, distributionItem{ + Name: stat.StatKey, + Count: stat.FileCount, + Size: stat.FileSize, + }) + case shared.UploadStatDimensionCategory: + if item, ok := categoryMap[stat.StatKey]; ok { + item.Count = stat.FileCount + item.Size = stat.FileSize + categoryMap[stat.StatKey] = item + } + case shared.UploadStatDimensionTrend: + if _, ok := trendCountMap[stat.StatKey]; ok { + trendCountMap[stat.StatKey] = stat.FileCount + trendSizeMap[stat.StatKey] = stat.FileSize + } + } + } + + categories = make([]distributionItem, 0, len(categoriesList)) + for _, cat := range categoriesList { + categories = append(categories, categoryMap[cat]) + } + + trend := make([]trendItem, 0, len(trendDates)) + for _, date := range trendDates { + trend = append(trend, trendItem{ + Date: date, + Count: trendCountMap[date], + Size: trendSizeMap[date], + }) + } + + c.JSON(http.StatusOK, response.OK(fileStatsResponse{ + TotalCount: totalCount, + TotalSize: totalSize, + Trend: trend, + Categories: categories, + Types: types, + })) +} diff --git a/backend/plugins/domain/upload/ingest/errors.go b/backend/plugins/domain/upload/ingest/errors.go new file mode 100644 index 00000000..20bf747f --- /dev/null +++ b/backend/plugins/domain/upload/ingest/errors.go @@ -0,0 +1,15 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package ingest + +import ( + "Wavelet/plugins/domain/upload/shared" + "errors" +) + +// ErrForbidden indicates the caller is not allowed to mutate the upload record. +var ErrForbidden = errors.New("upload forbidden") + +// ErrStorageReadOnly indicates the storage backend is in migration read-only mode. +var ErrStorageReadOnly = errors.New(shared.ErrStorageReadOnly) diff --git a/backend/plugins/domain/upload/ingest/helpers.go b/backend/plugins/domain/upload/ingest/helpers.go new file mode 100644 index 00000000..660f9e20 --- /dev/null +++ b/backend/plugins/domain/upload/ingest/helpers.go @@ -0,0 +1,217 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package ingest + +import ( + "Wavelet/pkg/idgen" + "Wavelet/pkg/logger" + "Wavelet/plugins/domain/upload/models" + "Wavelet/plugins/domain/upload/repository" + "Wavelet/plugins/domain/upload/shared" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + "time" + + "gorm.io/gorm" + + uploadcache "Wavelet/plugins/domain/upload/cache" + + uploadstats "Wavelet/plugins/domain/upload/stats" + uploadstorage "Wavelet/plugins/domain/upload/storage" +) + +func normalizeRequest(req *Request) { + req.Extension = strings.ToLower(strings.TrimSpace(req.Extension)) + if req.Extension == "" { + req.Extension = "bin" + } + if req.Type == "" { + req.Type = "generic" + } + if req.Status == "" { + req.Status = models.UploadStatusUsed + } +} + +func resolveAccessMode(uploadType string, explicit *int) int { + if explicit != nil { + return *explicit + } + if uploadType == shared.DefaultPublicUploadType { + return 1 + } + return 0 +} + +func validateAllowedExtension(ctx context.Context, ext string) error { + var val string + db := shared.GetDB(ctx) + if db != nil { + err := db.Table("w_system_configs").Where("key = ?", "upload_allowed_extensions").Pluck("value", &val).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + logger.WarnF(ctx, "failed to query upload_allowed_extensions: %v", err) + return nil + } + } + if val == "" { + return nil + } + + var list []string + if err := json.Unmarshal([]byte(val), &list); err == nil { + for _, allowedExt := range list { + if strings.EqualFold(strings.TrimSpace(allowedExt), ext) { + return nil + } + } + return errors.New(shared.ErrUnsupportedFormat) + } + + allowedExts := strings.Split(strings.ToLower(val), ",") + for _, allowedExt := range allowedExts { + if strings.TrimSpace(allowedExt) == ext { + return nil + } + } + return errors.New(shared.ErrUnsupportedFormat) +} + +func defaultObjectKey(id uint64, ext string) string { + return fmt.Sprintf("uploads/%s/%d.%s", time.Now().Format("2006/01/02"), id, ext) +} + +func buildObjectKey(req Request, id uint64) string { + if req.ObjectKeyFn != nil { + return req.ObjectKeyFn(id, req.Extension) + } + return defaultObjectKey(id, req.Extension) +} + +func storeObject(ctx context.Context, objectKey string, reader io.Reader, size int64, mimeType string, meta *models.UploadMetadata) (string, error) { + if uploadstorage.ReadOnly(ctx) { + return "", ErrStorageReadOnly + } + + storageSvc := shared.GetStorage(ctx) + if storageSvc == nil { + logger.ErrorF(ctx, "初始化活动存储失败: storage service is nil") + return "", errors.New(shared.ErrSaveFileFailed) + } + + result, err := storageSvc.Put(ctx, objectKey, reader, size, mimeType) + if err != nil { + logger.ErrorF(ctx, "写入存储失败: %v", err) + return "", errors.New(shared.ErrSaveFileFailed) + } + + meta.Bucket = result.Bucket + return result.Key, nil +} + +func persistUploadRecord(ctx context.Context, upload *models.Upload, objectKey string) error { + if err := createUploadWithStats(ctx, upload); err != nil { + if storageSvc := shared.GetStorage(ctx); storageSvc != nil { + if deleteErr := storageSvc.Delete(ctx, objectKey); deleteErr != nil { + logger.WarnF(ctx, "清理未写入数据库的上传对象失败: %v", deleteErr) + } + } + return err + } + uploadcache.SetUploadMeta(ctx, *upload) + return nil +} + +func createUploadWithStats(ctx context.Context, upload *models.Upload) error { + db := shared.GetDB(ctx) + if db == nil { + return errors.New("database service not available") + } + return db.Transaction(func(tx *gorm.DB) error { + if err := repository.CreateUploadTx(tx, upload); err != nil { + return err + } + return uploadstats.ApplyUploadStatsDeltaTx(tx, upload, 1) + }) +} + +func createDedupRecord(ctx context.Context, existing models.Upload, req Request) (Result, error) { + accessMode := resolveAccessMode(req.Type, req.AccessMode) + newUpload := models.Upload{ + ID: idgen.NextUint64ID(), + UserID: req.UserID, + FileName: req.FileName, + FilePath: existing.FilePath, + FileSize: req.Size, + MimeType: req.MimeType, + Extension: req.Extension, + Hash: req.Hash, + Type: req.Type, + Status: req.Status, + AccessMode: accessMode, + Metadata: existing.Metadata, + } + if err := persistUploadRecord(ctx, &newUpload, existing.FilePath); err != nil { + return Result{}, err + } + logger.InfoF(ctx, "文件触发秒传成功! ID: %d, Path: %s", newUpload.ID, existing.FilePath) + return Result{ + Upload: newUpload, + Created: true, + Stored: false, + }, nil +} + +func uploadstorageReadOnly(ctx context.Context) bool { + return uploadstorage.ReadOnly(ctx) +} + +func createNewUpload(ctx context.Context, req Request) (Result, error) { + if uploadstorageReadOnly(ctx) { + return Result{}, ErrStorageReadOnly + } + if !req.SkipExtensionCheck { + if err := validateAllowedExtension(ctx, req.Extension); err != nil { + return Result{}, err + } + } + + id := idgen.NextUint64ID() + objectKey := buildObjectKey(req, id) + storedKey, err := storeObject(ctx, objectKey, req.Reader, req.Size, req.MimeType, &req.Metadata) + if err != nil { + return Result{}, err + } + + accessMode := resolveAccessMode(req.Type, req.AccessMode) + upload := models.Upload{ + ID: id, + UserID: req.UserID, + FileName: req.FileName, + FilePath: storedKey, + FileSize: req.Size, + MimeType: req.MimeType, + Extension: req.Extension, + Hash: req.Hash, + Type: req.Type, + Status: req.Status, + AccessMode: accessMode, + Metadata: req.Metadata, + } + if err := persistUploadRecord(ctx, &upload, storedKey); err != nil { + return Result{}, err + } + + return Result{ + Upload: upload, + Created: true, + Stored: true, + }, nil +} diff --git a/backend/plugins/domain/upload/ingest/ingest.go b/backend/plugins/domain/upload/ingest/ingest.go new file mode 100644 index 00000000..db5ca4a5 --- /dev/null +++ b/backend/plugins/domain/upload/ingest/ingest.go @@ -0,0 +1,64 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package ingest + +import ( + "Wavelet/plugins/domain/upload/models" + "Wavelet/plugins/domain/upload/repository" + "context" + "errors" + + "gorm.io/gorm" +) + +// Ingest stores or resolves an upload using the configured policy and side effects. +func Ingest(ctx context.Context, req Request) (Result, error) { + normalizeRequest(&req) + if req.Hash == "" { + return Result{}, errors.New("ingest hash is required") + } + if req.Reader == nil { + return Result{}, errors.New("ingest reader is required") + } + if req.Size < 0 { + return Result{}, errors.New("ingest size must be non-negative") + } + + switch req.Policy { + case PolicyDedupNewRecord, PolicyResolveExisting: + return ingestWithHashPolicy(ctx, req) + case PolicyCreate: + return createNewUpload(ctx, req) + default: + return Result{}, errors.New("unsupported ingest policy") + } +} + +// FindByHash returns a reusable active upload with the same hash and size. +func FindByHash(ctx context.Context, hash string, size int64) (models.Upload, error) { + return repository.FindReusableUploadByHash(ctx, hash, size) +} + +func ingestWithHashPolicy(ctx context.Context, req Request) (Result, error) { + existing, err := repository.FindReusableUploadByHash(ctx, req.Hash, req.Size) + if err == nil { + switch req.Policy { + case PolicyResolveExisting: + return Result{ + Upload: existing, + Resolved: true, + }, nil + case PolicyDedupNewRecord: + if uploadstorageReadOnly(ctx) { + return Result{}, ErrStorageReadOnly + } + return createDedupRecord(ctx, existing, req) + } + } + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return Result{}, err + } + + return createNewUpload(ctx, req) +} diff --git a/backend/plugins/domain/upload/ingest/ingest_test.go b/backend/plugins/domain/upload/ingest/ingest_test.go new file mode 100644 index 00000000..de3cefc2 --- /dev/null +++ b/backend/plugins/domain/upload/ingest/ingest_test.go @@ -0,0 +1,358 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package ingest + +import ( + "Wavelet/core/contracts" + "Wavelet/pkg/idgen" + "Wavelet/plugins/domain/upload/models" + "Wavelet/plugins/domain/upload/shared" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "io" + "os" + "sync" + "testing" +) + +func init() { + _ = idgen.Init(1) +} + +type testStorageService struct { + mu sync.RWMutex + mockFiles map[string][]byte + putCount *int +} + +func (s *testStorageService) Put(_ context.Context, key string, body io.Reader, _ int64, _ string) (contracts.StoragePutResult, error) { + s.mu.Lock() + defer s.mu.Unlock() + data, err := io.ReadAll(body) + if err != nil { + return contracts.StoragePutResult{}, err + } + s.mockFiles[key] = data + if s.putCount != nil { + *s.putCount++ + } + return contracts.StoragePutResult{Key: key, Bucket: "test-bucket"}, nil +} + +func (s *testStorageService) Get(_ context.Context, key string) (*contracts.StorageObject, error) { + s.mu.RLock() + defer s.mu.RUnlock() + data, ok := s.mockFiles[key] + if !ok { + return nil, os.ErrNotExist + } + return &contracts.StorageObject{ + Key: key, + Body: io.NopCloser(bytes.NewReader(data)), + ContentLength: int64(len(data)), + ContentType: "application/octet-stream", + }, nil +} + +func (s *testStorageService) Delete(_ context.Context, key string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.mockFiles, key) + return nil +} + +func (s *testStorageService) Ingest(_ context.Context, _ io.Reader, _ contracts.IngestOptions) (*contracts.IngestResult, error) { + return nil, nil +} + +func setupMockStorage(t *testing.T, putCount *int) (restore, disable func()) { + t.Helper() + mockSvc := &testStorageService{ + mockFiles: make(map[string][]byte), + putCount: putCount, + } + shared.SetStorageService(mockSvc) + return func() { + shared.SetStorageService(nil) + }, func() { + shared.SetStorageService(nil) + } +} + +func loadTotalStats(ctx context.Context) (totalStatsSnapshot, error) { + var rows []models.UploadStat + if err := shared.GetDB(ctx).Where("dimension = ?", models.UploadStatDimensionTotal).Find(&rows).Error; err != nil { + return totalStatsSnapshot{}, err + } + if len(rows) == 0 { + return totalStatsSnapshot{}, nil + } + return totalStatsSnapshot{ + TotalCount: rows[0].FileCount, + TotalSize: rows[0].FileSize, + }, nil +} + +type totalStatsSnapshot struct { + TotalCount int64 + TotalSize int64 +} + +func TestIngestPolicyCreateIncrementsStats(t *testing.T) { + _, cleanup := shared.SetupTestEnv(t) + defer cleanup() + ctx := context.Background() + + content := []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01") + hash := sha256.Sum256(content) + + restoreStorage, disableStorage := setupMockStorage(t, nil) + defer restoreStorage() + defer disableStorage() + + result, err := Ingest(ctx, Request{ + UserID: 1001, + Reader: bytes.NewReader(content), + Size: int64(len(content)), + FileName: "mirror.png", + MimeType: "image/png", + Extension: "png", + Hash: hex.EncodeToString(hash[:]), + Type: "pixez_mirror", + Policy: PolicyCreate, + }) + if err != nil { + t.Fatalf("Ingest(PolicyCreate) returned error: %v", err) + } + if !result.Created || !result.Stored || result.Resolved { + t.Fatalf("Ingest(PolicyCreate) = %+v, want Created+Stored without Resolved", result) + } + + stats, err := loadTotalStats(ctx) + if err != nil { + t.Fatalf("loadTotalStats returned error: %v", err) + } + if stats.TotalCount != 1 || stats.TotalSize != int64(len(content)) { + t.Fatalf("loadTotalStats() = count %d size %d, want count 1 size %d", stats.TotalCount, stats.TotalSize, len(content)) + } +} + +func TestIngestPolicyResolveExistingSkipsStatsOnHit(t *testing.T) { + _, cleanup := shared.SetupTestEnv(t) + defer cleanup() + ctx := context.Background() + + content := []byte("hello duplicate resolution") + hash := sha256.Sum256(content) + hashStr := hex.EncodeToString(hash[:]) + + putCount := 0 + restoreStorage, disableStorage := setupMockStorage(t, &putCount) + defer restoreStorage() + defer disableStorage() + + first, err := Ingest(ctx, Request{ + UserID: 1001, + Reader: bytes.NewReader(content), + Size: int64(len(content)), + FileName: "first.txt", + MimeType: "text/plain", + Extension: "txt", + Hash: hashStr, + Type: "attachment", + Policy: PolicyCreate, + }) + if err != nil { + t.Fatalf("first Ingest returned error: %v", err) + } + if !first.Created || !first.Stored { + t.Fatalf("first Ingest = %+v, want Created and Stored true", first) + } + if putCount != 1 { + t.Fatalf("putCount = %d, want 1 after initial store", putCount) + } + + second, err := Ingest(ctx, Request{ + UserID: 1002, + Reader: bytes.NewReader(content), + Size: int64(len(content)), + FileName: "second.txt", + MimeType: "text/plain", + Extension: "txt", + Hash: hashStr, + Type: "attachment", + Policy: PolicyResolveExisting, + }) + if err != nil { + t.Fatalf("second Ingest(PolicyResolveExisting) returned error: %v", err) + } + if second.Created || second.Stored || !second.Resolved { + t.Fatalf("second Ingest = %+v, want Created/Stored false and Resolved true", second) + } + if second.Upload.ID != first.Upload.ID { + t.Fatalf("resolved ID = %d, want %d", second.Upload.ID, first.Upload.ID) + } + if putCount != 1 { + t.Fatalf("putCount = %d, want 1 after hit with PolicyResolveExisting", putCount) + } + + stats, err := loadTotalStats(ctx) + if err != nil { + t.Fatalf("loadTotalStats returned error: %v", err) + } + if stats.TotalCount != 1 || stats.TotalSize != int64(len(content)) { + t.Fatalf("stats = %+v, want count 1 size %d", stats, len(content)) + } +} + +func TestIngestPolicyDedupNewRecordReusesStorage(t *testing.T) { + _, cleanup := shared.SetupTestEnv(t) + defer cleanup() + ctx := context.Background() + + content := []byte("hello dedup reuse") + hash := sha256.Sum256(content) + hashStr := hex.EncodeToString(hash[:]) + + putCount := 0 + restoreStorage, disableStorage := setupMockStorage(t, &putCount) + defer restoreStorage() + defer disableStorage() + + first, err := Ingest(ctx, Request{ + UserID: 1001, + Reader: bytes.NewReader(content), + Size: int64(len(content)), + FileName: "first.txt", + MimeType: "text/plain", + Extension: "txt", + Hash: hashStr, + Type: "attachment", + Policy: PolicyCreate, + }) + if err != nil { + t.Fatalf("first Ingest: %v", err) + } + + second, err := Ingest(ctx, Request{ + UserID: 1002, + Reader: bytes.NewReader(content), + Size: int64(len(content)), + FileName: "second.txt", + MimeType: "text/plain", + Extension: "txt", + Hash: hashStr, + Type: "attachment", + Policy: PolicyDedupNewRecord, + }) + if err != nil { + t.Fatalf("second Ingest(PolicyDedupNewRecord): %v", err) + } + if !second.Created || second.Stored || second.Resolved { + t.Fatalf("second Ingest = %+v, want Created=true Stored=false Resolved=false", second) + } + if second.Upload.ID == first.Upload.ID { + t.Fatalf("expected new upload record, got matching ID %d", second.Upload.ID) + } + if second.Upload.FilePath != first.Upload.FilePath { + t.Fatalf("expected reused FilePath %q, got %q", first.Upload.FilePath, second.Upload.FilePath) + } + if putCount != 1 { + t.Fatalf("putCount = %d, want 1 after dedup new record", putCount) + } + + stats, err := loadTotalStats(ctx) + if err != nil { + t.Fatalf("loadTotalStats: %v", err) + } + if stats.TotalCount != 2 || stats.TotalSize != int64(len(content)*2) { + t.Fatalf("stats = %+v, want count 2 size %d", stats, len(content)*2) + } +} + +func TestRemoveDecrementsStats(t *testing.T) { + _, cleanup := shared.SetupTestEnv(t) + defer cleanup() + ctx := context.Background() + + content := []byte("remove payload") + hash := sha256.Sum256(content) + + restoreStorage, disableStorage := setupMockStorage(t, nil) + defer restoreStorage() + defer disableStorage() + + ingested, err := Ingest(ctx, Request{ + UserID: 1001, + Reader: bytes.NewReader(content), + Size: int64(len(content)), + FileName: "to_remove.txt", + MimeType: "text/plain", + Extension: "txt", + Hash: hex.EncodeToString(hash[:]), + Type: "generic", + Policy: PolicyCreate, + }) + if err != nil { + t.Fatalf("Ingest: %v", err) + } + + removed, err := Remove(ctx, ingested.Upload.ID) + if err != nil { + t.Fatalf("Remove: %v", err) + } + if removed.Status != models.UploadStatusDeleted { + t.Fatalf("removed status = %q, want deleted", removed.Status) + } + + stats, err := loadTotalStats(ctx) + if err != nil { + t.Fatalf("loadTotalStats: %v", err) + } + if stats.TotalCount != 0 || stats.TotalSize != 0 { + t.Fatalf("stats = %+v, want count 0 size 0 after remove", stats) + } +} + +func TestRemoveOwnedEnforcesOwnership(t *testing.T) { + _, cleanup := shared.SetupTestEnv(t) + defer cleanup() + ctx := context.Background() + + content := []byte("owner payload") + hash := sha256.Sum256(content) + + restoreStorage, disableStorage := setupMockStorage(t, nil) + defer restoreStorage() + defer disableStorage() + + ingested, err := Ingest(ctx, Request{ + UserID: 1001, + Reader: bytes.NewReader(content), + Size: int64(len(content)), + FileName: "owned.txt", + MimeType: "text/plain", + Extension: "txt", + Hash: hex.EncodeToString(hash[:]), + Type: "generic", + Policy: PolicyCreate, + }) + if err != nil { + t.Fatalf("Ingest: %v", err) + } + + if _, err := RemoveOwned(ctx, 2002, ingested.Upload.ID); err == nil { + t.Fatal("expected ErrForbidden for non-owner RemoveOwned") + } + + removed, err := RemoveOwned(ctx, 1001, ingested.Upload.ID) + if err != nil { + t.Fatalf("RemoveOwned owner failed: %v", err) + } + if removed.Status != models.UploadStatusDeleted { + t.Fatalf("removed status = %q, want deleted", removed.Status) + } +} diff --git a/backend/plugins/domain/upload/ingest/remove.go b/backend/plugins/domain/upload/ingest/remove.go new file mode 100644 index 00000000..27eea984 --- /dev/null +++ b/backend/plugins/domain/upload/ingest/remove.go @@ -0,0 +1,63 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package ingest + +import ( + "Wavelet/plugins/domain/upload/models" + "Wavelet/plugins/domain/upload/repository" + "Wavelet/plugins/domain/upload/shared" + "context" + + "gorm.io/gorm" + + uploadcache "Wavelet/plugins/domain/upload/cache" + + uploadstats "Wavelet/plugins/domain/upload/stats" +) + +// Remove soft-deletes an upload and decrements incremental stats. +func Remove(ctx context.Context, uploadID uint64) (models.Upload, error) { + upload, err := repository.GetActiveUploadByID(ctx, uploadID) + if err != nil { + return models.Upload{}, err + } + if err := softDeleteUploadWithStats(ctx, &upload); err != nil { + return models.Upload{}, err + } + upload.Status = models.UploadStatusDeleted + return upload, nil +} + +// RemoveOwned soft-deletes an upload owned by userID and decrements incremental stats. +func RemoveOwned(ctx context.Context, userID, uploadID uint64) (models.Upload, error) { + upload, err := repository.GetActiveUploadByID(ctx, uploadID) + if err != nil { + return models.Upload{}, err + } + if upload.UserID != userID { + return models.Upload{}, ErrForbidden + } + if err := softDeleteUploadWithStats(ctx, &upload); err != nil { + return models.Upload{}, err + } + upload.Status = models.UploadStatusDeleted + return upload, nil +} + +func softDeleteUploadWithStats(ctx context.Context, upload *models.Upload) error { + statsSnapshot := *upload + db := shared.GetDB(ctx) + if db != nil { + if err := db.Transaction(func(tx *gorm.DB) error { + if err := repository.SoftDeleteUploadTx(tx, upload); err != nil { + return err + } + return uploadstats.ApplyUploadStatsDeltaTx(tx, &statsSnapshot, -1) + }); err != nil { + return err + } + } + uploadcache.EvictUploadMeta(ctx, upload.ID) + return nil +} diff --git a/backend/plugins/domain/upload/ingest/types.go b/backend/plugins/domain/upload/ingest/types.go new file mode 100644 index 00000000..b4cbaebf --- /dev/null +++ b/backend/plugins/domain/upload/ingest/types.go @@ -0,0 +1,59 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package ingest provides the programmatic upload domain service for Wavelet. +package ingest + +import ( + "Wavelet/plugins/domain/upload/models" + "io" +) + +// Policy controls how ingest handles hash collisions and record creation. +type Policy int + +const ( + // PolicyCreate always stores a new object and creates a new upload record. + PolicyCreate Policy = iota + + // PolicyDedupNewRecord reuses an existing object path on hash match but creates a new record and stats delta. + PolicyDedupNewRecord + + // PolicyResolveExisting returns an existing upload on hash match without creating a record or stats delta. + PolicyResolveExisting +) + +// ObjectKeyFn builds the storage object key for a new upload. +type ObjectKeyFn func(id uint64, ext string) string + +// Request describes a programmatic file ingest operation. +type Request struct { + UserID uint64 + Type string + + AccessMode *int + Status models.UploadStatus + + Reader io.Reader + Size int64 + FileName string + MimeType string + Extension string + Hash string + + Metadata models.UploadMetadata + Policy Policy + + ObjectKeyFn ObjectKeyFn + + // SkipExtensionCheck bypasses the configured upload extension whitelist. + SkipExtensionCheck bool +} + +// Result reports the outcome of an ingest operation. +type Result struct { + Upload models.Upload + Created bool + Stored bool + Resolved bool +} diff --git a/backend/plugins/domain/upload/migrations/postgres/00001_initial.sql b/backend/plugins/domain/upload/migrations/postgres/00001_initial.sql new file mode 100644 index 00000000..a0b7b69b --- /dev/null +++ b/backend/plugins/domain/upload/migrations/postgres/00001_initial.sql @@ -0,0 +1,40 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS w_uploads ( + id BIGINT PRIMARY KEY, + user_id BIGINT NOT NULL, + file_name VARCHAR(255) NOT NULL, + file_path VARCHAR(500) NOT NULL, + file_size BIGINT NOT NULL, + mime_type VARCHAR(100) NOT NULL, + extension VARCHAR(50) NOT NULL, + hash VARCHAR(64), + type VARCHAR(50) NOT NULL, + status VARCHAR(20) NOT NULL, + access_mode INTEGER NOT NULL DEFAULT 0, + metadata JSONB, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_w_uploads_user_id ON w_uploads (user_id); +CREATE INDEX IF NOT EXISTS idx_w_uploads_file_path ON w_uploads (file_path); +CREATE INDEX IF NOT EXISTS idx_w_uploads_hash ON w_uploads (hash); +CREATE INDEX IF NOT EXISTS idx_w_uploads_type ON w_uploads (type); +CREATE INDEX IF NOT EXISTS idx_w_uploads_status_created_at ON w_uploads (status, created_at); +CREATE INDEX IF NOT EXISTS idx_w_uploads_hash_file_size_status ON w_uploads (hash, file_size, status); + +CREATE TABLE IF NOT EXISTS w_upload_stats ( + dimension VARCHAR(32) NOT NULL, + stat_key VARCHAR(64) NOT NULL DEFAULT '', + file_count BIGINT NOT NULL DEFAULT 0, + file_size BIGINT NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (dimension, stat_key) +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS w_upload_stats; +DROP TABLE IF EXISTS w_uploads; +-- +goose StatementEnd diff --git a/backend/plugins/domain/upload/migrations/sqlite/00001_initial.sql b/backend/plugins/domain/upload/migrations/sqlite/00001_initial.sql new file mode 100644 index 00000000..05ec33e8 --- /dev/null +++ b/backend/plugins/domain/upload/migrations/sqlite/00001_initial.sql @@ -0,0 +1,40 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS w_uploads ( + id BIGINT PRIMARY KEY, + user_id BIGINT NOT NULL, + file_name VARCHAR(255) NOT NULL, + file_path VARCHAR(500) NOT NULL, + file_size BIGINT NOT NULL, + mime_type VARCHAR(100) NOT NULL, + extension VARCHAR(50) NOT NULL, + hash VARCHAR(64), + type VARCHAR(50) NOT NULL, + status VARCHAR(20) NOT NULL, + access_mode INTEGER NOT NULL DEFAULT 0, + metadata TEXT NOT NULL DEFAULT '{}', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_w_uploads_user_id ON w_uploads (user_id); +CREATE INDEX IF NOT EXISTS idx_w_uploads_file_path ON w_uploads (file_path); +CREATE INDEX IF NOT EXISTS idx_w_uploads_hash ON w_uploads (hash); +CREATE INDEX IF NOT EXISTS idx_w_uploads_type ON w_uploads (type); +CREATE INDEX IF NOT EXISTS idx_w_uploads_status_created_at ON w_uploads (status, created_at); +CREATE INDEX IF NOT EXISTS idx_w_uploads_hash_file_size_status ON w_uploads (hash, file_size, status); + +CREATE TABLE IF NOT EXISTS w_upload_stats ( + dimension VARCHAR(32) NOT NULL, + stat_key VARCHAR(64) NOT NULL DEFAULT '', + file_count BIGINT NOT NULL DEFAULT 0, + file_size BIGINT NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (dimension, stat_key) +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS w_upload_stats; +DROP TABLE IF EXISTS w_uploads; +-- +goose StatementEnd diff --git a/backend/plugins/domain/upload/models.go b/backend/plugins/domain/upload/models.go new file mode 100644 index 00000000..adc363d8 --- /dev/null +++ b/backend/plugins/domain/upload/models.go @@ -0,0 +1,34 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package upload 提供上传域的门面与类型重导出。 +package upload + +import ( + "Wavelet/plugins/domain/upload/models" +) + +// UploadStatus 上传状态类型别名 +// +//nolint:revive +type UploadStatus = models.UploadStatus + +// UploadMetadata 上传元数据类型别名 +// +//nolint:revive +type UploadMetadata = models.UploadMetadata + +// Upload 上传实体类型别名 +type Upload = models.Upload + +// UploadStat 上传统计实体类型别名 +// +//nolint:revive +type UploadStat = models.UploadStat + +// 上传状态常量别名 +const ( + UploadStatusPending = models.UploadStatusPending + UploadStatusUsed = models.UploadStatusUsed + UploadStatusDeleted = models.UploadStatusDeleted +) diff --git a/backend/plugins/domain/upload/models/models.go b/backend/plugins/domain/upload/models/models.go new file mode 100644 index 00000000..37a6624b --- /dev/null +++ b/backend/plugins/domain/upload/models/models.go @@ -0,0 +1,76 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package models 提供上传域核心数据模型。 +package models + +import ( + "time" +) + +// UploadStatus 上传状态 +type UploadStatus string + +// 上传状态 +const ( + UploadStatusPending UploadStatus = "pending" // 待使用 + UploadStatusUsed UploadStatus = "used" // 已使用 + UploadStatusDeleted UploadStatus = "deleted" // 已删除 +) + +// UploadMetadata 自定义可扩展的 JSON 字段存储非核心或可选的文件元数据 +type UploadMetadata struct { + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + Duration float64 `json:"duration,omitempty"` + OriginalMime string `json:"original_mime,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + ClientIP string `json:"client_ip,omitempty"` + Bucket string `json:"bucket,omitempty"` + Extra map[string]any `json:"extra,omitempty"` +} + +// Upload 上传文件记录 +type Upload struct { + ID uint64 `json:"id,string" gorm:"primaryKey"` + UserID uint64 `json:"user_id,string" gorm:"index;not null"` + FileName string `json:"file_name" gorm:"size:255;not null"` + FilePath string `json:"file_path" gorm:"size:500;not null;index"` + FileSize int64 `json:"file_size" gorm:"not null"` + MimeType string `json:"mime_type" gorm:"size:100;not null"` + Extension string `json:"extension" gorm:"size:50;not null"` + Hash string `json:"hash" gorm:"size:64;index"` + Type string `json:"type" gorm:"column:type;size:50;not null;index"` + Status UploadStatus `json:"status" gorm:"type:varchar(20);not null"` + AccessMode int `json:"access_mode" gorm:"column:access_mode;not null;default:0"` + Metadata UploadMetadata `json:"metadata" gorm:"serializer:json;type:jsonb"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` +} + +// TableName 表名 +func (Upload) TableName() string { + return "w_uploads" +} + +// Upload stats dimension keys stored in w_upload_stats.dimension. +const ( + UploadStatDimensionTotal = "total" + UploadStatDimensionType = "type" + UploadStatDimensionCategory = "category" + UploadStatDimensionTrend = "trend" +) + +// UploadStat 聚合统计记录 +type UploadStat struct { + Dimension string `json:"dimension" gorm:"primaryKey;size:32;not null"` + StatKey string `json:"stat_key" gorm:"primaryKey;size:64;not null;default:''"` + FileCount int64 `json:"file_count" gorm:"not null;default:0"` + FileSize int64 `json:"file_size" gorm:"not null;default:0"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` +} + +// TableName 表名 +func (UploadStat) TableName() string { + return "w_upload_stats" +} diff --git a/backend/plugins/domain/upload/plugin.go b/backend/plugins/domain/upload/plugin.go new file mode 100644 index 00000000..e6b998bb --- /dev/null +++ b/backend/plugins/domain/upload/plugin.go @@ -0,0 +1,193 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package upload provides file uploading, storage abstraction, image transcoding, and caching domain plugin for Cordis. +package upload + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "Wavelet/core/extpoints" + "Wavelet/plugins/domain/upload/filesrv" + "Wavelet/plugins/domain/upload/handler" + "Wavelet/plugins/domain/upload/shared" + "Wavelet/plugins/domain/upload/task" + "context" + "embed" + "reflect" + + "github.com/gin-gonic/gin" +) + +//go:embed migrations/*/*.sql +var uploadMigrations embed.FS + +// Plugin implements core.Plugin to provide file upload and media serving domain services. +type Plugin struct{} + +// New creates a new upload domain plugin. +func New() *Plugin { + return &Plugin{} +} + +// Name returns the unique identifier for the upload domain plugin. +func (p *Plugin) Name() string { + return "upload" +} + +// Inject declares required dependencies for the upload domain plugin. +func (p *Plugin) Inject() []reflect.Type { + return []reflect.Type{ + reflect.TypeFor[contracts.DBService](), + reflect.TypeFor[contracts.StorageService](), + reflect.TypeFor[contracts.AuthService](), + } +} + +// Manifest returns the plugin metadata. +func (p *Plugin) Manifest() core.Manifest { + return core.Manifest{ + Name: "upload", + Version: "1.0.0", + Description: "File upload, secure delivery, image transcoding, and storage management domain plugin", + Author: "Wavelet Team", + } +} + +// Apply registers upload routes, tasks, and settings into the Context. +func (p *Plugin) Apply(ctx *core.Context) error { + // Bind DBService + if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil { + shared.SetDBService(db) + } else { + core.When[contracts.DBService](ctx, func(db contracts.DBService) { + shared.SetDBService(db) + }) + } + + // Bind CacheService + if cache, err := core.Inject[contracts.CacheService](ctx); err == nil && cache != nil { + shared.SetCacheService(cache) + } else { + core.When[contracts.CacheService](ctx, func(cache contracts.CacheService) { + shared.SetCacheService(cache) + }) + } + + // Bind StorageService + if storage, err := core.Inject[contracts.StorageService](ctx); err == nil && storage != nil { + shared.SetStorageService(storage) + } else { + core.When[contracts.StorageService](ctx, func(storage contracts.StorageService) { + shared.SetStorageService(storage) + }) + } + + // Bind TaskService + if taskSvc, err := core.Inject[contracts.TaskService](ctx); err == nil && taskSvc != nil { + shared.SetTaskService(taskSvc) + } else { + core.When[contracts.TaskService](ctx, func(taskSvc contracts.TaskService) { + shared.SetTaskService(taskSvc) + }) + } + + // Bind AuthService + if authSvc, err := core.Inject[contracts.AuthService](ctx); err == nil && authSvc != nil { + shared.SetAuthService(authSvc) + } else { + core.When[contracts.AuthService](ctx, func(authSvc contracts.AuthService) { + shared.SetAuthService(authSvc) + }) + } + + ctx.OnDispose(func() error { + shared.ResetServices() + return nil + }) + + // 0. Resolve auth service for middleware + var authSvc contracts.AuthService + if err := core.Using[contracts.AuthService](ctx, func(svc contracts.AuthService) { authSvc = svc }); err != nil { + return err + } + loginMW := authSvc.RequireAuthMiddleware().(gin.HandlerFunc) + + // 0a. Register migrations + ctx.Migrations().Register("upload", uploadMigrations) + + // 1. Register File Server Routes + ctx.Router().GET("/f/:id", filesrv.ServeFileByID) + + // 2. Register User/Admin Upload HTTP Routes + uploadGroup := ctx.Router().Group("/api/v1/upload", loginMW) + { + uploadGroup.POST("", handler.UploadFile) + uploadGroup.GET("", handler.ListFiles) + uploadGroup.DELETE("/:id", handler.DeleteFile) + uploadGroup.POST("/batch-download", handler.BatchDownloadFiles) + } + + adminUploadGroup := ctx.Router().Group("/api/v1/admin/uploads", loginMW) + { + adminUploadGroup.GET("", handler.ListFiles) + adminUploadGroup.GET("/stats", handler.GetFileStats) + adminUploadGroup.DELETE("/:id", handler.DeleteFile) + adminUploadGroup.GET("/download/:id", handler.DownloadFile) + adminUploadGroup.POST("/download/batch", handler.BatchDownloadFiles) + adminUploadGroup.GET("/types", handler.GetDistinctUploadTypes) + } + + const ( + defaultCleanupRetry = 3 + defaultStatsRetry = 2 + defaultSingleRetry = 1 + ) + + // 3. Register tasks. Handlers take raw payload bytes rather than a driver + // specific task type so they run under both the asynq and in-process workers. + cleanupHandler := &task.SystemCleanupHandler{} + ctx.Task().Register(task.SystemCleanupTask, func(c context.Context, payload []byte) error { + _, err := cleanupHandler.Execute(c, payload) + return err + }, extpoints.WithTaskMeta(task.SystemCleanupMeta), extpoints.WithTaskRetry(defaultCleanupRetry)) + + rebuildStatsHandler := &task.RebuildUploadStatsHandler{} + ctx.Task().Register(task.RebuildUploadStatsTask, func(c context.Context, payload []byte) error { + _, err := rebuildStatsHandler.Execute(c, payload) + return err + }, extpoints.WithTaskMeta(task.RebuildUploadStatsMeta), extpoints.WithTaskRetry(defaultStatsRetry)) + + migrationHandler := &task.MigrationHandler{} + ctx.Task().Register(task.StorageMigrationTask, func(c context.Context, payload []byte) error { + _, err := migrationHandler.Execute(c, payload) + return err + }, extpoints.WithTaskMeta(task.StorageMigrationMeta), extpoints.WithTaskRetry(defaultSingleRetry)) + + warmHandler := &task.WarmImageCacheHandler{} + ctx.Task().Register(task.WarmImageCacheTask, func(c context.Context, payload []byte) error { + _, err := warmHandler.Execute(c, payload) + return err + }, extpoints.WithTaskMeta(task.WarmImageCacheMeta), extpoints.WithTaskRetry(1)) + + // 4. Register Cron Schedule + ctx.Schedule().RegisterCron("0 3 * * *", task.SystemCleanupTask, nil) + + // 5. Register Settings Schemas + ctx.Settings().Register(extpoints.SettingSchema{ + Key: "upload.max_file_size_mb", + Default: 100, + Description: "Maximum upload file size limit in MB", + Type: "integer", + Category: "storage", + }) + ctx.Settings().Register(extpoints.SettingSchema{ + Key: "upload.allowed_extensions", + Default: "png,jpg,jpeg,gif,webp,svg,pdf,zip,tar,gz", + Description: "Allowed upload file extensions separated by commas", + Type: "string", + Category: "storage", + }) + + return nil +} diff --git a/backend/plugins/domain/upload/repository.go b/backend/plugins/domain/upload/repository.go new file mode 100644 index 00000000..e772410f --- /dev/null +++ b/backend/plugins/domain/upload/repository.go @@ -0,0 +1,146 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package upload + +import ( + "Wavelet/pkg/idgen" + "Wavelet/pkg/util" + "Wavelet/plugins/domain/upload/shared" + "context" + "strings" + + "gorm.io/gorm" +) + +// UploadListFilter filters paginated upload queries. +// +//nolint:revive +type UploadListFilter struct { + UserID uint64 + Keyword string + Type string + Extension string + Page int + PageSize int +} + +// ListUploads returns paginated upload records matching the filter. +func ListUploads(ctx context.Context, filter UploadListFilter) (int64, []Upload, error) { + query := shared.GetDB(ctx).Model(&Upload{}). + Where("status != ?", UploadStatusDeleted) + + if filter.UserID != 0 { + query = query.Where("user_id = ?", filter.UserID) + } + if filter.Keyword != "" { + query = query.Where("LOWER(file_name) LIKE ? ESCAPE '\\'", "%"+util.EscapeLike(strings.ToLower(filter.Keyword))+"%") + } + if filter.Type != "" { + query = query.Where("type = ?", filter.Type) + } + if filter.Extension != "" { + query = query.Where("extension = ?", strings.ToLower(filter.Extension)) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return 0, nil, err + } + + var items []Upload + offset := (filter.Page - 1) * filter.PageSize + if err := query.Order("created_at DESC").Offset(offset).Limit(filter.PageSize).Find(&items).Error; err != nil { + return 0, nil, err + } + return total, items, nil +} + +// GetActiveUploadByID loads a non-deleted upload by ID. +func GetActiveUploadByID(ctx context.Context, id uint64) (Upload, error) { + var upload Upload + if err := shared.GetDB(ctx).Where("id = ? AND status != ?", id, UploadStatusDeleted).First(&upload).Error; err != nil { + return Upload{}, err + } + return upload, nil +} + +// SoftDeleteUpload marks an upload as deleted. +// External modules must use upload.Remove or upload.RemoveOwned; only internal/apps/upload may call this. +func SoftDeleteUpload(ctx context.Context, upload *Upload) error { + return SoftDeleteUploadTx(shared.GetDB(ctx), upload) +} + +// SoftDeleteUploadTx marks an upload as deleted within an existing transaction. +func SoftDeleteUploadTx(tx *gorm.DB, upload *Upload) error { + return tx.Model(upload).Update("status", UploadStatusDeleted).Error +} + +// UpdateUpload applies partial field updates to an upload record. +func UpdateUpload(ctx context.Context, upload *Upload, updates map[string]any) error { + if len(updates) == 0 { + return nil + } + return shared.GetDB(ctx).Model(upload).Updates(updates).Error +} + +// ListDistinctUploadTypes returns all distinct non-empty upload business types. +func ListDistinctUploadTypes(ctx context.Context) ([]string, error) { + var types []string + if err := shared.GetDB(ctx).Model(&Upload{}). + Where("type IS NOT NULL AND type != ''"). + Distinct(). + Pluck("type", &types).Error; err != nil { + return nil, err + } + return types, nil +} + +// FindReusableUploadByHash finds an existing upload with the same hash and size. +func FindReusableUploadByHash(ctx context.Context, hash string, size int64) (Upload, error) { + var existing Upload + err := shared.GetDB(ctx). + Where("hash = ? AND file_size = ? AND status IN (?, ?)", hash, size, UploadStatusPending, UploadStatusUsed). + First(&existing).Error + return existing, err +} + +// CreateUpload persists a new upload record. +func CreateUpload(ctx context.Context, upload *Upload) error { + return CreateUploadTx(shared.GetDB(ctx), upload) +} + +// CreateUploadTx persists a new upload record within an existing transaction. +func CreateUploadTx(tx *gorm.DB, upload *Upload) error { + if upload.ID == 0 { + upload.ID = idgen.NextUint64ID() + } + return tx.Create(upload).Error +} + +// ListUploadsByIDs returns active uploads matching the given IDs. +func ListUploadsByIDs(ctx context.Context, ids []uint64) ([]Upload, error) { + var uploads []Upload + if err := shared.GetDB(ctx). + Where("id IN ? AND status IN (?, ?)", ids, UploadStatusPending, UploadStatusUsed). + Find(&uploads).Error; err != nil { + return nil, err + } + return uploads, nil +} + +// UploadQuery returns a scoped GORM query for uploads. +// +//nolint:revive +func UploadQuery(ctx context.Context) *gorm.DB { + return shared.GetDB(ctx).Model(&Upload{}) +} + +// ListUploadStats returns all upload statistics rows. +func ListUploadStats(ctx context.Context) ([]UploadStat, error) { + var stats []UploadStat + if err := shared.GetDB(ctx).Find(&stats).Error; err != nil { + return nil, err + } + return stats, nil +} diff --git a/backend/plugins/domain/upload/repository/repository.go b/backend/plugins/domain/upload/repository/repository.go new file mode 100644 index 00000000..ac1b22c4 --- /dev/null +++ b/backend/plugins/domain/upload/repository/repository.go @@ -0,0 +1,146 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package repository 提供上传域数据库仓储层操作。 +package repository + +import ( + "Wavelet/pkg/util" + "Wavelet/plugins/domain/upload/models" + "Wavelet/plugins/domain/upload/shared" + "context" + "errors" + "strings" + + "gorm.io/gorm" +) + +// UploadListFilter filters paginated upload queries. +type UploadListFilter struct { + UserID uint64 + Keyword string + Type string + Extension string + Page int + PageSize int +} + +// ListUploads returns paginated upload records matching the filter. +func ListUploads(ctx context.Context, filter UploadListFilter) (int64, []models.Upload, error) { + query := shared.GetDB(ctx).Model(&models.Upload{}). + Where("status != ?", models.UploadStatusDeleted) + + if filter.UserID != 0 { + query = query.Where("user_id = ?", filter.UserID) + } + if filter.Keyword != "" { + query = query.Where("LOWER(file_name) LIKE ? ESCAPE '\\'", "%"+util.EscapeLike(strings.ToLower(filter.Keyword))+"%") + } + if filter.Type != "" { + query = query.Where("type = ?", filter.Type) + } + if filter.Extension != "" { + query = query.Where("extension = ?", strings.ToLower(filter.Extension)) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return 0, nil, err + } + + var items []models.Upload + offset := (filter.Page - 1) * filter.PageSize + if err := query.Order("created_at DESC").Offset(offset).Limit(filter.PageSize).Find(&items).Error; err != nil { + return 0, nil, err + } + return total, items, nil +} + +// GetActiveUploadByID loads a non-deleted upload by ID. +func GetActiveUploadByID(ctx context.Context, id uint64) (models.Upload, error) { + var upload models.Upload + if err := shared.GetDB(ctx).Where("id = ? AND status != ?", id, models.UploadStatusDeleted).First(&upload).Error; err != nil { + return models.Upload{}, err + } + return upload, nil +} + +// SoftDeleteUpload marks an upload as deleted. +func SoftDeleteUpload(ctx context.Context, upload *models.Upload) error { + return SoftDeleteUploadTx(shared.GetDB(ctx), upload) +} + +// SoftDeleteUploadTx marks an upload as deleted within an existing transaction. +func SoftDeleteUploadTx(tx *gorm.DB, upload *models.Upload) error { + return tx.Model(upload).Update("status", models.UploadStatusDeleted).Error +} + +// UpdateUpload applies partial field updates to an upload record. +func UpdateUpload(ctx context.Context, upload *models.Upload, updates map[string]any) error { + if len(updates) == 0 { + return nil + } + return shared.GetDB(ctx).Model(upload).Updates(updates).Error +} + +// ListDistinctUploadTypes returns all distinct non-empty upload business types. +func ListDistinctUploadTypes(ctx context.Context) ([]string, error) { + var types []string + if err := shared.GetDB(ctx).Model(&models.Upload{}). + Where("type IS NOT NULL AND type != ''"). + Distinct(). + Pluck("type", &types).Error; err != nil { + return nil, err + } + return types, nil +} + +// FindReusableUploadByHash finds an existing upload with the same hash and size. +func FindReusableUploadByHash(ctx context.Context, hash string, size int64) (models.Upload, error) { + var existing models.Upload + err := shared.GetDB(ctx). + Where("hash = ? AND file_size = ? AND status IN (?, ?)", hash, size, models.UploadStatusPending, models.UploadStatusUsed). + First(&existing).Error + return existing, err +} + +// CreateUpload persists a new upload record. +func CreateUpload(ctx context.Context, upload *models.Upload) error { + return CreateUploadTx(shared.GetDB(ctx), upload) +} + +// CreateUploadTx persists a new upload record within an existing transaction. +func CreateUploadTx(tx *gorm.DB, upload *models.Upload) error { + return tx.Create(upload).Error +} + +// ListUploadsByIDs returns active uploads matching the given IDs. +func ListUploadsByIDs(ctx context.Context, ids []uint64) ([]models.Upload, error) { + var uploads []models.Upload + if err := shared.GetDB(ctx). + Where("id IN ? AND status IN (?, ?)", ids, models.UploadStatusPending, models.UploadStatusUsed). + Find(&uploads).Error; err != nil { + return nil, err + } + return uploads, nil +} + +// UploadQuery returns a scoped GORM query for uploads. +func UploadQuery(ctx context.Context) *gorm.DB { + return shared.GetDB(ctx).Model(&models.Upload{}) +} + +// ListUploadStats returns all upload statistics rows. +func ListUploadStats(ctx context.Context) ([]models.UploadStat, error) { + var stats []models.UploadStat + if err := shared.GetDB(ctx).Find(&stats).Error; err != nil { + return nil, err + } + return stats, nil +} + +// IsRecordNotFound reports whether err is the persistence "record not found" sentinel. +// Upper layers must use this helper instead of importing gorm directly. +func IsRecordNotFound(err error) bool { + return errors.Is(err, gorm.ErrRecordNotFound) +} diff --git a/backend/plugins/domain/upload/shared/constants.go b/backend/plugins/domain/upload/shared/constants.go new file mode 100644 index 00000000..1a1a7466 --- /dev/null +++ b/backend/plugins/domain/upload/shared/constants.go @@ -0,0 +1,25 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package shared + +// Upload size, path, media quality, and cache constants shared across subpackages. +const ( + MaxUploadSize = 32 * 1024 * 1024 // 32MB + DetectContentBytes = 512 // http.DetectContentType 需要的最小字节数 + UploadDirPerm = 0o755 // 上传目录权限 + UploadFilePerm = 0o644 // 上传文件权限 + ImageQualityLow = "low" + ImageQualityMedium = "medium" + ImageQualityHigh = "high" + ImageQualityOrigin = "origin" + DefaultPublicUploadType = "avatar" + FileStatsTrendDays = 7 + MaxS3KeyLength = 1024 + AccessCacheTTL = 5 // seconds; multiplied by time.Second at use site + + UploadStatDimensionTotal = "total" + UploadStatDimensionType = "type" + UploadStatDimensionCategory = "category" + UploadStatDimensionTrend = "trend" +) diff --git a/backend/plugins/domain/upload/shared/context_services.go b/backend/plugins/domain/upload/shared/context_services.go new file mode 100644 index 00000000..cb6c2415 --- /dev/null +++ b/backend/plugins/domain/upload/shared/context_services.go @@ -0,0 +1,130 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package shared + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "context" + "sync" + + "gorm.io/gorm" +) + +var ( + svcMu sync.RWMutex + dbSvc contracts.DBService + cacheSvc contracts.CacheService + storageSvc contracts.StorageService + taskSvc contracts.TaskService + authSvc contracts.AuthService +) + +// SetDBService configures the DBService. +func SetDBService(s contracts.DBService) { + svcMu.Lock() + defer svcMu.Unlock() + dbSvc = s +} + +// SetCacheService configures the CacheService. +func SetCacheService(s contracts.CacheService) { + svcMu.Lock() + defer svcMu.Unlock() + cacheSvc = s +} + +// SetStorageService configures the StorageService. +func SetStorageService(s contracts.StorageService) { + svcMu.Lock() + defer svcMu.Unlock() + storageSvc = s +} + +// SetTaskService configures the TaskService. +func SetTaskService(s contracts.TaskService) { + svcMu.Lock() + defer svcMu.Unlock() + taskSvc = s +} + +// SetAuthService configures the AuthService. +func SetAuthService(s contracts.AuthService) { + svcMu.Lock() + defer svcMu.Unlock() + authSvc = s +} + +// ResetServices clears all injected services. +func ResetServices() { + svcMu.Lock() + defer svcMu.Unlock() + dbSvc = nil + cacheSvc = nil + storageSvc = nil + taskSvc = nil + authSvc = nil +} + +// GetDB resolves the GORM DB instance. +func GetDB(ctx context.Context) *gorm.DB { + if c, ok := ctx.(*core.Context); ok && c != nil { + if s, err := core.Inject[contracts.DBService](c); err == nil && s != nil { + return s.DB(ctx) + } + } + svcMu.RLock() + s := dbSvc + svcMu.RUnlock() + if s != nil { + return s.DB(ctx) + } + return nil +} + +// GetCache resolves the CacheService instance. +func GetCache(ctx context.Context) contracts.CacheService { + if c, ok := ctx.(*core.Context); ok && c != nil { + if s, err := core.Inject[contracts.CacheService](c); err == nil && s != nil { + return s + } + } + svcMu.RLock() + s := cacheSvc + svcMu.RUnlock() + return s +} + +// GetStorage resolves the StorageService instance. +func GetStorage(ctx context.Context) contracts.StorageService { + if c, ok := ctx.(*core.Context); ok && c != nil { + if s, err := core.Inject[contracts.StorageService](c); err == nil && s != nil { + return s + } + } + svcMu.RLock() + s := storageSvc + svcMu.RUnlock() + return s +} + +// GetTaskService resolves the TaskService instance. +func GetTaskService() contracts.TaskService { + svcMu.RLock() + defer svcMu.RUnlock() + return taskSvc +} + +// GetAuthService resolves the AuthService instance. +func GetAuthService(ctx context.Context) contracts.AuthService { + if c, ok := ctx.(*core.Context); ok && c != nil { + if s, err := core.Inject[contracts.AuthService](c); err == nil && s != nil { + return s + } + } + svcMu.RLock() + s := authSvc + svcMu.RUnlock() + return s +} diff --git a/backend/plugins/domain/upload/shared/errs.go b/backend/plugins/domain/upload/shared/errs.go new file mode 100644 index 00000000..4849bd27 --- /dev/null +++ b/backend/plugins/domain/upload/shared/errs.go @@ -0,0 +1,48 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package shared holds upload error and configuration constants shared across subpackages. +package shared + +// 文件管理常量 +const ( + ErrNoFileSelected = "请选择要上传的文件" + ErrUnsupportedFormat = "只支持 JPG、PNG、WEBP 格式的图片" + ErrProcessFileFailed = "处理文件失败" + ErrSaveFileFailed = "保存文件失败" + ErrOpenFileFailed = "打开文件失败" + ErrSaveUploadRecordFailed = "保存上传记录失败" + ErrGenericFileTooLarge = "文件大小不能超过 32MB" + ErrFileContentExtensionMismatch = "文件内容与扩展名不匹配,可能包含安全风险" + ErrFileValidationFailed = "文件校验失败" + ErrInvalidMetadataJSON = "元数据 JSON 格式不合法" + ErrInvalidFileID = "无效的文件 ID" + ErrQueryUploadRecordFailed = "查询文件记录失败" + ErrInvalidBatchDownloadRequest = "参数绑定失败,请传入有效的文件 ID 数组" + ErrInvalidIDValueFormat = "无效的 ID 值: %s" + ErrRetrieveUploadRecordsFailed = "检索文件记录失败" + ErrNoValidFilesForArchive = "没有找到任何有效的文件记录进行打包" + ErrInvalidParams = "参数错误" + ErrQueryFileCountFailed = "查询文件数量失败" + ErrQueryFileListFailed = "查询文件列表失败" + ErrDeleteFileFailed = "删除文件失败" + ErrStorageReadOnly = "存储迁移维护中,当前仅允许读取文件" + ErrS3KeyRequired = "s3 key must not be empty" + ErrS3KeyTooLongFormat = "s3 key exceeds maximum length of %d" + ErrS3KeyStartsWithSlash = "s3 key must not start with /" + ErrS3KeyContainsNullBytes = "s3 key must not contain null bytes" + ErrQueryUnusedUploadsFailed = "查询未使用的上传文件失败: %w" + ErrImageCacheWarmupPayloadRequired = "图片缓存预热参数不能为空" + ErrInvalidImageCacheWarmupPayload = "图片缓存预热参数格式无效: %w" + ErrInvalidImageCacheWarmupQuality = "图片质量仅支持 low、medium、high" + ErrParseImageCacheWarmupPayload = "解析图片缓存预热参数失败: %w" + ErrQueryImagesForCacheWarmup = "查询待预热图片失败: %w" + ErrQueryTypeListFailed = "查询文件类型列表失败" + ErrUpdateFileFailed = "更新文件失败" + ErrUnauthorized = "未登录" + ErrFileRecordNotFound = "文件记录未找到" + ErrFileNotFound = "文件未找到" + ErrOperationForbidden = "无权操作" + ErrInvalidAccessModeParam = "无效的 access_mode 参数" + ErrInternalServerError = "服务器内部错误" +) diff --git a/backend/plugins/domain/upload/shared/test_helpers.go b/backend/plugins/domain/upload/shared/test_helpers.go new file mode 100644 index 00000000..df60cbc0 --- /dev/null +++ b/backend/plugins/domain/upload/shared/test_helpers.go @@ -0,0 +1,299 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package shared + +import ( + "Wavelet/core/contracts" + "Wavelet/pkg/testhelper" + "bytes" + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + "sync" + "testing" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// MockDBService is a mock implementation of contracts.DBService for unit testing. +type MockDBService struct { + DBInstance *gorm.DB +} + +// GORM returns the underlying GORM instance. +func (m *MockDBService) GORM() *gorm.DB { + return m.DBInstance +} + +// DB returns the GORM instance bound to context. +func (m *MockDBService) DB(ctx context.Context) *gorm.DB { + return m.DBInstance.WithContext(ctx) +} + +// Named returns the named GORM instance. +func (m *MockDBService) Named(_ string) *gorm.DB { + return m.DBInstance +} + +// MockCacheService is an in-memory mock implementation of contracts.CacheService for unit testing. +type MockCacheService struct { + mu sync.RWMutex + data map[string][]byte +} + +// NewMockCacheService creates a new MockCacheService. +func NewMockCacheService() *MockCacheService { + return &MockCacheService{ + data: make(map[string][]byte), + } +} + +// Get retrieves a cached value. +func (m *MockCacheService) Get(_ context.Context, key string, val any) error { + m.mu.RLock() + defer m.mu.RUnlock() + b, ok := m.data[key] + if !ok { + return contracts.ErrCacheMiss + } + return json.Unmarshal(b, val) +} + +// Set stores a key-value pair in cache. +func (m *MockCacheService) Set(_ context.Context, key string, val any, _ time.Duration) error { + m.mu.Lock() + defer m.mu.Unlock() + b, err := json.Marshal(val) + if err != nil { + return err + } + m.data[key] = b + return nil +} + +// Delete removes a key from cache. +func (m *MockCacheService) Delete(_ context.Context, key string) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.data, key) + return nil +} + +// GetOrSet retrieves or populates a cache entry. +func (m *MockCacheService) GetOrSet(ctx context.Context, key string, target any, ttl time.Duration, loader func() (any, error)) error { + err := m.Get(ctx, key, target) + if err == nil { + return nil + } + val, err := loader() + if err != nil { + return err + } + return m.Set(ctx, key, val, ttl) +} + +// Invalidate invalidates a cache tag or prefix. +func (m *MockCacheService) Invalidate(_ context.Context, _ string) error { + return nil +} + +// MockStorageService is an in-memory mock implementation of contracts.StorageService for unit testing. +type MockStorageService struct { + mu sync.RWMutex + objects map[string][]byte +} + +// NewMockStorageService creates a new MockStorageService. +func NewMockStorageService() *MockStorageService { + return &MockStorageService{ + objects: make(map[string][]byte), + } +} + +// Put uploads an object into mock storage. +func (m *MockStorageService) Put(_ context.Context, key string, body io.Reader, _ int64, _ string) (contracts.StoragePutResult, error) { + m.mu.Lock() + defer m.mu.Unlock() + data, err := io.ReadAll(body) + if err != nil { + return contracts.StoragePutResult{}, err + } + m.objects[key] = data + return contracts.StoragePutResult{Key: key, Bucket: "test-bucket"}, nil +} + +// PutRaw seeds an object directly into the in-memory store (test helper, no I/O). +func (m *MockStorageService) PutRaw(key string, data []byte) { + m.mu.Lock() + defer m.mu.Unlock() + m.objects[key] = data +} + +// Get retrieves an object from mock storage. +func (m *MockStorageService) Get(_ context.Context, key string) (*contracts.StorageObject, error) { + m.mu.RLock() + defer m.mu.RUnlock() + data, ok := m.objects[key] + if ok { + return &contracts.StorageObject{ + Key: key, + Body: io.NopCloser(bytes.NewReader(data)), + ContentLength: int64(len(data)), + ContentType: "application/octet-stream", + }, nil + } + return nil, gorm.ErrRecordNotFound +} + +// Delete removes an object from mock storage. +func (m *MockStorageService) Delete(_ context.Context, key string) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.objects, key) + return nil +} + +// Ingest handles programmatic file ingestion for mock storage. +func (m *MockStorageService) Ingest(_ context.Context, _ io.Reader, _ contracts.IngestOptions) (*contracts.IngestResult, error) { + return &contracts.IngestResult{ID: 1, Key: "test.png", Created: true, Stored: true}, nil +} + +// MockAuthService is a mock implementation of contracts.AuthService for unit testing. +type MockAuthService struct { + DB *gorm.DB +} + +// errMockNotImplemented marks a mock method no test exercises yet, so an +// accidental call reports this instead of dereferencing a nil result. +var errMockNotImplemented = errors.New("mock auth service: not implemented") + +// RequireAuthMiddleware returns a dummy auth middleware. +func (a *MockAuthService) RequireAuthMiddleware() any { + return func(c *gin.Context) { c.Next() } +} + +// RequireAdminMiddleware returns a dummy admin middleware. +func (a *MockAuthService) RequireAdminMiddleware() any { + return func(c *gin.Context) { c.Next() } +} + +// DisallowTokenAuthMiddleware returns a dummy disallow token middleware. +func (a *MockAuthService) DisallowTokenAuthMiddleware() any { + return func(c *gin.Context) { c.Next() } +} + +// GetCurrentUser returns the user associated with the request context. +func (a *MockAuthService) GetCurrentUser(ctx context.Context) (*contracts.UserDTO, error) { + if c, ok := ctx.(*gin.Context); ok { + authHeader := c.GetHeader("Authorization") + if strings.HasPrefix(authHeader, "Bearer ") { + tokenStr := strings.TrimPrefix(authHeader, "Bearer ") + tokenHash := fmt.Sprintf("%x", sha256.Sum256([]byte(tokenStr))) + var tokenRecord struct { + UserID uint64 + } + if err := a.DB.Table("w_access_tokens").Where("token_hash = ?", tokenHash).First(&tokenRecord).Error; err == nil { + return &contracts.UserDTO{ID: tokenRecord.UserID, IsActive: true}, nil + } + } + } + return nil, errors.New("unauthorized") +} + +// GetCurrentUserID returns the current user ID. +func (a *MockAuthService) GetCurrentUserID(ctx context.Context) (uint64, error) { + u, err := a.GetCurrentUser(ctx) + if err != nil { + return 0, err + } + return u.ID, nil +} + +// VerifyToken verifies an access token. +func (a *MockAuthService) VerifyToken(_ context.Context, token string) (*contracts.UserDTO, error) { + tokenHash := fmt.Sprintf("%x", sha256.Sum256([]byte(token))) + var tokenRecord struct { + UserID uint64 + } + if err := a.DB.Table("w_access_tokens").Where("token_hash = ?", tokenHash).First(&tokenRecord).Error; err == nil { + return &contracts.UserDTO{ID: tokenRecord.UserID, IsActive: true}, nil + } + return nil, errors.New("unauthorized") +} + +// Authenticate verifies credentials. +func (a *MockAuthService) Authenticate(_ context.Context, _, _ string) (*contracts.UserDTO, error) { + return nil, errMockNotImplemented +} + +// CreateSession creates a login session. +func (a *MockAuthService) CreateSession(_ context.Context, _ uint64, _ map[string]any) (string, error) { + return "test-session", nil +} + +// RevokeToken revokes an access token. +func (a *MockAuthService) RevokeToken(_ context.Context, _ string) error { + return nil +} + +// RevokeUserSessions revokes all sessions for a user. +func (a *MockAuthService) RevokeUserSessions(_ context.Context, _ uint64) error { + return nil +} + +// InvalidateCachedUser invalidates cached user profile. +func (a *MockAuthService) InvalidateCachedUser(_ context.Context, _ uint64) {} + +// InvalidateCachedToken invalidates cached access token. +func (a *MockAuthService) InvalidateCachedToken(_ context.Context, _ string) {} + +// ListAuthSources lists configured authentication sources. +func (a *MockAuthService) ListAuthSources(_ context.Context) ([]contracts.AuthSourceViewDTO, error) { + return nil, nil +} + +// CreateAuthSource creates an authentication source. +func (a *MockAuthService) CreateAuthSource(_ context.Context, _ contracts.AuthSourceDTO) (*contracts.AuthSourceDTO, error) { + return nil, errMockNotImplemented +} + +// UpdateAuthSource updates an authentication source. +func (a *MockAuthService) UpdateAuthSource(_ context.Context, _ uint64, _ contracts.AuthSourceDTO) (*contracts.AuthSourceDTO, error) { + return nil, errMockNotImplemented +} + +// DeleteAuthSource deletes an authentication source. +func (a *MockAuthService) DeleteAuthSource(_ context.Context, _ uint64) error { + return nil +} + +// ToggleAuthSource toggles an authentication source active state. +func (a *MockAuthService) ToggleAuthSource(_ context.Context, _ uint64) (*contracts.AuthSourceDTO, error) { + return nil, errMockNotImplemented +} + +// SetupTestEnv initializes test helper environment and binds DB, Cache, Storage, Auth mocks to shared services. +func SetupTestEnv(t *testing.T) (*gorm.DB, func()) { + dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) + dbSvc := &MockDBService{DBInstance: dbConn} + cacheSvc := NewMockCacheService() + storageSvc := NewMockStorageService() + authSvc := &MockAuthService{DB: dbConn} + + SetDBService(dbSvc) + SetCacheService(cacheSvc) + SetStorageService(storageSvc) + SetAuthService(authSvc) + + return dbConn, func() { + ResetServices() + cleanup() + } +} diff --git a/backend/plugins/domain/upload/stats/category.go b/backend/plugins/domain/upload/stats/category.go new file mode 100644 index 00000000..9e38c128 --- /dev/null +++ b/backend/plugins/domain/upload/stats/category.go @@ -0,0 +1,42 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package stats maintains incremental upload statistics and aggregations. +package stats + +import ( + "Wavelet/plugins/domain/upload/util" + "strings" +) + +const ( + catImage = "图片" + catVideo = "视频" + catAudio = "音频" + catDocument = "文档" + catArchive = "压缩包" + catOther = "其他" +) + +// GetFileCategory classifies a file by mime type and extension. +func GetFileCategory(mimeType, ext string) string { + mimeType = strings.ToLower(mimeType) + ext = strings.ToLower(ext) + + if strings.HasPrefix(mimeType, "image/") || util.IsImageExtension(ext) { + return catImage + } + if strings.HasPrefix(mimeType, "video/") { + return catVideo + } + if strings.HasPrefix(mimeType, "audio/") { + return catAudio + } + if util.IsArchiveExtension(ext) || strings.Contains(mimeType, "zip") || strings.Contains(mimeType, "tar") || strings.Contains(mimeType, "gzip") { + return catArchive + } + if util.IsDocumentExtension(ext) || strings.HasPrefix(mimeType, "text/") || mimeType == "application/pdf" { + return catDocument + } + return catOther +} diff --git a/backend/plugins/domain/upload/stats/stats_counter.go b/backend/plugins/domain/upload/stats/stats_counter.go new file mode 100644 index 00000000..c312fd30 --- /dev/null +++ b/backend/plugins/domain/upload/stats/stats_counter.go @@ -0,0 +1,131 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package stats + +import ( + "Wavelet/pkg/logger" + "Wavelet/plugins/domain/upload/models" + "Wavelet/plugins/domain/upload/shared" + "context" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// ApplyUploadStatsAdd increments incremental stats for a newly active upload record. +func ApplyUploadStatsAdd(ctx context.Context, upload *models.Upload) error { + return applyUploadStatsDelta(ctx, upload, 1) +} + +// ApplyUploadStatsRemove decrements incremental stats for a removed active upload record. +func ApplyUploadStatsRemove(ctx context.Context, upload *models.Upload) error { + return applyUploadStatsDelta(ctx, upload, -1) +} + +// RebuildUploadStats rebuilds all incremental stats from current upload records. +func RebuildUploadStats(ctx context.Context) error { + return shared.GetDB(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("1 = 1").Delete(&models.UploadStat{}).Error; err != nil { + return err + } + + var uploads []models.Upload + if err := tx.Where("status != ?", models.UploadStatusDeleted).Find(&uploads).Error; err != nil { + return err + } + + for i := range uploads { + if err := ApplyUploadStatsDeltaTx(tx, &uploads[i], 1); err != nil { + return err + } + } + return nil + }) +} + +func applyUploadStatsDelta(ctx context.Context, upload *models.Upload, sign int64) error { + if upload == nil || !isActiveUploadStatus(upload.Status) { + return nil + } + return shared.GetDB(ctx).Transaction(func(tx *gorm.DB) error { + return ApplyUploadStatsDeltaTx(tx, upload, sign) + }) +} + +// ApplyUploadStatsDeltaTx applies incremental upload stats within an existing transaction. +func ApplyUploadStatsDeltaTx(tx *gorm.DB, upload *models.Upload, sign int64) error { + if upload == nil || !isActiveUploadStatus(upload.Status) || sign == 0 { + return nil + } + + countDelta := sign + sizeDelta := sign * upload.FileSize + typeKey := upload.Type + if typeKey == "" { + typeKey = "generic" + } + + entries := []struct { + dimension string + key string + }{ + {models.UploadStatDimensionTotal, ""}, + {models.UploadStatDimensionType, typeKey}, + {models.UploadStatDimensionCategory, GetFileCategory(upload.MimeType, upload.Extension)}, + {models.UploadStatDimensionTrend, upload.CreatedAt.Format("2006-01-02")}, + } + + for _, entry := range entries { + if err := upsertUploadStatDelta(tx, entry.dimension, entry.key, countDelta, sizeDelta); err != nil { + return err + } + } + return nil +} + +func upsertUploadStatDelta(tx *gorm.DB, dimension, key string, countDelta, sizeDelta int64) error { + return tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{ + {Name: "dimension"}, + {Name: "stat_key"}, + }, + DoUpdates: clause.Assignments(map[string]any{ + "file_count": gorm.Expr( + "CASE WHEN w_upload_stats.file_count + ? < 0 THEN 0 ELSE w_upload_stats.file_count + ? END", + countDelta, + countDelta, + ), + "file_size": gorm.Expr( + "CASE WHEN w_upload_stats.file_size + ? < 0 THEN 0 ELSE w_upload_stats.file_size + ? END", + sizeDelta, + sizeDelta, + ), + "updated_at": time.Now(), + }), + }).Create(&models.UploadStat{ + Dimension: dimension, + StatKey: key, + FileCount: countDelta, + FileSize: sizeDelta, + }).Error +} + +// RecordUploadStatsAdd logs and applies upload stats increment. +func RecordUploadStatsAdd(ctx context.Context, upload *models.Upload) { + if err := ApplyUploadStatsAdd(ctx, upload); err != nil { + logger.WarnF(ctx, "increment upload stats failed: %v", err) + } +} + +// RecordUploadStatsRemove logs and applies upload stats decrement. +func RecordUploadStatsRemove(ctx context.Context, upload *models.Upload) { + if err := ApplyUploadStatsRemove(ctx, upload); err != nil { + logger.WarnF(ctx, "decrement upload stats failed: %v", err) + } +} + +func isActiveUploadStatus(status models.UploadStatus) bool { + return status == models.UploadStatusPending || status == models.UploadStatusUsed +} diff --git a/backend/plugins/domain/upload/stats/stats_counter_test.go b/backend/plugins/domain/upload/stats/stats_counter_test.go new file mode 100644 index 00000000..d5e58d3e --- /dev/null +++ b/backend/plugins/domain/upload/stats/stats_counter_test.go @@ -0,0 +1,127 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package stats + +import ( + "Wavelet/pkg/testhelper" + "Wavelet/plugins/domain/upload/models" + "Wavelet/plugins/domain/upload/shared" + "context" + "testing" + "time" + + "gorm.io/gorm" +) + +type mockDBService struct { + db *gorm.DB +} + +func (m *mockDBService) GORM() *gorm.DB { + return m.db +} + +func (m *mockDBService) DB(ctx context.Context) *gorm.DB { + return m.db.WithContext(ctx) +} + +func (m *mockDBService) Named(_ string) *gorm.DB { + return m.db +} + +func TestApplyUploadStatsDeltaTxWithinTransaction(t *testing.T) { + dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) + shared.SetDBService(&mockDBService{db: dbConn}) + defer func() { + shared.SetDBService(nil) + cleanup() + }() + ctx := context.Background() + + upload := &models.Upload{ + ID: 42002, + FileSize: 256, + MimeType: "image/jpeg", + Extension: "jpg", + Type: "avatar", + Status: models.UploadStatusUsed, + CreatedAt: time.Now(), + } + + if err := shared.GetDB(ctx).Transaction(func(tx *gorm.DB) error { + return ApplyUploadStatsDeltaTx(tx, upload, 1) + }); err != nil { + t.Fatalf("ApplyUploadStatsDeltaTx returned error: %v", err) + } + + stats, err := loadUploadStats(ctx) + if err != nil { + t.Fatalf("loadUploadStats returned error: %v", err) + } + if stats.TotalCount != 1 || stats.TotalSize != 256 { + t.Fatalf("unexpected total stats: count=%d size=%d", stats.TotalCount, stats.TotalSize) + } +} + +func TestApplyUploadStatsAddAndRemove(t *testing.T) { + dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) + shared.SetDBService(&mockDBService{db: dbConn}) + defer func() { + shared.SetDBService(nil) + cleanup() + }() + ctx := context.Background() + + upload := &models.Upload{ + ID: 42001, + FileSize: 128, + MimeType: "image/png", + Extension: "png", + Type: "avatar", + Status: models.UploadStatusUsed, + CreatedAt: time.Now(), + } + if err := ApplyUploadStatsAdd(ctx, upload); err != nil { + t.Fatalf("ApplyUploadStatsAdd returned error: %v", err) + } + + stats, err := loadUploadStats(ctx) + if err != nil { + t.Fatalf("loadUploadStats returned error: %v", err) + } + if stats.TotalCount != 1 || stats.TotalSize != 128 { + t.Fatalf("unexpected total stats: count=%d size=%d", stats.TotalCount, stats.TotalSize) + } + + if err := ApplyUploadStatsRemove(ctx, upload); err != nil { + t.Fatalf("ApplyUploadStatsRemove returned error: %v", err) + } + + stats, err = loadUploadStats(ctx) + if err != nil { + t.Fatalf("loadUploadStats after remove returned error: %v", err) + } + if stats.TotalCount != 0 || stats.TotalSize != 0 { + t.Fatalf("expected zeroed total stats, got count=%d size=%d", stats.TotalCount, stats.TotalSize) + } +} + +type uploadStatsSnapshot struct { + TotalCount int64 + TotalSize int64 +} + +func loadUploadStats(ctx context.Context) (uploadStatsSnapshot, error) { + var rows []models.UploadStat + if err := shared.GetDB(ctx).Where("dimension = ?", models.UploadStatDimensionTotal).Find(&rows).Error; err != nil { + return uploadStatsSnapshot{}, err + } + if len(rows) == 0 { + return uploadStatsSnapshot{}, nil + } + return uploadStatsSnapshot{ + TotalCount: rows[0].FileCount, + TotalSize: rows[0].FileSize, + }, nil +} diff --git a/backend/plugins/domain/upload/storage/access_state.go b/backend/plugins/domain/upload/storage/access_state.go new file mode 100644 index 00000000..44526924 --- /dev/null +++ b/backend/plugins/domain/upload/storage/access_state.go @@ -0,0 +1,86 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package storage provides upload storage backend operations and migration state. +package storage + +import ( + "Wavelet/core/contracts" + "Wavelet/plugins/domain/upload/shared" + "context" + "sync" + "time" +) + +// MigrationAccessState captures cached migration maintenance state. +type MigrationAccessState struct { + ReadOnly bool + Target contracts.StorageConfigDTO + HasTarget bool + TargetErr error + LoadErr error +} + +var ( + migrationAccessMu sync.RWMutex + migrationAccessCached MigrationAccessState + migrationAccessValid bool + migrationAccessCheckedAt time.Time +) + +// ResetMigrationAccessCache clears the in-process migration access cache. +func ResetMigrationAccessCache() { + migrationAccessMu.Lock() + migrationAccessValid = false + migrationAccessMu.Unlock() +} + +// LoadMigrationAccessState returns cached migration maintenance state. +func LoadMigrationAccessState(ctx context.Context) MigrationAccessState { + migrationAccessMu.RLock() + if migrationAccessValid && time.Since(migrationAccessCheckedAt) < time.Duration(shared.AccessCacheTTL)*time.Second { + state := migrationAccessCached + migrationAccessMu.RUnlock() + return state + } + migrationAccessMu.RUnlock() + + migrationAccessMu.Lock() + defer migrationAccessMu.Unlock() + + if migrationAccessValid && time.Since(migrationAccessCheckedAt) < time.Duration(shared.AccessCacheTTL)*time.Second { + return migrationAccessCached + } + + migrationAccessCached = buildMigrationAccessState(ctx) + migrationAccessValid = true + migrationAccessCheckedAt = time.Now() + return migrationAccessCached +} + +func buildMigrationAccessState(ctx context.Context) MigrationAccessState { + execution, ok, err := LatestMigrationExecution(ctx) + if err != nil { + return MigrationAccessState{LoadErr: err, ReadOnly: true} + } + if !ok || execution == nil { + return MigrationAccessState{} + } + + state := MigrationAccessState{ + ReadOnly: execution.Status != "succeeded", + } + if execution.Status == "succeeded" { + return state + } + + target, err := ParseMigrationTargetConfig(ctx, []byte(execution.Payload)) + if err != nil { + state.TargetErr = err + return state + } + + state.Target = target + state.HasTarget = true + return state +} diff --git a/backend/plugins/domain/upload/storage/migration.go b/backend/plugins/domain/upload/storage/migration.go new file mode 100644 index 00000000..65a4aeda --- /dev/null +++ b/backend/plugins/domain/upload/storage/migration.go @@ -0,0 +1,116 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package storage + +import ( + "Wavelet/core/contracts" + "Wavelet/plugins/domain/upload/shared" + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "gorm.io/gorm" +) + +// StorageMigrationTask is the task name for storage migration. +const StorageMigrationTask = "storage:migrate" + +// LatestMigrationExecution returns the most recent storage migration task execution. +func LatestMigrationExecution(ctx context.Context) (*contracts.TaskExecutionDTO, bool, error) { + db := shared.GetDB(ctx) + if db == nil { + return nil, false, nil + } + var exec contracts.TaskExecutionDTO + err := db.Table("w_task_executions").Where("task_type = ?", StorageMigrationTask).Order("id DESC").First(&exec).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, false, nil + } + return nil, false, err + } + return &exec, true, nil +} + +// ParseMigrationTargetConfig parses and validates a storage migration target payload. +func ParseMigrationTargetConfig(_ context.Context, payload []byte) (contracts.StorageConfigDTO, error) { + if strings.TrimSpace(string(payload)) == "" { + return contracts.StorageConfigDTO{}, errors.New("storage migration target payload is required") + } + + var raw struct { + Target json.RawMessage `json:"target"` + } + if err := json.Unmarshal(payload, &raw); err != nil { + return contracts.StorageConfigDTO{}, fmt.Errorf("parse storage migration payload envelope: %w", err) + } + + if len(raw.Target) == 0 { + return contracts.StorageConfigDTO{}, errors.New("storage migration target payload is required") + } + + var targetBytes []byte + var targetStr string + if err := json.Unmarshal(raw.Target, &targetStr); err == nil { + targetBytes = []byte(targetStr) + } else { + targetBytes = raw.Target + } + + var target contracts.StorageConfigDTO + if err := json.Unmarshal(targetBytes, &target); err != nil { + return contracts.StorageConfigDTO{}, fmt.Errorf("parse target storage config: %w", err) + } + + return target, nil +} + +// NormalizeMigrationPayload validates and normalizes a storage migration payload. +func NormalizeMigrationPayload(ctx context.Context, payload []byte) ([]byte, contracts.StorageConfigDTO, error) { + target, err := ParseMigrationTargetConfig(ctx, payload) + if err != nil { + return nil, contracts.StorageConfigDTO{}, err + } + raw, err := json.Marshal(struct { + Target contracts.StorageConfigDTO `json:"target"` + }{Target: target}) + if err != nil { + return nil, contracts.StorageConfigDTO{}, fmt.Errorf("serialize normalized payload: %w", err) + } + return raw, target, nil +} + +// SaveActiveConfig persists the active storage configuration to w_system_configs. +func SaveActiveConfig(ctx context.Context, cfg contracts.StorageConfigDTO) error { + db := shared.GetDB(ctx) + if db == nil { + return errors.New("database not available") + } + data, err := json.Marshal(cfg) + if err != nil { + return err + } + return db.Table("w_system_configs").Where("key = ?", "storage_config").Update("value", string(data)).Error +} + +// LoadStorageConfig loads the current storage configuration from w_system_configs. +func LoadStorageConfig(ctx context.Context) (contracts.StorageConfigDTO, error) { + db := shared.GetDB(ctx) + if db == nil { + return contracts.StorageConfigDTO{}, errors.New("database not available") + } + var row struct { + Value string + } + if err := db.Table("w_system_configs").Where("key = ?", "storage_config").First(&row).Error; err != nil { + return contracts.StorageConfigDTO{}, err + } + var cfg contracts.StorageConfigDTO + if err := json.Unmarshal([]byte(row.Value), &cfg); err != nil { + return contracts.StorageConfigDTO{}, err + } + return cfg, nil +} diff --git a/backend/plugins/domain/upload/storage/storage_ops.go b/backend/plugins/domain/upload/storage/storage_ops.go new file mode 100644 index 00000000..9ffd2aec --- /dev/null +++ b/backend/plugins/domain/upload/storage/storage_ops.go @@ -0,0 +1,32 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package storage + +import ( + "Wavelet/core/contracts" + "Wavelet/pkg/logger" + "Wavelet/plugins/domain/upload/models" + "Wavelet/plugins/domain/upload/shared" + "context" + "errors" +) + +// ReadOnly checks if the storage system is in read-only maintenance mode. +func ReadOnly(ctx context.Context) bool { + state := LoadMigrationAccessState(ctx) + if state.LoadErr != nil { + logger.ErrorF(ctx, "读取存储维护状态失败: %v", state.LoadErr) + return true + } + return state.ReadOnly +} + +// OpenStoredObject opens a stored upload object from the active storage backend. +func OpenStoredObject(ctx context.Context, upload *models.Upload) (*contracts.StorageObject, error) { + storageSvc := shared.GetStorage(ctx) + if storageSvc == nil { + return nil, errors.New("storage service not available") + } + return storageSvc.Get(ctx, upload.FilePath) +} diff --git a/backend/plugins/domain/upload/task/cleanup.go b/backend/plugins/domain/upload/task/cleanup.go new file mode 100644 index 00000000..7b09ee2c --- /dev/null +++ b/backend/plugins/domain/upload/task/cleanup.go @@ -0,0 +1,155 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package task provides upload-related async background task handlers. +package task + +import ( + "Wavelet/core/contracts" + "Wavelet/pkg/logger" + "Wavelet/plugins/domain/upload/models" + "Wavelet/plugins/domain/upload/shared" + "context" + "errors" + "fmt" + "time" + + "gorm.io/gorm" + + uploadcache "Wavelet/plugins/domain/upload/cache" + + uploadstats "Wavelet/plugins/domain/upload/stats" + uploadstorage "Wavelet/plugins/domain/upload/storage" +) + +const ( + // SystemCleanupTask 系统定期垃圾清理任务标识 + SystemCleanupTask = "system:cleanup" + // TaskTypeSystemCleanup 系统定期垃圾清理管理类型 + TaskTypeSystemCleanup = "system_cleanup" +) + +// SystemCleanupMeta represents the task metadata. +var SystemCleanupMeta = contracts.TaskMetaDTO{ + Type: TaskTypeSystemCleanup, + AsynqTask: SystemCleanupTask, + Name: "系统垃圾清理", + DisplayName: "系统垃圾清理", + Description: "定期清理未使用上传文件、历史推送记录和过期任务执行日志", + Category: "maintenance", + SupportsTime: false, + MaxRetry: 3, + Queue: taskQueueDefault, + Retryable: true, +} + +// SystemCleanupHandler 系统定期垃圾清理异步任务处理器 +type SystemCleanupHandler struct{} + +// Execute 执行系统清理(包含文件清理、历史推送日志和任务执行日志清理) +func (h *SystemCleanupHandler) Execute(ctx context.Context, _ []byte) (*contracts.TaskResultDTO, error) { + if uploadstorage.ReadOnly(ctx) { + return nil, errors.New(shared.ErrStorageReadOnly) + } + const batchSize = 100 + var lastID uint64 + var totalProcessed int + var totalDeleted int + + oneHourAgo := time.Now().Add(-1 * time.Hour) + + logger.InfoF(ctx, "开始扫描未使用的待删除上传文件,阈值时间: %s", oneHourAgo.Format(time.RFC3339)) + + db := shared.GetDB(ctx) + if db == nil { + return nil, errors.New("database service not available") + } + + storageSvc := shared.GetStorage(ctx) + + for { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("system cleanup canceled: %w", err) + } + + var pendingUploads []models.Upload + if err := db. + Where("id > ? AND status = ? AND created_at < ?", lastID, models.UploadStatusPending, oneHourAgo). + Order("id ASC"). + Limit(batchSize). + Find(&pendingUploads).Error; err != nil { + logger.ErrorF(ctx, "查询过期待使用上传文件失败: %v", err) + return nil, fmt.Errorf("failed to query pending uploads: %w", err) + } + + if len(pendingUploads) == 0 { + break + } + + for i := range pendingUploads { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("system cleanup canceled: %w", err) + } + + upload := &pendingUploads[i] + totalProcessed++ + lastID = upload.ID + + if storageSvc != nil { + if err := storageSvc.Delete(ctx, upload.FilePath); err != nil { + logger.WarnF(ctx, "清理过期未确认上传底层文件失败 [ID:%d, Path:%s]: %v", upload.ID, upload.FilePath, err) + } + } + + statsSnapshot := *upload + if err := db.Transaction(func(tx *gorm.DB) error { + if err := tx.Delete(upload).Error; err != nil { + return err + } + return uploadstats.ApplyUploadStatsDeltaTx(tx, &statsSnapshot, -1) + }); err != nil { + logger.ErrorF(ctx, "删除过期未确认上传记录失败 [ID:%d]: %v", upload.ID, err) + continue + } + + uploadcache.EvictUploadMeta(ctx, upload.ID) + totalDeleted++ + } + } + + // 清理过期任务执行记录 + var deletedExecutions int64 + sevenDaysAgo := time.Now().Add(-7 * 24 * time.Hour) + if err := db. + Table("w_task_executions"). + Where("created_at < ?", sevenDaysAgo). + Delete(&struct{}{}).Error; err != nil { + logger.WarnF(ctx, "清理过期任务执行日志失败: %v", err) + } else { + deletedExecutions = db.RowsAffected + logger.InfoF(ctx, "已清理 7 天前任务执行日志,共 %d 条", deletedExecutions) + } + + // 清理已过期推送日志 + var deletedPushLogs int64 + thirtyDaysAgo := time.Now().Add(-30 * 24 * time.Hour) + if err := db. + Table("w_push_logs"). + Where("created_at < ?", thirtyDaysAgo). + Delete(&struct{}{}).Error; err != nil { + logger.WarnF(ctx, "清理历史推送日志失败: %v", err) + } else { + deletedPushLogs = db.RowsAffected + logger.InfoF(ctx, "已清理 30 天前推送日志,共 %d 条", deletedPushLogs) + } + + msg := fmt.Sprintf( + "系统垃圾清理完成,处理未确认文件: %d 个,物理删除: %d 个,清理过期任务日志: %d 条,清理历史推送日志: %d 条", + totalProcessed, + totalDeleted, + deletedExecutions, + deletedPushLogs, + ) + logger.InfoF(ctx, "%s", msg) + return &contracts.TaskResultDTO{Message: msg}, nil +} diff --git a/backend/plugins/domain/upload/task/rebuild_stats.go b/backend/plugins/domain/upload/task/rebuild_stats.go new file mode 100644 index 00000000..2b192fba --- /dev/null +++ b/backend/plugins/domain/upload/task/rebuild_stats.go @@ -0,0 +1,80 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package task + +import ( + "Wavelet/core/contracts" + "Wavelet/pkg/logger" + "Wavelet/plugins/domain/upload/models" + "Wavelet/plugins/domain/upload/shared" + "context" + "errors" + "fmt" + + uploadstats "Wavelet/plugins/domain/upload/stats" +) + +const ( + // RebuildUploadStatsTask is the Asynq task name for rebuilding upload stats. + RebuildUploadStatsTask = "upload:rebuild_stats" + // TaskTypeRebuildUploadStats is the admin-dispatchable task type. + TaskTypeRebuildUploadStats = "rebuild_upload_stats" +) + +// RebuildUploadStatsMeta describes the upload stats rebuild task. +var RebuildUploadStatsMeta = contracts.TaskMetaDTO{ + Type: TaskTypeRebuildUploadStats, + AsynqTask: RebuildUploadStatsTask, + Name: "重算文件存储统计", + DisplayName: "重算文件存储统计", + Description: "根据当前 w_uploads 活跃记录全量重建 w_upload_stats(总量、类型、分类、趋势)", + Category: taskCategoryUpload, + SupportsTime: false, + MaxRetry: 2, + Queue: taskQueueDefault, + Retryable: true, +} + +// RebuildUploadStatsHandler rebuilds incremental upload stats from active upload records. +type RebuildUploadStatsHandler struct{} + +// Execute scans active uploads and rebuilds all upload stat dimensions. +func (h *RebuildUploadStatsHandler) Execute(ctx context.Context, _ []byte) (*contracts.TaskResultDTO, error) { + db := shared.GetDB(ctx) + if db == nil { + return nil, errors.New("database service not available") + } + + var activeCount int64 + if err := db. + Model(&models.Upload{}). + Where("status != ?", models.UploadStatusDeleted). + Count(&activeCount).Error; err != nil { + logger.ErrorF(ctx, "统计活跃上传记录失败: %v", err) + return nil, fmt.Errorf("count active uploads: %w", err) + } + + logger.InfoF(ctx, "开始重算文件存储统计,活跃记录数: %d", activeCount) + + if err := uploadstats.RebuildUploadStats(ctx); err != nil { + logger.ErrorF(ctx, "重算文件存储统计失败: %v", err) + return nil, fmt.Errorf("rebuild upload stats: %w", err) + } + + var totalStat models.UploadStat + if err := db. + Where("dimension = ? AND stat_key = ?", models.UploadStatDimensionTotal, ""). + First(&totalStat).Error; err != nil { + logger.ErrorF(ctx, "读取总量统计失败: %v", err) + return nil, fmt.Errorf("read total upload stat: %w", err) + } + + msg := fmt.Sprintf( + "文件存储统计重算完成,活跃文件: %d 个,总大小: %d 字节", + totalStat.FileCount, + totalStat.FileSize, + ) + logger.InfoF(ctx, "%s", msg) + return &contracts.TaskResultDTO{Message: msg}, nil +} diff --git a/backend/plugins/domain/upload/task/rebuild_stats_test.go b/backend/plugins/domain/upload/task/rebuild_stats_test.go new file mode 100644 index 00000000..8ece3f9e --- /dev/null +++ b/backend/plugins/domain/upload/task/rebuild_stats_test.go @@ -0,0 +1,66 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package task + +import ( + "Wavelet/plugins/domain/upload/models" + "Wavelet/plugins/domain/upload/shared" + "context" + "testing" + "time" +) + +func TestRebuildUploadStatsHandler_Execute(t *testing.T) { + _, cleanup := shared.SetupTestEnv(t) + defer cleanup() + + ctx := context.Background() + now := time.Now() + + uploads := []models.Upload{ + { + UserID: 1001, FileName: "a.jpg", FilePath: "uploads/a.jpg", + FileSize: 100, MimeType: "image/jpeg", Extension: "jpg", Hash: "hash-a", + Type: "pixez_mirror", Status: models.UploadStatusUsed, CreatedAt: now, + }, + { + UserID: 1001, FileName: "b.png", FilePath: "uploads/b.png", + FileSize: 200, MimeType: "image/png", Extension: "png", Hash: "hash-b", + Type: "attachment", Status: models.UploadStatusUsed, CreatedAt: now, + }, + } + db := shared.GetDB(ctx) + for i := range uploads { + if err := db.Create(&uploads[i]).Error; err != nil { + t.Fatalf("seed upload failed: %v", err) + } + } + + // Corrupt stats to ensure rebuild recalculates from uploads. + if err := db.Create(&models.UploadStat{ + Dimension: models.UploadStatDimensionTotal, + StatKey: "", + FileCount: 0, + FileSize: 0, + }).Error; err != nil { + t.Fatalf("seed broken total stat failed: %v", err) + } + + handler := &RebuildUploadStatsHandler{} + result, err := handler.Execute(ctx, nil) + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + if result == nil || result.Message == "" { + t.Fatalf("Execute() returned empty result: %+v", result) + } + + var totalStat models.UploadStat + if err := db.Where("dimension = ? AND stat_key = ?", models.UploadStatDimensionTotal, "").First(&totalStat).Error; err != nil { + t.Fatalf("query total stat failed: %v", err) + } + if totalStat.FileCount != 2 || totalStat.FileSize != 300 { + t.Fatalf("total stat mismatch: count=%d size=%d, want count=2 size=300", totalStat.FileCount, totalStat.FileSize) + } +} diff --git a/backend/plugins/domain/upload/task/storage_migration.go b/backend/plugins/domain/upload/task/storage_migration.go new file mode 100644 index 00000000..bf91c49a --- /dev/null +++ b/backend/plugins/domain/upload/task/storage_migration.go @@ -0,0 +1,399 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package task + +import ( + "Wavelet/core/contracts" + "Wavelet/pkg/logger" + "Wavelet/pkg/util" + "Wavelet/plugins/domain/upload/models" + "Wavelet/plugins/domain/upload/shared" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "strings" + "sync/atomic" + "time" + + "golang.org/x/sync/errgroup" + + uploadstats "Wavelet/plugins/domain/upload/stats" + uploadstorage "Wavelet/plugins/domain/upload/storage" +) + +const ( + // StorageMigrationTask is the task name for storage migration. + StorageMigrationTask = uploadstorage.StorageMigrationTask + // TaskTypeStorageMigration is the task metadata type for storage migration. + TaskTypeStorageMigration = "storage_migration" +) + +// StorageMigrationMeta describes the manually dispatchable migration task. +var StorageMigrationMeta = contracts.TaskMetaDTO{ + Type: TaskTypeStorageMigration, + AsynqTask: StorageMigrationTask, + Name: "迁移文件存储", + DisplayName: "迁移文件存储", + Description: "将活动存储中的文件迁移到待切换的目标存储,迁移期间文件系统保持只读", + Category: taskCategoryUpload, + SupportsTime: false, + MaxRetry: 1, + Queue: taskQueueDefault, + Retryable: true, + Params: []contracts.TaskParamDTO{ + { + Name: "target", + Label: "目标存储配置 (JSON)", + Type: "text", + Required: true, + Placeholder: `{"driver": "s3", "local": {"root": "."}, "s3": {"bucket": "my-bucket"}}`, + Description: "待迁移到的目标存储引擎完整配置 JSON 字符串", + }, + }, +} + +// MigrationHandler copies stored objects and activates the target backend. +type MigrationHandler struct{} + +// ValidatePayload rejects duplicate active migrations through the task framework. +func (h *MigrationHandler) ValidatePayload(payload []byte) ([]byte, error) { + normalized, _, err := uploadstorage.NormalizeMigrationPayload(context.Background(), payload) + if err != nil { + return payload, err + } + active, err := hasUnresolvedMigrationTask(context.Background()) + if err != nil { + return payload, err + } + if active { + return payload, fmt.Errorf("storage migration task is already unresolved") + } + return normalized, nil +} + +// Execute migrates all unique active-storage objects to the pending backend. +func (h *MigrationHandler) Execute(ctx context.Context, payload []byte) (*contracts.TaskResultDTO, error) { + cache := shared.GetCache(ctx) + if cache != nil { + const ( + cleanupTimeout = 5 * time.Second + renewalInterval = 10 * time.Minute + ) + + lockKey := "lock:storage:migrate" + var lockVal string + if err := cache.Get(ctx, lockKey, &lockVal); err == nil && lockVal != "" { + return nil, errors.New("另一个存储迁移任务正在运行中") + } + _ = cache.Set(ctx, lockKey, "locked", time.Hour) + + stopRenewal := make(chan struct{}) + //nolint:contextcheck + defer func() { + close(stopRenewal) + cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout) + defer cancel() + _ = cache.Delete(cleanupCtx, lockKey) + }() + + util.Go(func() { + ticker := time.NewTicker(renewalInterval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + renewCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout) + //nolint:contextcheck // renewal must carry its own deadline; the task context may already be canceled + _ = cache.Set(renewCtx, lockKey, "locked", time.Hour) + cancel() + case <-stopRenewal: + return + case <-ctx.Done(): + return + } + } + }) + } + + active, err := uploadstorage.LoadStorageConfig(ctx) + if err != nil { + return nil, fmt.Errorf("load active storage config: %w", err) + } + target, err := uploadstorage.ParseMigrationTargetConfig(ctx, payload) + if err != nil { + return nil, err + } + if target.Driver == active.Driver { + if err := uploadstorage.SaveActiveConfig(ctx, target); err != nil { + return nil, fmt.Errorf("activate same-driver storage config: %w", err) + } + message := fmt.Sprintf("存储配置已更新,活动存储保持为 %s", target.Driver) + logger.InfoF(ctx, "%s", message) + return &contracts.TaskResultDTO{Message: message}, nil + } + + total, err := countStorageObjects(ctx) + if err != nil { + return nil, fmt.Errorf("count source objects: %w", err) + } + if total == 0 { + if err := uploadstorage.SaveActiveConfig(ctx, target); err != nil { + return nil, fmt.Errorf("activate empty storage config: %w", err) + } + message := fmt.Sprintf("当前存储没有需要迁移的对象,活动存储已切换为 %s", target.Driver) + logger.InfoF(ctx, "%s", message) + return &contracts.TaskResultDTO{Message: message}, nil + } + + storageSvc := shared.GetStorage(ctx) + if storageSvc == nil { + return nil, errors.New("source storage service not available") + } + + logger.InfoF(ctx, "开始存储迁移: %s -> %s,总对象数: %d", active.Driver, target.Driver, total) + migrated, err := migrateObjects(ctx, storageSvc, storageSvc, total) + if err != nil { + return nil, err + } + + if err := uploadstorage.SaveActiveConfig(ctx, target); err != nil { + return nil, fmt.Errorf("activate target storage: %w", err) + } + message := fmt.Sprintf("存储迁移完成,共迁移 %d 个对象,活动存储已切换为 %s", migrated, target.Driver) + logger.InfoF(ctx, "%s", message) + return &contracts.TaskResultDTO{Message: message}, nil +} + +func countStorageObjects(ctx context.Context) (int64, error) { + var count int64 + db := shared.GetDB(ctx) + if db == nil { + return 0, errors.New("database not available") + } + err := db.Model(&models.Upload{}). + Where("status != ?", models.UploadStatusDeleted). + Distinct("file_path"). + Count(&count).Error + return count, err +} + +func hasUnresolvedMigrationTask(ctx context.Context) (bool, error) { + execution, ok, err := uploadstorage.LatestMigrationExecution(ctx) + if err != nil || !ok || execution == nil { + return false, err + } + return execution.Status == "pending" || execution.Status == "running", nil +} + +type migrationObject struct { + FilePath string `gorm:"column:file_path"` + FileSize int64 `gorm:"column:file_size"` + MimeType string `gorm:"column:mime_type"` + Hash string `gorm:"column:hash"` +} + +type storageReaderWriter interface { + Get(ctx context.Context, key string) (*contracts.StorageObject, error) + Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) (contracts.StoragePutResult, error) +} + +func migrateObjects( + ctx context.Context, + sourceBackend storageReaderWriter, + targetBackend storageReaderWriter, + total int64, +) (int64, error) { + const batchSize = 50 + const migrationConcurrency = 10 + const sha256HexLength = 64 + var migrated int64 + var lastFilePath string + db := shared.GetDB(ctx) + if db == nil { + return 0, errors.New("database not available") + } + for { + if err := ctx.Err(); err != nil { + return atomic.LoadInt64(&migrated), fmt.Errorf("storage migration canceled: %w", err) + } + + logger.InfoF(ctx, "正在查询待迁移对象批次,当前已完成迁移: %d/%d", atomic.LoadInt64(&migrated), total) + + var objects []migrationObject + query := db.Model(&models.Upload{}). + Select("file_path, MAX(file_size) AS file_size, MAX(mime_type) AS mime_type, MAX(hash) AS hash"). + Where("status != ?", models.UploadStatusDeleted) + if lastFilePath != "" { + query = query.Where("file_path > ?", lastFilePath) + } + if err := query.Group("file_path"). + Order("file_path ASC"). + Limit(batchSize). + Scan(&objects).Error; err != nil { + return atomic.LoadInt64(&migrated), fmt.Errorf("query source objects: %w", err) + } + if len(objects) == 0 { + logger.InfoF(ctx, "所有对象迁移完毕") + break + } + + lastFilePath = objects[len(objects)-1].FilePath + logger.InfoF(ctx, "获取当前批次迁移对象,批次大小: %d,实际获取对象数: %d", batchSize, len(objects)) + + var g errgroup.Group + g.SetLimit(migrationConcurrency) + + for _, object := range objects { + obj := object + g.Go(func() error { + if err := migrateSingleObject(ctx, sourceBackend, targetBackend, obj, sha256HexLength); err != nil { + return err + } + atomic.AddInt64(&migrated, 1) + return nil + }) + } + + if err := g.Wait(); err != nil { + return atomic.LoadInt64(&migrated), err + } + + logger.InfoF(ctx, "当前批次迁移完成。迁移进度: %d/%d", atomic.LoadInt64(&migrated), total) + } + return atomic.LoadInt64(&migrated), nil +} + +func migrateSingleObject( + ctx context.Context, + sourceBackend storageReaderWriter, + targetBackend storageReaderWriter, + obj migrationObject, + sha256HexLength int, +) error { + if shouldSkipMigration(ctx, sourceBackend, targetBackend, obj) { + logger.InfoF(ctx, "[跳过迁移] 目标存储已存在相同文件: %s", obj.FilePath) + return nil + } + + logger.InfoF(ctx, "[迁移开始] 正在从源存储读取文件: %s", obj.FilePath) + source, err := sourceBackend.Get(ctx, obj.FilePath) + if err != nil { + if isNotFoundError(err) { + return markMissingMigrationObjectDeleted(ctx, obj.FilePath, err) + } + return fmt.Errorf("open source object %q: %w", obj.FilePath, err) + } + logger.InfoF(ctx, "[传输中] 正在向目标存储上传文件: %s (大小: %d 字节, 类型: %s)", obj.FilePath, obj.FileSize, obj.MimeType) + targetResult, putErr := targetBackend.Put(ctx, obj.FilePath, source.Body, obj.FileSize, obj.MimeType) + closeErr := source.Body.Close() + if putErr != nil { + return fmt.Errorf("copy object %q: %w", obj.FilePath, putErr) + } + if closeErr != nil { + return fmt.Errorf("close source object %q: %w", obj.FilePath, closeErr) + } + + if len(obj.Hash) == sha256HexLength { + logger.InfoF(ctx, "[校验中] 正在对目标文件进行数据一致性校验 (SHA-256): %s", targetResult.Key) + targetObj, getErr := targetBackend.Get(ctx, targetResult.Key) + if getErr != nil { + return fmt.Errorf("retrieve target object for verification %q: %w", obj.FilePath, getErr) + } + if targetObj == nil || targetObj.Body == nil { + return fmt.Errorf("retrieve target object for verification %q: object or body is nil", obj.FilePath) + } + h := sha256.New() + if _, copyErr := io.Copy(h, targetObj.Body); copyErr != nil { + _ = targetObj.Body.Close() + return fmt.Errorf("read target object for verification %q: %w", obj.FilePath, copyErr) + } + _ = targetObj.Body.Close() + + computedHash := hex.EncodeToString(h.Sum(nil)) + if computedHash != obj.Hash { + return fmt.Errorf("integrity check failed for %q: got hash %s, want %s", obj.FilePath, computedHash, obj.Hash) + } + logger.InfoF(ctx, "[校验通过] 文件一致性校验成功: %s", targetResult.Key) + } + + db := shared.GetDB(ctx) + if targetResult.Key != obj.FilePath && db != nil { + logger.InfoF(ctx, "[更新数据库] 正在更新文件路径: %s -> %s", obj.FilePath, targetResult.Key) + if err := db.Model(&models.Upload{}). + Where("file_path = ? AND status != ?", obj.FilePath, models.UploadStatusDeleted). + Update("file_path", targetResult.Key).Error; err != nil { + return fmt.Errorf("update migrated object %q: %w", obj.FilePath, err) + } + } + logger.InfoF(ctx, "[迁移成功] 文件已完成迁移: %s", targetResult.Key) + return nil +} + +func shouldSkipMigration( + ctx context.Context, + sourceBackend storageReaderWriter, + targetBackend storageReaderWriter, + obj migrationObject, +) bool { + if sourceBackend == targetBackend { + return false + } + targetObj, err := targetBackend.Get(ctx, obj.FilePath) + if err != nil || targetObj == nil || targetObj.Body == nil { + return false + } + defer func() { + _ = targetObj.Body.Close() + }() + + return targetObj.ContentLength == obj.FileSize +} + +func markMissingMigrationObjectDeleted( + ctx context.Context, + filePath string, + sourceErr error, +) error { + logger.WarnF(ctx, "警告: 源存储中物理文件不存在,标记为已删除并跳过: %s (错误: %v)", filePath, sourceErr) + db := shared.GetDB(ctx) + if db == nil { + return errors.New("database not available") + } + + var affectedUploads []models.Upload + if err := db. + Where("file_path = ? AND status != ?", filePath, models.UploadStatusDeleted). + Find(&affectedUploads).Error; err != nil { + return fmt.Errorf("load missing object uploads %q: %w", filePath, err) + } + if err := db.Model(&models.Upload{}). + Where("file_path = ?", filePath). + Update("status", models.UploadStatusDeleted).Error; err != nil { + return fmt.Errorf("update missing object %q: %w", filePath, err) + } + for i := range affectedUploads { + _ = uploadstats.ApplyUploadStatsRemove(ctx, &affectedUploads[i]) + } + return nil +} + +func isNotFoundError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, os.ErrNotExist) { + return true + } + errStr := strings.ToLower(err.Error()) + for _, sub := range []string{"not found", "nosuchkey", "nosuchbucket", "404", "does not exist"} { + if strings.Contains(errStr, sub) { + return true + } + } + return false +} diff --git a/backend/plugins/domain/upload/task/storage_migration_config_test.go b/backend/plugins/domain/upload/task/storage_migration_config_test.go new file mode 100644 index 00000000..f56bba10 --- /dev/null +++ b/backend/plugins/domain/upload/task/storage_migration_config_test.go @@ -0,0 +1,37 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package task + +import ( + "Wavelet/plugins/domain/upload/shared" + + "context" + "strings" + "testing" +) + +// TestMigrationHandlerRejectsCorruptActiveConfig pins the failure path the task +// package used to swallow. Its local loader discarded the read error and the +// JSON parse error and returned a zero config with a nil error, so a migration +// could proceed believing the active storage driver was simply unknown. +func TestMigrationHandlerRejectsCorruptActiveConfig(t *testing.T) { + dbConn, cleanup := shared.SetupTestEnv(t) + defer cleanup() + + if err := dbConn.Table("w_system_configs"). + Where("key = ?", "storage_config"). + Update("value", "{ this is not json").Error; err != nil { + t.Fatalf("corrupt stored config: %v", err) + } + + payload := []byte(`{"target":{"driver":"s3","s3":{"region":"us-east-1","bucket":"dst"}}}`) + + result, err := (&MigrationHandler{}).Execute(context.Background(), payload) + if err == nil { + t.Fatalf("Execute() = (%v, nil), want an error for an unparsable active config", result) + } + if !strings.Contains(err.Error(), "load active storage config") { + t.Errorf("Execute() error = %q, want it to name the failed active-config read", err) + } +} diff --git a/backend/plugins/domain/upload/task/storage_migration_task_test.go b/backend/plugins/domain/upload/task/storage_migration_task_test.go new file mode 100644 index 00000000..a48e150e --- /dev/null +++ b/backend/plugins/domain/upload/task/storage_migration_task_test.go @@ -0,0 +1,227 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package task + +import ( + "Wavelet/core/contracts" + "Wavelet/plugins/domain/upload/models" + "Wavelet/plugins/domain/upload/shared" + "Wavelet/plugins/domain/upload/storage" + + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "strings" + "testing" +) + +func TestMigrationHandlerExecute(t *testing.T) { + dbConn, cleanup := shared.SetupTestEnv(t) + defer cleanup() + + ctx := context.Background() + storageSvc, ok := shared.GetStorage(ctx).(*shared.MockStorageService) + if !ok { + t.Fatalf("shared.GetStorage(%T) returned %T, want *shared.MockStorageService", ctx, storageSvc) + } + + const sourcePath = "uploads/test.txt" + const content = "storage migration" + storageSvc.PutRaw(sourcePath, []byte(content)) + + active := contracts.StorageConfigDTO{ + Driver: contracts.StorageDriverLocal, + Local: contracts.LocalStorageConfigDTO{Root: "unit"}, + } + if err := storage.SaveActiveConfig(ctx, active); err != nil { + t.Fatalf("SaveActiveConfig() returned error: %v", err) + } + target := contracts.StorageConfigDTO{ + Driver: contracts.StorageDriverS3, + S3: contracts.ObjectStorageConfigDTO{ + Region: "us-east-1", + Bucket: "target", + AccessKeyID: "key", + SecretAccessKey: "secret", + }, + } + payload, err := json.Marshal(struct { + Target contracts.StorageConfigDTO `json:"target"` + }{Target: target}) + if err != nil { + t.Fatalf("Marshal(storageMigrationPayload) returned error: %v", err) + } + + upload := models.Upload{ + ID: 99101, + UserID: 1, + FileName: "test.txt", + FilePath: sourcePath, + FileSize: int64(len(content)), + MimeType: "text/plain", + Extension: "txt", + Hash: "hash", + Type: "attachment", + Status: models.UploadStatusUsed, + } + if err := dbConn.Create(&upload).Error; err != nil { + t.Fatalf("Create(upload) returned error: %v", err) + } + + result, err := (&MigrationHandler{}).Execute(ctx, payload) + if err != nil { + t.Fatalf("Execute() returned error: %v", err) + } + if result == nil { + t.Fatal("Execute() result = nil, want non-nil") + } + + var migrated models.Upload + if err := dbConn.First(&migrated, upload.ID).Error; err != nil { + t.Fatalf("First(upload) returned error: %v", err) + } + current, err := storage.LoadStorageConfig(ctx) + if err != nil { + t.Fatalf("LoadStorageConfig() returned error: %v", err) + } + if current.Driver != contracts.StorageDriverS3 { + t.Errorf("active driver = %q, want %q", current.Driver, contracts.StorageDriverS3) + } +} + +func TestMigrationHandlerExecuteWithHashValidation(t *testing.T) { + dbConn, cleanup := shared.SetupTestEnv(t) + defer cleanup() + + ctx := context.Background() + storageSvc, ok := shared.GetStorage(ctx).(*shared.MockStorageService) + if !ok { + t.Fatalf("shared.GetStorage(%T) returned %T, want *shared.MockStorageService", ctx, storageSvc) + } + + const sourcePath = "uploads/test-hash.txt" + const content = "storage migration integrity check content" + storageSvc.PutRaw(sourcePath, []byte(content)) + + // Calculate correct SHA-256 hash + h := sha256.New() + h.Write([]byte(content)) + correctHash := hex.EncodeToString(h.Sum(nil)) + + active := contracts.StorageConfigDTO{ + Driver: contracts.StorageDriverLocal, + Local: contracts.LocalStorageConfigDTO{Root: "unit"}, + } + if err := storage.SaveActiveConfig(ctx, active); err != nil { + t.Fatalf("SaveActiveConfig() returned error: %v", err) + } + target := contracts.StorageConfigDTO{ + Driver: contracts.StorageDriverS3, + S3: contracts.ObjectStorageConfigDTO{ + Region: "us-east-1", + Bucket: "target", + AccessKeyID: "key", + SecretAccessKey: "secret", + }, + } + payload, err := json.Marshal(struct { + Target contracts.StorageConfigDTO `json:"target"` + }{Target: target}) + if err != nil { + t.Fatalf("Marshal(storageMigrationPayload) returned error: %v", err) + } + + // Case 1: Incorrect Hash (should fail validation) + uploadIncorrect := models.Upload{ + ID: 99102, + UserID: 1, + FileName: "test-hash.txt", + FilePath: sourcePath, + FileSize: int64(len(content)), + MimeType: "text/plain", + Extension: "txt", + Hash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", // Invalid hash + Type: "attachment", + Status: models.UploadStatusUsed, + } + if err := dbConn.Create(&uploadIncorrect).Error; err != nil { + t.Fatalf("Create(uploadIncorrect) returned error: %v", err) + } + + // Running execution with incorrect hash should fail with integrity error + _, err = (&MigrationHandler{}).Execute(ctx, payload) + if err == nil { + t.Fatal("Execute() succeeded with incorrect hash, want error") + } + if !strings.Contains(err.Error(), "integrity check failed") { + t.Errorf("expected integrity check failed error, got: %v", err) + } + + // Case 2: Correct Hash (should succeed) + if err := dbConn.Model(&models.Upload{}).Where("id = ?", uploadIncorrect.ID).Update("hash", correctHash).Error; err != nil { + t.Fatalf("Update hash to correct value returned error: %v", err) + } + + // Run execution with correct hash should succeed + result, err := (&MigrationHandler{}).Execute(ctx, payload) + if err != nil { + t.Fatalf("Execute() with correct hash failed: %v", err) + } + if result == nil { + t.Fatal("Execute() result = nil, want non-nil") + } + + var migrated models.Upload + if err := dbConn.First(&migrated, uploadIncorrect.ID).Error; err != nil { + t.Fatalf("First(upload) returned error: %v", err) + } + if migrated.FilePath != sourcePath { + t.Errorf("FilePath = %q, want %q", migrated.FilePath, sourcePath) + } +} + +func TestMigrationHandlerExecuteWithLock(t *testing.T) { + _, cleanup := shared.SetupTestEnv(t) + defer cleanup() + + ctx := context.Background() + cacheSvc := shared.GetCache(ctx) + if cacheSvc != nil { + _ = cacheSvc.Set(ctx, "lock:storage:migrate", "locked", 3600) + } + + active := contracts.StorageConfigDTO{ + Driver: contracts.StorageDriverLocal, + } + if err := storage.SaveActiveConfig(ctx, active); err != nil { + t.Fatalf("SaveActiveConfig() returned error: %v", err) + } + + payload, err := json.Marshal(struct { + Target contracts.StorageConfigDTO `json:"target"` + }{Target: active}) + if err != nil { + t.Fatalf("Marshal payload failed: %v", err) + } + + // Execution should fail because lock is already acquired + _, err = (&MigrationHandler{}).Execute(ctx, payload) + if err == nil { + t.Fatal("Execute() succeeded when lock was held, want error") + } + if !strings.Contains(err.Error(), "另一个存储迁移任务正在运行中") { + t.Errorf("expected lock warning, got: %v", err) + } + + // Release lock and run again, should succeed + if cacheSvc != nil { + _ = cacheSvc.Delete(ctx, "lock:storage:migrate") + } + + _, err = (&MigrationHandler{}).Execute(ctx, payload) + if err != nil { + t.Fatalf("Execute() failed after lock released: %v", err) + } +} diff --git a/backend/plugins/domain/upload/task/tasks.go b/backend/plugins/domain/upload/task/tasks.go new file mode 100644 index 00000000..dacb08a5 --- /dev/null +++ b/backend/plugins/domain/upload/task/tasks.go @@ -0,0 +1,195 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package task + +import ( + "Wavelet/core/contracts" + "Wavelet/pkg/logger" + "Wavelet/plugins/domain/upload/filesrv" + "Wavelet/plugins/domain/upload/models" + "Wavelet/plugins/domain/upload/shared" + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" +) + +// 上传域任务的通用元数据常量(TaskMetaDTO Category/Queue 复用) +const ( + taskCategoryUpload = "upload" + taskQueueDefault = "default" +) + +const ( + // WarmImageCacheTask 图片压缩缓存预热任务标识 + WarmImageCacheTask = "upload:warm_image_cache" + // TaskTypeWarmImageCache 图片压缩缓存预热管理类型 + TaskTypeWarmImageCache = "warm_image_cache" +) + +var warmImageCacheMu sync.Mutex + +// WarmImageCacheMeta represents the image cache warmup task metadata. +var WarmImageCacheMeta = contracts.TaskMetaDTO{ + Type: TaskTypeWarmImageCache, + AsynqTask: WarmImageCacheTask, + Name: "预热图片压缩缓存", + DisplayName: "预热图片压缩缓存", + Description: "串行将文件管理中的图片转换为指定质量的 WebP 并写入永久缓存", + Category: taskCategoryUpload, + SupportsTime: false, + MaxRetry: 1, + Queue: taskQueueDefault, + Retryable: true, + Params: []contracts.TaskParamDTO{ + { + Name: "quality", + Label: "图片质量", + Type: "string", + Required: true, + Placeholder: "low / medium / high", + Description: "WebP 压缩质量,仅支持 low、medium、high", + }, + }, +} + +// WarmImageCachePayload is the image cache warmup task payload. +type WarmImageCachePayload struct { + Quality string `json:"quality"` +} + +// WarmImageCacheHandler serially warms compressed image cache entries. +type WarmImageCacheHandler struct{} + +// ValidatePayload validates and normalizes image cache warmup parameters. +func (h *WarmImageCacheHandler) ValidatePayload(payload []byte) ([]byte, error) { + if len(payload) == 0 { + return nil, errors.New(shared.ErrImageCacheWarmupPayloadRequired) + } + + var req WarmImageCachePayload + if err := json.Unmarshal(payload, &req); err != nil { + return nil, fmt.Errorf(shared.ErrInvalidImageCacheWarmupPayload, err) + } + + req.Quality = strings.ToLower(strings.TrimSpace(req.Quality)) + if req.Quality != shared.ImageQualityLow && + req.Quality != shared.ImageQualityMedium && + req.Quality != shared.ImageQualityHigh { + return nil, errors.New(shared.ErrInvalidImageCacheWarmupQuality) + } + + return json.Marshal(req) +} + +// Execute serially converts all managed images to WebP cache entries. +func (h *WarmImageCacheHandler) Execute(ctx context.Context, payload []byte) (*contracts.TaskResultDTO, error) { + normalizedPayload, err := h.ValidatePayload(payload) + if err != nil { + logger.WarnF(ctx, "图片缓存预热参数无效: %v", err) + return nil, err + } + + var req WarmImageCachePayload + if err := json.Unmarshal(normalizedPayload, &req); err != nil { + return nil, fmt.Errorf(shared.ErrParseImageCacheWarmupPayload, err) + } + + logger.InfoF(ctx, "等待获取图片缓存预热执行锁,质量: %s", req.Quality) + warmImageCacheMu.Lock() + defer warmImageCacheMu.Unlock() + + const ( + batchSize = 50 + maxFailureLogs = 5 + ) + var lastID uint64 + var totalProcessed int + var totalCached int + var totalGenerated int + var totalFailed int + + logger.InfoF(ctx, "开始串行预热图片压缩缓存,质量: %s,每批: %d", req.Quality, batchSize) + + db := shared.GetDB(ctx) + if db == nil { + return nil, errors.New("database service not available") + } + + for { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("image cache warmup canceled: %w", err) + } + + var uploads []models.Upload + if err := db. + Where("id > ? AND status != ? AND (LOWER(mime_type) LIKE ? OR LOWER(extension) IN ?)", + lastID, + models.UploadStatusDeleted, + "image/%", + []string{"jpg", "jpeg", "png", "webp", "gif"}, + ). + Order("id ASC"). + Limit(batchSize). + Find(&uploads).Error; err != nil { + logger.ErrorF(ctx, "查询图片上传记录失败: %v", err) + return nil, fmt.Errorf(shared.ErrQueryImagesForCacheWarmup, err) + } + + if len(uploads) == 0 { + break + } + + batchGenerated := 0 + batchCached := 0 + batchFailed := 0 + for i := range uploads { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("image cache warmup canceled: %w", err) + } + + upload := &uploads[i] + totalProcessed++ + lastID = upload.ID + + _, cacheHit, err := filesrv.EnsureCompressedImageCache(ctx, upload, req.Quality) + if err != nil { + totalFailed++ + batchFailed++ + if totalFailed <= maxFailureLogs { + logger.WarnF(ctx, "图片处理失败 [ID:%d]: %v", upload.ID, err) + } + continue + } + if cacheHit { + totalCached++ + batchCached++ + continue + } + totalGenerated++ + batchGenerated++ + } + + logger.InfoF( + ctx, + "批次完成,末尾 ID: %d,生成: %d,命中: %d,失败: %d", + lastID, + batchGenerated, + batchCached, + batchFailed, + ) + } + + msg := fmt.Sprintf( + "图片缓存预热完成,共处理 %d 张,生成 %d 张,命中 %d 张,失败 %d 张", + totalProcessed, + totalGenerated, + totalCached, + totalFailed, + ) + logger.InfoF(ctx, "%s", msg) + return &contracts.TaskResultDTO{Message: msg}, nil +} diff --git a/backend/plugins/domain/upload/task/tasks_test.go b/backend/plugins/domain/upload/task/tasks_test.go new file mode 100644 index 00000000..c8313622 --- /dev/null +++ b/backend/plugins/domain/upload/task/tasks_test.go @@ -0,0 +1,288 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package task + +import ( + "Wavelet/plugins/domain/upload/filesrv" + "Wavelet/plugins/domain/upload/models" + "Wavelet/plugins/domain/upload/shared" + + "bytes" + "context" + "encoding/json" + "image" + "image/color" + "image/png" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSystemCleanupHandler_Execute(t *testing.T) { + _, cleanup := shared.SetupTestEnv(t) + defer cleanup() + + ctx := context.Background() + db := shared.GetDB(ctx) + + // 准备测试数据:创建一些上传记录 + now := time.Now() + twoHoursAgo := now.Add(-2 * time.Hour) + + records := []*models.Upload{ + // 超过1小时且状态为 pending 的记录 —— 应被清理 + { + UserID: 1001, FileName: "old_file_1.jpg", FilePath: "uploads/old_1.jpg", + FileSize: 1024, MimeType: "image/jpeg", Extension: "jpg", Hash: "hash1", + Type: "attachment", Status: models.UploadStatusPending, + CreatedAt: twoHoursAgo, + }, + { + UserID: 1001, FileName: "old_file_2.png", FilePath: "uploads/old_2.png", + FileSize: 2048, MimeType: "image/png", Extension: "png", Hash: "hash2", + Type: "attachment", Status: models.UploadStatusPending, + CreatedAt: twoHoursAgo, + }, + // 状态为 used 的记录 —— 不应被清理 + { + UserID: 1001, FileName: "used_file.jpg", FilePath: "uploads/used.jpg", + FileSize: 512, MimeType: "image/jpeg", Extension: "jpg", Hash: "hash3", + Type: "attachment", Status: models.UploadStatusUsed, + CreatedAt: twoHoursAgo, + }, + // 不到1小时的 pending 记录 —— 不应被清理 + { + UserID: 1001, FileName: "recent_file.jpg", FilePath: "uploads/recent.jpg", + FileSize: 256, MimeType: "image/jpeg", Extension: "jpg", Hash: "hash4", + Type: "attachment", Status: models.UploadStatusPending, + CreatedAt: now.Add(-10 * time.Minute), + }, + } + for _, r := range records { + err := db.Create(r).Error + require.NoError(t, err) + } + + // 执行 handler + handler := &SystemCleanupHandler{} + result, err := handler.Execute(ctx, nil) + + // 验证结果 + require.NoError(t, err) + require.NotNil(t, result) + assert.Contains(t, result.Message, "系统垃圾清理完成") + + // 验证数据库状态:pending 且超过1小时的已被清理 + var pendingCount int64 + db.Model(&models.Upload{}).Where("status = ?", models.UploadStatusPending).Count(&pendingCount) + assert.Equal(t, int64(1), pendingCount, "应只剩1条 pending 记录(最近的文件)") + + var usedCount int64 + db.Model(&models.Upload{}).Where("status = ?", models.UploadStatusUsed).Count(&usedCount) + assert.Equal(t, int64(1), usedCount, "used 状态的文件不应受影响") +} + +func TestSystemCleanupHandler_ExecuteNoFiles(t *testing.T) { + _, cleanup := shared.SetupTestEnv(t) + defer cleanup() + + ctx := context.Background() + + // 没有任何上传记录 + handler := &SystemCleanupHandler{} + result, err := handler.Execute(ctx, nil) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Contains(t, result.Message, "系统垃圾清理完成") +} + +func TestWarmImageCacheHandlerValidatePayload(t *testing.T) { + tests := []struct { + name string + payload []byte + wantQuality string + wantErr bool + }{ + { + name: "normalizes quality", + payload: []byte(`{"quality":" HIGH "}`), + wantQuality: shared.ImageQualityHigh, + }, + { + name: "empty payload", + wantErr: true, + }, + { + name: "invalid json", + payload: []byte(`{`), + wantErr: true, + }, + { + name: "origin is not a compressed quality", + payload: []byte(`{"quality":"origin"}`), + wantErr: true, + }, + { + name: "unsupported quality", + payload: []byte(`{"quality":"maximum"}`), + wantErr: true, + }, + } + + handler := &WarmImageCacheHandler{} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotPayload, err := handler.ValidatePayload(tt.payload) + if gotErr := err != nil; gotErr != tt.wantErr { + t.Fatalf("ValidatePayload(%s) error = %v, want error presence = %t", tt.payload, err, tt.wantErr) + } + if tt.wantErr { + return + } + + var got WarmImageCachePayload + if err := json.Unmarshal(gotPayload, &got); err != nil { + t.Fatalf("json.Unmarshal(%s) returned error: %v", gotPayload, err) + } + if got.Quality != tt.wantQuality { + t.Errorf("ValidatePayload(%s).Quality = %q, want %q", tt.payload, got.Quality, tt.wantQuality) + } + }) + } +} + +func TestWarmImageCacheHandlerExecute(t *testing.T) { + dbConn, cleanup := shared.SetupTestEnv(t) + defer cleanup() + + ctx := context.Background() + + firstPath := "uploads/first.png" + secondPath := "uploads/second.jpg" + firstData := writeTaskTestPNG(t, color.RGBA{R: 255, A: 255}) + secondData := writeTaskTestPNG(t, color.RGBA{G: 255, A: 255}) + + storageSvc, ok := shared.GetStorage(ctx).(*shared.MockStorageService) + if !ok { + t.Fatalf("shared.GetStorage(%T) returned %T, want *shared.MockStorageService", ctx, storageSvc) + } + storageSvc.PutRaw(firstPath, firstData) + storageSvc.PutRaw(secondPath, secondData) + + records := []models.Upload{ + { + ID: 4101, + UserID: 1001, + FileName: "first.png", + FilePath: firstPath, + FileSize: int64(len(firstData)), + MimeType: "image/png", + Extension: "png", + Hash: "hash1", + Type: "attachment", + Status: models.UploadStatusUsed, + }, + { + ID: 4102, + UserID: 1001, + FileName: "second.jpg", + FilePath: secondPath, + FileSize: int64(len(secondData)), + MimeType: "application/octet-stream", + Extension: "jpg", + Hash: "hash2", + Type: "attachment", + Status: models.UploadStatusPending, + }, + { + ID: 4103, + UserID: 1001, + FileName: "notes.txt", + FilePath: "uploads/notes.txt", + FileSize: 123, + MimeType: "text/plain", + Extension: "txt", + Hash: "hash3", + Type: "attachment", + Status: models.UploadStatusUsed, + }, + { + ID: 4104, + UserID: 1001, + FileName: "deleted.png", + FilePath: firstPath, + FileSize: int64(len(firstData)), + MimeType: "image/png", + Extension: "png", + Hash: "hash4", + Type: "attachment", + Status: models.UploadStatusDeleted, + }, + } + + for i := range records { + if err := dbConn.Create(&records[i]).Error; err != nil { + t.Fatalf("failed to create upload %d: %v", records[i].ID, err) + } + } + + handler := &WarmImageCacheHandler{} + payload := []byte(`{"quality":"low"}`) + + result, err := handler.Execute(ctx, payload) + if err != nil { + t.Fatalf("Execute(%s) returned error: %v", payload, err) + } + if result == nil { + t.Fatal("Execute() result = nil, want non-nil") + } + if result.Message != "图片缓存预热完成,共处理 2 张,生成 2 张,命中 0 张,失败 0 张" { + t.Errorf("Execute() message = %q, want generated summary", result.Message) + } + + for i := range records[:2] { + key := filesrv.ImageCompressionCacheKey(&records[i], shared.ImageQualityLow) + got, hit, err := filesrv.EnsureCompressedImageCache(context.Background(), &records[i], shared.ImageQualityLow) + if err != nil { + t.Errorf("EnsureCompressedImageCache(%q) returned error: %v", key, err) + continue + } + if !hit { + t.Errorf("expected cache hit for %q", key) + } + if len(got) == 0 { + t.Errorf("EnsureCompressedImageCache(%q) returned empty WebP data", key) + } + } + + secondResult, err := handler.Execute(ctx, payload) + if err != nil { + t.Fatalf("second Execute(%s) returned error: %v", payload, err) + } + if secondResult.Message != "图片缓存预热完成,共处理 2 张,生成 0 张,命中 2 张,失败 0 张" { + t.Errorf("second Execute() message = %q, want cache-hit summary", secondResult.Message) + } + +} + +func writeTaskTestPNG(t *testing.T, fill color.RGBA) []byte { + t.Helper() + + img := image.NewRGBA(image.Rect(0, 0, 2, 2)) + for y := 0; y < 2; y++ { + for x := 0; x < 2; x++ { + img.Set(x, y, fill) + } + } + + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatalf("png.Encode() returned error: %v", err) + } + + return buf.Bytes() +} diff --git a/backend/plugins/domain/upload/util/media.go b/backend/plugins/domain/upload/util/media.go new file mode 100644 index 00000000..7b7274bd --- /dev/null +++ b/backend/plugins/domain/upload/util/media.go @@ -0,0 +1,50 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package util + +import ( + "Wavelet/plugins/domain/upload/shared" + "strings" +) + +// IsImageExtension reports whether ext is a common image format. +func IsImageExtension(ext string) bool { + for _, imgExt := range []string{"jpg", "jpeg", "png", "webp", "gif"} { + if ext == imgExt { + return true + } + } + return false +} + +// IsArchiveExtension reports whether ext is a common archive format. +func IsArchiveExtension(ext string) bool { + for _, e := range []string{"zip", "rar", "7z", "tar", "gz", "tgz", "bz2", "xz"} { + if ext == e { + return true + } + } + return false +} + +// IsDocumentExtension reports whether ext is a common document format. +func IsDocumentExtension(ext string) bool { + for _, e := range []string{"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "md", "csv", "json", "yaml", "yml", "xml"} { + if ext == e { + return true + } + } + return false +} + +// NormalizeImageQuality normalizes the requested image quality query parameter. +func NormalizeImageQuality(quality string) string { + q := strings.TrimSpace(strings.ToLower(quality)) + switch q { + case shared.ImageQualityLow, shared.ImageQualityMedium, shared.ImageQualityHigh: + return q + default: + return shared.ImageQualityOrigin + } +} diff --git a/backend/plugins/domain/upload/util/utils.go b/backend/plugins/domain/upload/util/utils.go new file mode 100644 index 00000000..54887214 --- /dev/null +++ b/backend/plugins/domain/upload/util/utils.go @@ -0,0 +1,74 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package util provides upload media helpers and image utilities. +package util + +import ( + "Wavelet/plugins/domain/upload/shared" + "bytes" + "errors" + "fmt" + "image" // Register GIF decoder for image.Decode + _ "image/gif" // Register JPEG decoder for image.Decode + _ "image/jpeg" // Register PNG decoder for image.Decode + _ "image/png" + "io" + "strings" + + "github.com/deepteams/webp" + _ "golang.org/x/image/webp" // Register WebP decoder for image.Decode +) + +// ValidateS3Key validates an S3 object key for safety. +func ValidateS3Key(key string) error { + if key == "" { + return errors.New(shared.ErrS3KeyRequired) + } + + if len(key) > shared.MaxS3KeyLength { + return fmt.Errorf(shared.ErrS3KeyTooLongFormat, shared.MaxS3KeyLength) + } + + if strings.HasPrefix(key, "/") { + return errors.New(shared.ErrS3KeyStartsWithSlash) + } + + if strings.Contains(key, "\x00") { + return errors.New(shared.ErrS3KeyContainsNullBytes) + } + + return nil +} + +// CompressImageToWebP decodes an image from srcReader and encodes it into WebP format +// using the specified quality (low -> 60, medium -> 75, high -> 85). +func CompressImageToWebP(srcReader io.Reader, quality string) ([]byte, error) { + img, format, err := image.Decode(srcReader) + if err != nil { + return nil, fmt.Errorf("failed to decode image (format: %s): %w", format, err) + } + + var qualityScore float32 + switch strings.ToLower(quality) { + case shared.ImageQualityLow: + qualityScore = 60 + case shared.ImageQualityMedium: + qualityScore = 75 + case shared.ImageQualityHigh, "": + qualityScore = 85 + default: + qualityScore = 85 + } + + var buf bytes.Buffer + err = webp.Encode(&buf, img, &webp.EncoderOptions{ + Quality: qualityScore, + Method: 4, + }) + if err != nil { + return nil, fmt.Errorf("failed to encode WebP: %w", err) + } + + return buf.Bytes(), nil +} diff --git a/backend/plugins/domain/user/errs.go b/backend/plugins/domain/user/errs.go new file mode 100644 index 00000000..df4102f2 --- /dev/null +++ b/backend/plugins/domain/user/errs.go @@ -0,0 +1,43 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package user + +// HTTP 响应错误文案 +const ( + errInvalidParams = "无效的请求参数" + errUserNotFound = "用户不存在" + //nolint:gosec // error message, not hardcoded credentials + errPasswordMismatch = "用户名或密码错误" + //nolint:gosec // error message, not hardcoded credentials + errOldPasswordIncorrect = "原密码不正确" + //nolint:gosec // error message, not hardcoded credentials + errTokenNotFound = "访问令牌不存在" + errCreateUserFailed = "创建用户失败: " + //nolint:gosec // error message, not hardcoded credentials + errCreateTokenFailed = "创建令牌失败" + //nolint:gosec // error message, not hardcoded credentials + errPasswordEncryptFailed = "密码加密失败" + //nolint:gosec // error message, not hardcoded credentials + errPasswordUpdateFailed = "密码更新失败" + // error message, not hardcoded credentials + errPasswordEmpty = "password cannot be empty" +) + +// Service 层业务校验错误文案,取值被上游插件按字符串精确匹配消费,禁止改写 +const ( + errUsernameEmpty = "用户名不能为空" + errEmailEmpty = "邮箱不能为空" + errUsernameTaken = "用户名已被使用" + errEmailTaken = "邮箱已被使用" + errCannotRevokeSelf = "不能取消自己的管理员权限" + errAdminCannotDisable = "管理员账号无法被禁用" + errAdminCannotDelete = "管理员账号无法被删除" + errCannotDeleteSelf = "不能删除当前登录用户" + errServiceUsernameEmpty = "user: username cannot be empty" + // error message, not hardcoded credentials + errServiceOldPasswordIncorrect = "user: incorrect old password" + //nolint:gosec // error message, not hardcoded credentials + errServicePasswordTooShort = "密码长度至少为 8 位" + errUniqueUsernameFailed = "failed to generate unique username" +) diff --git a/backend/plugins/domain/user/handlers.go b/backend/plugins/domain/user/handlers.go new file mode 100644 index 00000000..2db068c2 --- /dev/null +++ b/backend/plugins/domain/user/handlers.go @@ -0,0 +1,349 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package user + +import ( + "Wavelet/core/contracts" + "Wavelet/pkg/logger" + "Wavelet/pkg/response" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "net/http" + "strconv" + "sync" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +var ( + authMu sync.RWMutex + authSvc contracts.AuthService +) + +// SetAuthService binds the AuthService for cache synchronization. +func SetAuthService(s contracts.AuthService) { + authMu.Lock() + defer authMu.Unlock() + authSvc = s +} + +func getAuthService() contracts.AuthService { + authMu.RLock() + defer authMu.RUnlock() + return authSvc +} + +func getUserIDFromSession(c *gin.Context) uint64 { + defer func() { _ = recover() }() + session := sessions.Default(c) + val := session.Get(contracts.AuthUserIDKey) + if val == nil { + return 0 + } + switch v := val.(type) { + case uint64: + return v + case int64: + if v < 0 { + return 0 + } + return uint64(v) + case float64: + if v < 0 { + return 0 + } + return uint64(v) + case string: + id, _ := strconv.ParseUint(v, 10, 64) + return id + default: + return 0 + } +} + +func invalidateUserCache(ctx context.Context, userID uint64) { + if s := getAuthService(); s != nil { + s.InvalidateCachedUser(ctx, userID) + } +} + +func invalidateTokenCache(ctx context.Context, tokenHash string) { + if s := getAuthService(); s != nil { + s.InvalidateCachedToken(ctx, tokenHash) + } +} + +// Login handles username and password authentication. +func Login(c *gin.Context) { + var req loginRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortBadRequest(c, errInvalidParams) + return + } + + user, err := GetUserByUsername(c.Request.Context(), req.Username) + if err != nil { + response.AbortUnauthorized(c, errPasswordMismatch) + return + } + + if !user.CheckPassword(req.Password) { + response.AbortUnauthorized(c, errPasswordMismatch) + return + } + + sess := sessions.Default(c) + sess.Set(contracts.AuthUserIDKey, user.ID) + sess.Set(contracts.AuthUserNameKey, user.Username) + needChange := user.NeedChangePassword || user.IsPlaintextPassword() + user.NeedChangePassword = needChange + sess.Set("need_change_password", needChange) + if err := sess.Save(); err != nil { + logger.ErrorF(c.Request.Context(), "save session failed on login: %v", err) + } + + c.JSON(http.StatusOK, response.OK(user)) +} + +// Register registers a new user. +func Register(c *gin.Context) { + var req registerRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortBadRequest(c, errInvalidParams) + return + } + + newUser := &User{ + Username: req.Username, + Email: req.Email, + IsActive: true, + } + if err := newUser.SetEncryptedPassword(req.Password); err != nil { + response.AbortInternal(c, errPasswordEncryptFailed) + return + } + + if err := CreateUser(c.Request.Context(), newUser); err != nil { + response.AbortBadRequest(c, errCreateUserFailed+err.Error()) + return + } + + sess := sessions.Default(c) + sess.Set(contracts.AuthUserIDKey, newUser.ID) + sess.Set(contracts.AuthUserNameKey, newUser.Username) + if err := sess.Save(); err != nil { + logger.ErrorF(c.Request.Context(), "save session failed on register: %v", err) + } + + c.JSON(http.StatusOK, response.OK(newUser)) +} + +// Logout logs out the current session. +func Logout(c *gin.Context) { + sess := sessions.Default(c) + sess.Options(sessions.Options{ + Path: "/", + MaxAge: -1, + }) + sess.Clear() + if err := sess.Save(); err != nil { + logger.ErrorF(c.Request.Context(), "clear session failed on logout: %v", err) + } + c.JSON(http.StatusOK, response.OKNil()) +} + +// SendEmailCode sends an email verification code. +func SendEmailCode(c *gin.Context) { + c.JSON(http.StatusOK, response.OK(gin.H{"sent": true})) +} + +// ChangePassword changes the current user password. +func ChangePassword(c *gin.Context) { + var req changePasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortBadRequest(c, errInvalidParams) + return + } + + userID := getUserIDFromSession(c) + user, err := GetUserByID(c.Request.Context(), userID) + if err != nil { + response.AbortNotFound(c, errUserNotFound) + return + } + + if !user.CheckPassword(req.OldPassword) { + response.AbortBadRequest(c, errOldPasswordIncorrect) + return + } + + if err := user.SetEncryptedPassword(req.NewPassword); err != nil { + response.AbortInternal(c, errPasswordUpdateFailed) + return + } + + if err := UpdateUser(c.Request.Context(), user); err != nil { + logger.ErrorF(c.Request.Context(), "persist changed password failed: %v", err) + } + invalidateUserCache(c.Request.Context(), user.ID) + + sess := sessions.Default(c) + sess.Set("need_change_password", false) + if err := sess.Save(); err != nil { + logger.ErrorF(c.Request.Context(), "save session failed on change-password: %v", err) + } + + c.JSON(http.StatusOK, response.OKNil()) +} + +// UpdateProfile updates profile info. +func UpdateProfile(c *gin.Context) { + var req updateProfileRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortBadRequest(c, errInvalidParams) + return + } + + userID := getUserIDFromSession(c) + user, err := GetUserByID(c.Request.Context(), userID) + if err != nil { + response.AbortNotFound(c, errUserNotFound) + return + } + + user.Nickname = req.Nickname + user.AvatarURL = req.AvatarURL + user.Bio = req.Bio + user.Phone = req.Phone + user.Gender = req.Gender + user.Website = req.Website + user.Location = req.Location + + if err := UpdateUser(c.Request.Context(), user); err != nil { + logger.ErrorF(c.Request.Context(), "persist updated profile failed: %v", err) + } + invalidateUserCache(c.Request.Context(), user.ID) + + c.JSON(http.StatusOK, response.OK(user)) +} + +// ListAccessTokens lists access tokens for the current user. +func ListAccessTokens(c *gin.Context) { + userID := getUserIDFromSession(c) + tokens, err := listAccessTokensByUser(c.Request.Context(), userID) + if err != nil { + logger.ErrorF(c.Request.Context(), "list access tokens failed: %v", err) + } + c.JSON(http.StatusOK, response.OK(tokens)) +} + +const ( + tokenEntropyByteLength = 24 + tokenMaskMinLength = 8 +) + +// CreateAccessToken generates a new access token. +func CreateAccessToken(c *gin.Context) { + var req createAccessTokenRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortBadRequest(c, errInvalidParams) + return + } + + userID := getUserIDFromSession(c) + rawBytes := make([]byte, tokenEntropyByteLength) + _, _ = rand.Read(rawBytes) + rawToken := "wvt_" + hex.EncodeToString(rawBytes) + hash := sha256.Sum256([]byte(rawToken)) + tokenHash := hex.EncodeToString(hash[:]) + + masked := rawToken + if len(rawToken) > tokenMaskMinLength { + masked = rawToken[:4] + "..." + rawToken[len(rawToken)-4:] + } + + token := AccessToken{ + UserID: userID, + Name: req.Name, + TokenHash: tokenHash, + MaskedToken: masked, + IsAdmin: req.IsAdmin, + } + + if err := createAccessTokenRow(c.Request.Context(), &token); err != nil { + response.AbortInternal(c, errCreateTokenFailed) + return + } + + c.JSON(http.StatusOK, response.OK(gin.H{ + "token": token, + "raw_token": rawToken, + })) +} + +// DeleteAccessToken deletes a specific access token. +func DeleteAccessToken(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + response.AbortBadRequest(c, errInvalidParams) + return + } + + userID := getUserIDFromSession(c) + token, err := getAccessTokenOfUser(c.Request.Context(), id, userID) + if err != nil { + response.AbortNotFound(c, errTokenNotFound) + return + } + + if err := deleteAccessTokenRow(c.Request.Context(), token); err != nil { + logger.ErrorF(c.Request.Context(), "delete access token failed: %v", err) + } + invalidateTokenCache(c.Request.Context(), token.TokenHash) + c.JSON(http.StatusOK, response.OKNil()) +} + +// RotateAccessToken rotates an access token value. +func RotateAccessToken(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + response.AbortBadRequest(c, errInvalidParams) + return + } + + userID := getUserIDFromSession(c) + token, err := getAccessTokenOfUser(c.Request.Context(), id, userID) + if err != nil { + response.AbortNotFound(c, errTokenNotFound) + return + } + + invalidateTokenCache(c.Request.Context(), token.TokenHash) + + rawBytes := make([]byte, tokenEntropyByteLength) + _, _ = rand.Read(rawBytes) + rawToken := "wvt_" + hex.EncodeToString(rawBytes) + hash := sha256.Sum256([]byte(rawToken)) + token.TokenHash = hex.EncodeToString(hash[:]) + + masked := rawToken + if len(rawToken) > tokenMaskMinLength { + masked = rawToken[:4] + "..." + rawToken[len(rawToken)-4:] + } + token.MaskedToken = masked + + if err := saveAccessTokenRow(c.Request.Context(), token); err != nil { + logger.ErrorF(c.Request.Context(), "rotate access token failed: %v", err) + } + + c.JSON(http.StatusOK, response.OK(gin.H{ + "token": token, + "raw_token": rawToken, + })) +} diff --git a/backend/plugins/domain/user/migrations/postgres/00001_initial.sql b/backend/plugins/domain/user/migrations/postgres/00001_initial.sql new file mode 100644 index 00000000..e183f348 --- /dev/null +++ b/backend/plugins/domain/user/migrations/postgres/00001_initial.sql @@ -0,0 +1,41 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS w_users ( + id BIGINT PRIMARY KEY, + username VARCHAR(64) NOT NULL UNIQUE, + password VARCHAR(255), + nickname VARCHAR(255), + email VARCHAR(255), + avatar_url VARCHAR(255), + is_active BOOLEAN DEFAULT TRUE, + is_admin BOOLEAN DEFAULT FALSE, + bio VARCHAR(500), + phone VARCHAR(32), + gender VARCHAR(16), + website VARCHAR(255), + location VARCHAR(255), + last_login_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_w_users_email ON w_users (email); +CREATE INDEX IF NOT EXISTS idx_w_users_is_active ON w_users (is_active); +CREATE INDEX IF NOT EXISTS idx_w_users_last_login_at ON w_users (last_login_at); +CREATE INDEX IF NOT EXISTS idx_w_users_created_at ON w_users (created_at); + +-- Seed system user +INSERT INTO w_users (id, username, password, nickname, avatar_url, is_active, is_admin, last_login_at, created_at, updated_at) +VALUES (999, 'system', '*', '系统', '', TRUE, FALSE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT (username) DO NOTHING; + +-- Seed default administrator user (username: admin, password: 12345678) +INSERT INTO w_users (id, username, password, nickname, email, is_active, is_admin, last_login_at, created_at, updated_at) +VALUES (1, 'admin', '12345678', '管理员', 'admin@wavelet.local', TRUE, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT (username) DO NOTHING; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DELETE FROM w_users WHERE username = 'system'; +DROP TABLE IF EXISTS w_users; +-- +goose StatementEnd diff --git a/backend/plugins/domain/user/migrations/sqlite/00001_initial.sql b/backend/plugins/domain/user/migrations/sqlite/00001_initial.sql new file mode 100644 index 00000000..20b7f7a0 --- /dev/null +++ b/backend/plugins/domain/user/migrations/sqlite/00001_initial.sql @@ -0,0 +1,41 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS w_users ( + id BIGINT PRIMARY KEY, + username VARCHAR(64) NOT NULL UNIQUE, + password VARCHAR(255), + nickname VARCHAR(255), + email VARCHAR(255), + avatar_url VARCHAR(255), + is_active BOOLEAN DEFAULT 1, + is_admin BOOLEAN DEFAULT 0, + bio VARCHAR(500), + phone VARCHAR(32), + gender VARCHAR(16), + website VARCHAR(255), + location VARCHAR(255), + last_login_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_w_users_email ON w_users (email); +CREATE INDEX IF NOT EXISTS idx_w_users_is_active ON w_users (is_active); +CREATE INDEX IF NOT EXISTS idx_w_users_last_login_at ON w_users (last_login_at); +CREATE INDEX IF NOT EXISTS idx_w_users_created_at ON w_users (created_at); + +-- Seed system user +INSERT INTO w_users (id, username, password, nickname, avatar_url, is_active, is_admin, last_login_at, created_at, updated_at) +VALUES (999, 'system', '*', '系统', '', 1, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT (username) DO NOTHING; + +-- Seed default administrator user (username: admin, password: 12345678) +INSERT INTO w_users (id, username, password, nickname, email, is_active, is_admin, last_login_at, created_at, updated_at) +VALUES (1, 'admin', '12345678', '管理员', 'admin@wavelet.local', 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT (username) DO NOTHING; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DELETE FROM w_users WHERE username = 'system'; +DROP TABLE IF EXISTS w_users; +-- +goose StatementEnd diff --git a/backend/plugins/domain/user/models.go b/backend/plugins/domain/user/models.go new file mode 100644 index 00000000..b742f9a9 --- /dev/null +++ b/backend/plugins/domain/user/models.go @@ -0,0 +1,140 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package user + +import ( + "Wavelet/pkg/util" + "errors" + "strings" + "time" +) + +// AccessToken 个人访问令牌实体 +type AccessToken struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + UserID uint64 `json:"user_id" gorm:"index;not null"` + Name string `json:"name" gorm:"size:128;not null"` + TokenHash string `json:"-" gorm:"size:64;uniqueIndex;not null"` + MaskedToken string `json:"masked_token" gorm:"size:64;not null"` + IsAdmin bool `json:"is_admin" gorm:"default:false"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` +} + +// TableName 表名 +func (AccessToken) TableName() string { + return "w_access_tokens" +} + +// User 用户表实体 +type User struct { + ID uint64 `json:"id,string" gorm:"primaryKey;not null"` + Username string `json:"username" gorm:"size:64;uniqueIndex"` + Password string `json:"password,omitempty" gorm:"size:255"` + Nickname string `json:"nickname" gorm:"size:255"` + Email string `json:"email" gorm:"size:255;index"` + AvatarURL string `json:"avatar_url" gorm:"size:255"` + IsActive bool `json:"is_active" gorm:"default:true;index"` + IsAdmin bool `json:"is_admin" gorm:"default:false"` + NeedChangePassword bool `json:"need_change_password,omitempty" gorm:"-"` + Bio string `json:"bio" gorm:"size:500"` + Phone string `json:"phone" gorm:"size:32"` + Gender string `json:"gender" gorm:"size:16"` + Website string `json:"website" gorm:"size:255"` + Location string `json:"location" gorm:"size:255"` + LastLoginAt time.Time `json:"last_login_at" gorm:"index"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"` +} + +// TableName 表名 +func (User) TableName() string { + return "w_users" +} + +// IsPlaintextPassword 检查当前密码是否为未加密的明文密码(如初始默认密码) +func (u *User) IsPlaintextPassword() bool { + if u.Password == "" { + return false + } + return !strings.HasPrefix(u.Password, "$2a$") && + !strings.HasPrefix(u.Password, "$2b$") && + !strings.HasPrefix(u.Password, "$2y$") && + !strings.HasPrefix(u.Password, "$2x$") +} + +// SetEncryptedPassword 设置加密密码 +func (u *User) SetEncryptedPassword(password string) error { + trimmed := strings.TrimSpace(password) + if trimmed == "" { + return errors.New(errPasswordEmpty) + } + hash, err := util.HashPassword(trimmed) + if err != nil { + return err + } + u.Password = hash + return nil +} + +// CheckPassword 校验密码(支持 bcrypt 哈希校验与初始明文密码校验) +func (u *User) CheckPassword(password string) bool { + if u.Password == "" { + util.DummyCheckPassword(password) + return false + } + if !u.IsPlaintextPassword() { + return util.CheckPasswordHash(u.Password, password) + } + + // 明文密码兼容比对(识别初始默认密码向用户警告修改密码) + if u.Password == password { + u.NeedChangePassword = true + return true + } + return false +} + +// loginRequest 登录请求参数 +type loginRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +// registerRequest 注册请求参数 +type registerRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + Email string `json:"email"` +} + +// changePasswordRequest 修改密码请求参数 +type changePasswordRequest struct { + OldPassword string `json:"old_password" binding:"required"` + NewPassword string `json:"new_password" binding:"required"` +} + +// updateProfileRequest 资料更新请求参数 +type updateProfileRequest struct { + Nickname string `json:"nickname"` + AvatarURL string `json:"avatar_url"` + Bio string `json:"bio"` + Phone string `json:"phone"` + Gender string `json:"gender"` + Website string `json:"website"` + Location string `json:"location"` +} + +// createAccessTokenRequest 创建访问令牌请求参数 +type createAccessTokenRequest struct { + Name string `json:"name" binding:"required"` + ExpiresAt *time.Time `json:"expires_at"` + IsAdmin bool `json:"is_admin"` +} + +// userAdminFlags 用户写操作前置校验所需的最小列投影 +type userAdminFlags struct { + ID uint64 + IsAdmin bool +} diff --git a/backend/plugins/domain/user/plugin.go b/backend/plugins/domain/user/plugin.go new file mode 100644 index 00000000..b9818d54 --- /dev/null +++ b/backend/plugins/domain/user/plugin.go @@ -0,0 +1,254 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package user provides the user profile, credential management, role management, and access token domain plugin for Cordis. +package user + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "Wavelet/core/extpoints" + "Wavelet/pkg/ginutil" + "context" + "embed" + "reflect" + + "github.com/gin-gonic/gin" +) + +//go:embed migrations/*/*.sql +var userMigrations embed.FS + +// Option configures the user plugin. +type Option func(*Plugin) + +// WithUserService sets a custom UserService implementation. +func WithUserService(svc contracts.UserService) Option { + return func(p *Plugin) { + p.userSvc = svc + } +} + +// Plugin implements core.Plugin to provide user account and credential domain services. +type Plugin struct { + userSvc contracts.UserService +} + +// New creates a new user domain plugin. +func New(opts ...Option) *Plugin { + p := &Plugin{} + for _, opt := range opts { + if opt != nil { + opt(p) + } + } + return p +} + +// PluginName 用户插件唯一名称标识 +const PluginName = "user" + +// Name returns the unique identifier for the user domain plugin. +func (p *Plugin) Name() string { + return PluginName +} + +// Inject declares required dependencies for the user domain plugin. +func (p *Plugin) Inject() []reflect.Type { + return []reflect.Type{ + reflect.TypeFor[contracts.DBService](), + // Apply resolves AuthService to build the route auth middleware. The + // kernel only gates Apply on declared deps, so leaving this out lets + // user mount before auth and fall back to a pass-through middleware. + reflect.TypeFor[contracts.AuthService](), + } +} + +// Manifest returns the plugin metadata. +func (p *Plugin) Manifest() core.Manifest { + return core.Manifest{ + Name: PluginName, + Version: "1.0.0", + Description: "User profiles, credentials, role management, and access token domain plugin", + Author: "Wavelet Team", + } +} + +// Apply registers user migrations, services, routes, tasks, schedules, and settings into the Context. +func (p *Plugin) Apply(ctx *core.Context) error { + // 0. Bind DBService from Context + if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil { + SetDBService(db) + } else { + core.When[contracts.DBService](ctx, func(db contracts.DBService) { + SetDBService(db) + }) + } + ctx.OnDispose(func() error { + SetDBService(nil) + return nil + }) + + // 0.1 Resolve auth service for middleware and cache invalidation (via IoC, not direct import) + denyAuth := ginutil.AuthUnavailable() + loginMW := denyAuth + noTokenMW := denyAuth + if authSvc, err := core.Inject[contracts.AuthService](ctx); err == nil && authSvc != nil { + SetAuthService(authSvc) + if mw, ok := authSvc.RequireAuthMiddleware().(gin.HandlerFunc); ok { + loginMW = mw + } + if mw, ok := authSvc.DisallowTokenAuthMiddleware().(gin.HandlerFunc); ok { + noTokenMW = mw + } + } else { + core.When[contracts.AuthService](ctx, func(svc contracts.AuthService) { + SetAuthService(svc) + }) + } + ctx.OnDispose(func() error { + SetAuthService(nil) + return nil + }) + + // 1. Register migrations + ctx.Migrations().Register("user", userMigrations) + + // 2. Initialize and provide UserService + if p.userSvc == nil { + p.userSvc = newUserService(ctx.Events()) + } + core.Provide[contracts.UserService](ctx, p.userSvc) + + // 3. Register HTTP Routes + userGroup := ctx.Router().Group("/api/v1/user") + { + userGroup.POST("/login", Login) + userGroup.POST("/register", Register) + userGroup.GET("/logout", Logout) + userGroup.POST("/send-email-code", SendEmailCode) + userGroup.POST("/change-password", loginMW, ChangePassword) + userGroup.PUT("/profile", loginMW, UpdateProfile) + + // Access Tokens + tokensGroup := userGroup.Group("/access-tokens", loginMW, noTokenMW) + { + tokensGroup.GET("", ListAccessTokens) + tokensGroup.POST("", CreateAccessToken) + tokensGroup.DELETE("/:id", DeleteAccessToken) + tokensGroup.POST("/:id/rotate", RotateAccessToken) + } + } + + const ( + defaultUserTaskRetry = 3 + paramTypeString = "string" + paramNameEmail = "email" + ) + + // 4. Register background tasks + ctx.Task().Register("user:send_email_code", func(_ context.Context, _ []byte) error { + return nil + }, + extpoints.WithTaskType("send_email_code"), + extpoints.WithTaskName("发送邮箱验证码"), + extpoints.WithTaskDescription("异步发送用户注册与验证邮箱验证码"), + extpoints.WithTaskCategory("user"), + extpoints.WithTaskRetry(defaultUserTaskRetry), + extpoints.WithTaskQueue("default"), + extpoints.WithTaskRetryable(true), + extpoints.WithTaskParams( + contracts.TaskParamDTO{ + Name: paramNameEmail, + Label: "目标邮箱", + Type: paramTypeString, + Required: true, + Placeholder: "user@example.com", + Description: "接收验证码的目标邮箱", + }, + contracts.TaskParamDTO{ + Name: "code", + Label: "验证码", + Type: paramTypeString, + Required: true, + Placeholder: "123456", + Description: "6 位数字验证码", + }, + ), + ) + + ctx.Task().Register("mail:send", func(_ context.Context, _ []byte) error { + return nil + }, + extpoints.WithTaskType("send_email"), + extpoints.WithTaskName("发送邮件"), + extpoints.WithTaskDescription("异步发送系统邮件"), + extpoints.WithTaskCategory("mail"), + extpoints.WithTaskRetry(defaultUserTaskRetry), + extpoints.WithTaskQueue("default"), + extpoints.WithTaskRetryable(true), + extpoints.WithTaskParams( + contracts.TaskParamDTO{ + Name: "to", + Label: "接收邮箱 (To)", + Type: paramTypeString, + Required: true, + Placeholder: "receiver@example.com", + Description: "接收邮件的目标邮箱地址", + }, + contracts.TaskParamDTO{ + Name: "subject", + Label: "邮件主题 (Subject)", + Type: paramTypeString, + Required: true, + Placeholder: "请输入邮件主题", + Description: "发送邮件的主题标题", + }, + contracts.TaskParamDTO{ + Name: "body", + Label: "邮件内容 (Body)", + Type: "text", + Required: true, + Placeholder: "请输入邮件内容(支持 HTML格式)", + Description: "发送邮件的内容主体", + }, + ), + ) + + ctx.Task().Register("user:cleanup_inactive", func(_ context.Context, _ []byte) error { + return nil + }, + extpoints.WithTaskType("cleanup_inactive_users"), + extpoints.WithTaskName("清理未激活用户"), + extpoints.WithTaskDescription("清理长期未激活的注册用户与临时凭据"), + extpoints.WithTaskCategory("user"), + extpoints.WithTaskQueue("default"), + ) + + // 5. Register Settings Schemas + ctx.Settings().Register(extpoints.SettingSchema{ + Key: "user.registration_enabled", + Default: true, + Description: "Whether new user registration is enabled", + Type: "boolean", + Category: "general", + Public: true, + }) + ctx.Settings().Register(extpoints.SettingSchema{ + Key: "user.password_login_enabled", + Default: true, + Description: "Whether password login is enabled", + Type: "boolean", + Category: "general", + Public: true, + }) + ctx.Settings().Register(extpoints.SettingSchema{ + Key: "user.min_password_length", + Default: 8, + Description: "Minimum password length required for user accounts", + Type: "integer", + Category: "security", + }) + + return nil +} diff --git a/backend/plugins/domain/user/plugin_test.go b/backend/plugins/domain/user/plugin_test.go new file mode 100644 index 00000000..a5a03783 --- /dev/null +++ b/backend/plugins/domain/user/plugin_test.go @@ -0,0 +1,200 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package user_test + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "Wavelet/pkg/idgen" + "Wavelet/plugins/domain/user" + "bytes" + "context" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/cookie" + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + + database "Wavelet/plugins/infra/database" +) + +func setupTestDB(t *testing.T) *gorm.DB { + t.Helper() + _ = idgen.Init(1) + dbPath := filepath.Join(t.TempDir(), "user_test.db") + testDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + require.NoError(t, err) + + require.NoError(t, testDB.AutoMigrate( + &user.User{}, + &user.AccessToken{}, + )) + + database.SetDB(testDB) + return testDB +} + +func TestUserPluginUnit(t *testing.T) { + ctx := core.NewContext(context.Background()) + ctx.Config().SetSource(core.NewMapSource(nil)) + require.NoError(t, ctx.Config().Resolve()) + testDB := setupTestDB(t) + + dbPlugin := database.New(database.WithDB(testDB)) + require.NoError(t, dbPlugin.Apply(ctx)) + + p := user.New() + assert.Equal(t, "user", p.Name()) + assert.Equal(t, "1.0.0", p.Manifest().Version) + require.NoError(t, p.Apply(ctx)) + + userSvc, err := core.Inject[contracts.UserService](ctx) + require.NoError(t, err) + require.NotNil(t, userSvc) + + testCtx := context.Background() + + // 1. Create User + u, err := userSvc.CreateUser(testCtx, contracts.CreateUserRequest{ + Username: "charlie", + Password: "Password789!", + Email: "charlie@example.com", + }) + require.NoError(t, err) + assert.Equal(t, "charlie", u.Username) + + // 2. Empty username error + _, err = userSvc.CreateUser(testCtx, contracts.CreateUserRequest{}) + assert.Error(t, err) + + // 3. Verify Password + assert.True(t, userSvc.VerifyPassword(testCtx, u.ID, "Password789!")) + assert.False(t, userSvc.VerifyPassword(testCtx, u.ID, "Wrong")) + + // 4. Update Password with wrong old password + err = userSvc.UpdatePassword(testCtx, u.ID, "WrongOld", "NewPass999!") + assert.Error(t, err) + + // Update Password success + err = userSvc.UpdatePassword(testCtx, u.ID, "Password789!", "NewPass999!") + require.NoError(t, err) + assert.True(t, userSvc.VerifyPassword(testCtx, u.ID, "NewPass999!")) + + // 5. Update Profile + nickname := "Charlie Brown" + email := "charlie.new@example.com" + gender := "male" + website := "https://charlie.me" + loc := "SF" + updated, err := userSvc.UpdateProfile(testCtx, u.ID, contracts.UpdateUserProfileRequest{ + Nickname: &nickname, + Email: &email, + Gender: &gender, + Website: &website, + Location: &loc, + }) + require.NoError(t, err) + assert.Equal(t, "Charlie Brown", updated.Nickname) + assert.Equal(t, "charlie.new@example.com", updated.Email) + assert.Equal(t, "male", updated.Gender) + assert.Equal(t, "https://charlie.me", updated.Website) + assert.Equal(t, "SF", updated.Location) + + // 6. List and Status + require.NoError(t, userSvc.SetUserAdmin(testCtx, u.ID, true)) + require.NoError(t, userSvc.SetUserActive(testCtx, u.ID, true)) + + list, total, err := userSvc.ListUsers(testCtx, 1, 10, "") + require.NoError(t, err) + assert.GreaterOrEqual(t, total, int64(1)) + assert.NotEmpty(t, list) +} + +func TestUserLoginHTTPHandler(t *testing.T) { + ctx := core.NewContext(context.Background()) + ctx.Config().SetSource(core.NewMapSource(nil)) + require.NoError(t, ctx.Config().Resolve()) + testDB := setupTestDB(t) + + dbPlugin := database.New(database.WithDB(testDB)) + require.NoError(t, dbPlugin.Apply(ctx)) + + p := user.New() + require.NoError(t, p.Apply(ctx)) + + userSvc, err := core.Inject[contracts.UserService](ctx) + require.NoError(t, err) + + _, err = userSvc.CreateUser(context.Background(), contracts.CreateUserRequest{ + Username: "admin", + Password: "Password123!", + Email: "admin@example.com", + }) + require.NoError(t, err) + + r := gin.New() + cookieStore := cookie.NewStore([]byte("test-session-secret")) + r.Use(sessions.Sessions("wavelet_session", cookieStore)) + r.POST("/api/v1/user/login", user.Login) + + reqBody := `{"username":"admin","password":"Password123!"}` + req, _ := http.NewRequest(http.MethodPost, "/api/v1/user/login", bytes.NewBufferString(reqBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), `"username":"admin"`) + setCookie := w.Header().Get("Set-Cookie") + assert.NotEmpty(t, setCookie) + assert.Contains(t, setCookie, "wavelet_session=") + + // Plaintext default password seeded user + plainUser := &user.User{ + Username: "plain_admin", + Password: "12345678", // Plaintext seed + Email: "plain@example.com", + IsActive: true, + } + require.NoError(t, user.CreateUser(context.Background(), plainUser)) + + reqBodyPlain := `{"username":"plain_admin","password":"12345678"}` + reqPlain, _ := http.NewRequest(http.MethodPost, "/api/v1/user/login", bytes.NewBufferString(reqBodyPlain)) + reqPlain.Header.Set("Content-Type", "application/json") + wPlain := httptest.NewRecorder() + + r.ServeHTTP(wPlain, reqPlain) + + assert.Equal(t, http.StatusOK, wPlain.Code) + assert.Contains(t, wPlain.Body.String(), `"username":"plain_admin"`) + assert.Contains(t, wPlain.Body.String(), `"need_change_password":true`) + cookieHeader := wPlain.Header().Get("Set-Cookie") + assert.NotEmpty(t, cookieHeader) + + // Change password + r.POST("/api/v1/user/change-password", user.ChangePassword) + changeBody := `{"old_password":"12345678","new_password":"NewStrongPassword123!"}` + reqChange, _ := http.NewRequest(http.MethodPost, "/api/v1/user/change-password", bytes.NewBufferString(changeBody)) + reqChange.Header.Set("Content-Type", "application/json") + reqChange.Header.Set("Cookie", cookieHeader) + wChange := httptest.NewRecorder() + + r.ServeHTTP(wChange, reqChange) + assert.Equal(t, http.StatusOK, wChange.Code) + + // Verify updated user model has encrypted password and need_change_password is false + updatedUser, err := user.GetUserByUsername(context.Background(), "plain_admin") + require.NoError(t, err) + assert.False(t, updatedUser.IsPlaintextPassword()) + assert.True(t, updatedUser.CheckPassword("NewStrongPassword123!")) + assert.False(t, updatedUser.NeedChangePassword) +} diff --git a/backend/plugins/domain/user/repository.go b/backend/plugins/domain/user/repository.go new file mode 100644 index 00000000..2bb02a9e --- /dev/null +++ b/backend/plugins/domain/user/repository.go @@ -0,0 +1,366 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package user + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "Wavelet/pkg/util" + "context" + "strings" + "sync" + + "gorm.io/gorm" +) + +var ( + dbMu sync.RWMutex + dbSvc contracts.DBService +) + +// SetDBService sets the active DBService contract for the user domain plugin. +func SetDBService(s contracts.DBService) { + dbMu.Lock() + defer dbMu.Unlock() + dbSvc = s +} + +func getDB(ctx context.Context) *gorm.DB { + if c, ok := ctx.(*core.Context); ok && c != nil { + if s, err := core.Inject[contracts.DBService](c); err == nil && s != nil { + return s.DB(ctx) + } + } + + dbMu.RLock() + s := dbSvc + dbMu.RUnlock() + if s != nil { + return s.DB(ctx) + } + + return nil +} + +// GetUserByID 通过 ID 获取用户 +func GetUserByID(ctx context.Context, id uint64) (*User, error) { + var u User + if err := getDB(ctx).First(&u, id).Error; err != nil { + return nil, err + } + return &u, nil +} + +// GetUsersByIDs 一次性批量获取多个用户,避免调用方按 ID 逐条查询。 +func GetUsersByIDs(ctx context.Context, ids []uint64) ([]User, error) { + if len(ids) == 0 { + return []User{}, nil + } + var users []User + if err := getDB(ctx).Where("id IN ?", ids).Find(&users).Error; err != nil { + return nil, err + } + return users, nil +} + +// GetUserByUsername 通过用户名获取用户 +func GetUserByUsername(ctx context.Context, username string) (*User, error) { + var u User + if err := getDB(ctx).Where("username = ?", username).First(&u).Error; err != nil { + return nil, err + } + return &u, nil +} + +// GetUserByEmail 通过邮箱获取用户 +func GetUserByEmail(ctx context.Context, email string) (*User, error) { + var u User + if err := getDB(ctx).Where("email = ?", email).First(&u).Error; err != nil { + return nil, err + } + return &u, nil +} + +// CreateUser 创建用户 +func CreateUser(ctx context.Context, u *User) error { + return getDB(ctx).Create(u).Error +} + +// UpdateUser 更新用户 +func UpdateUser(ctx context.Context, u *User) error { + return getDB(ctx).Save(u).Error +} + +// ListUsers 分页查询用户 +func ListUsers(ctx context.Context, page, pageSize int, keyword string) ([]*User, int64, error) { + db := getDB(ctx).Model(&User{}) + if keyword != "" { + escaped := util.EscapeLike(keyword) + db = db.Where("username LIKE ? ESCAPE '\\' OR nickname LIKE ? ESCAPE '\\' OR email LIKE ? ESCAPE '\\'", "%"+escaped+"%", "%"+escaped+"%", "%"+escaped+"%") + } + + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + + var users []*User + offset := (page - 1) * pageSize + if err := db.Offset(offset).Limit(pageSize).Order("id DESC").Find(&users).Error; err != nil { + return nil, 0, err + } + return users, total, nil +} + +// GetAccessTokenByHash 通过 Hash 查询访问令牌 +func GetAccessTokenByHash(ctx context.Context, tokenHash string) (*AccessToken, error) { + var token AccessToken + if err := getDB(ctx).Where("token_hash = ?", tokenHash).First(&token).Error; err != nil { + return nil, err + } + return &token, nil +} + +// AdminUserListFilter 包含后台用户列表过滤条件 +type AdminUserListFilter struct { + Username string + Keyword string + Page int + PageSize int +} + +// ListAdminUsers 获取后台管理用户列表 +func ListAdminUsers(ctx context.Context, filter AdminUserListFilter) (int64, []User, error) { + query := getDB(ctx).Model(&User{}) + if filter.Username != "" { + escaped := util.EscapeLike(strings.ToLower(filter.Username)) + query = query.Where("LOWER(username) LIKE ? ESCAPE '\\'", "%"+escaped+"%") + } + if filter.Keyword != "" { + escaped := util.EscapeLike(strings.ToLower(filter.Keyword)) + query = query.Where("LOWER(username) LIKE ? ESCAPE '\\' OR LOWER(nickname) LIKE ? ESCAPE '\\' OR LOWER(email) LIKE ? ESCAPE '\\'", + "%"+escaped+"%", "%"+escaped+"%", "%"+escaped+"%") + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return 0, nil, err + } + + var users []User + offset := (filter.Page - 1) * filter.PageSize + if err := query.Order("id DESC").Offset(offset).Limit(filter.PageSize).Find(&users).Error; err != nil { + return 0, nil, err + } + return total, users, nil +} + +// UpdateUserActive 更新用户激活状态 +func UpdateUserActive(ctx context.Context, id uint64, active bool) error { + return getDB(ctx).Model(&User{}).Where("id = ?", id).Update("is_active", active).Error +} + +// GetActiveUserByID 获取处于激活状态的用户 +func GetActiveUserByID(ctx context.Context, id uint64) (*User, error) { + var u User + if err := getDB(ctx).Where("id = ? AND is_active = ?", id, true).First(&u).Error; err != nil { + return nil, err + } + return &u, nil +} + +// DeleteUserWithRelations 删除用户及其级联关系 +func DeleteUserWithRelations(ctx context.Context, id uint64) error { + return getDB(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("user_id = ?", id).Delete(&AccessToken{}).Error; err != nil { + return err + } + return tx.Where("id = ?", id).Delete(&User{}).Error + }) +} + +// GetFirstAdminUser 获取第一个管理员用户 +func GetFirstAdminUser(ctx context.Context) (*User, error) { + var u User + if err := getDB(ctx).Where("is_admin = ?", true).Order("id ASC").First(&u).Error; err != nil { + return nil, err + } + return &u, nil +} + +// ListUsernamesMatchingBase 列出匹配基础用户名的所有用户名 +func ListUsernamesMatchingBase(ctx context.Context, base string) ([]string, error) { + var usernames []string + escaped := util.EscapeLike(strings.ToLower(base)) + if err := getDB(ctx).Model(&User{}). + Where("LOWER(username) LIKE ? ESCAPE '\\'", escaped+"%"). + Pluck("username", &usernames).Error; err != nil { + return nil, err + } + return usernames, nil +} + +// adminUserColumns 后台用户查询显式列清单 +const adminUserColumns = "id, username, nickname, email, avatar_url, is_active, is_admin, bio, phone, gender, website, location, last_login_at, created_at, updated_at" + +// updateUserColumns 按列局部更新指定用户行 +func updateUserColumns(ctx context.Context, id uint64, updates map[string]any) error { + return getDB(ctx).Model(&User{}).Where("id = ?", id).Updates(updates).Error +} + +// setUserAdminFlag 更新指定用户的管理员标记列 +func setUserAdminFlag(ctx context.Context, id uint64, admin bool) error { + return getDB(ctx).Model(&User{}).Where("id = ?", id).Update("is_admin", admin).Error +} + +// setUserActiveColumn 后台启用/禁用用户,沿用无 schema 的按表直写语义 +// (与导出函数 UpdateUserActive 不同,后者经由 Model 会额外刷新 updated_at) +func setUserActiveColumn(ctx context.Context, id uint64, active bool) error { + return getDB(ctx).Table("w_users").Where("id = ?", id).Update("is_active", active).Error +} + +// countAllUsers 统计用户总数 +func countAllUsers(ctx context.Context) (int64, error) { + var count int64 + err := getDB(ctx).Model(&User{}).Count(&count).Error + return count, err +} + +// countActiveUsers 统计激活状态用户数 +func countActiveUsers(ctx context.Context) (int64, error) { + var count int64 + err := getDB(ctx).Model(&User{}).Where("is_active = ?", true).Count(&count).Error + return count, err +} + +// countUsersByUsername 统计同名用户数量 +func countUsersByUsername(ctx context.Context, username string) (int64, error) { + var count int64 + err := getDB(ctx).Table("w_users").Where("username = ?", username).Count(&count).Error + return count, err +} + +// countUsersByEmail 统计同邮箱用户数量 +func countUsersByEmail(ctx context.Context, email string) (int64, error) { + var count int64 + err := getDB(ctx).Table("w_users").Where("email = ?", email).Count(&count).Error + return count, err +} + +// countOtherUsersByEmail 统计除指定用户外的同邮箱数量 +func countOtherUsersByEmail(ctx context.Context, email string, id uint64) (int64, error) { + var count int64 + err := getDB(ctx).Table("w_users").Where("email = ? AND id != ?", email, id).Count(&count).Error + return count, err +} + +// adminListUserRows 后台条件分页查询用户 +func adminListUserRows(ctx context.Context, filter contracts.AdminListUsersFilter) (int64, []*contracts.UserDTO, error) { + query := getDB(ctx).Table("w_users") + if filter.UserID != nil { + query = query.Where("id = ?", *filter.UserID) + } + if filter.Username != "" { + query = query.Where("username LIKE ? ESCAPE '\\'", util.EscapeLike(filter.Username)+"%") + } + if filter.Email != "" { + query = query.Where("email LIKE ? ESCAPE '\\'", util.EscapeLike(filter.Email)+"%") + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return 0, nil, err + } + + var users []*contracts.UserDTO + offset := (filter.Page - 1) * filter.PageSize + if err := query. + Select(adminUserColumns). + Order("id ASC"). + Offset(offset). + Limit(filter.PageSize). + Find(&users).Error; err != nil { + return 0, nil, err + } + return total, users, nil +} + +// adminGetUserRow 后台按 ID 读取用户视图 +func adminGetUserRow(ctx context.Context, id uint64) (*contracts.UserDTO, error) { + var user contracts.UserDTO + if err := getDB(ctx).Table("w_users"). + Select(adminUserColumns). + Where("id = ?", id). + First(&user).Error; err != nil { + return nil, err + } + return &user, nil +} + +// getUserRow 按 ID 整行读取用户视图 +func getUserRow(ctx context.Context, id uint64) (*contracts.UserDTO, error) { + var user contracts.UserDTO + if err := getDB(ctx).Table("w_users").Where("id = ?", id).First(&user).Error; err != nil { + return nil, err + } + return &user, nil +} + +// insertUserRow 以显式列映射写入用户行 +func insertUserRow(ctx context.Context, row map[string]any) error { + return getDB(ctx).Table("w_users").Create(row).Error +} + +// getUserAdminFlags 读取指定用户的管理员标记 +func getUserAdminFlags(ctx context.Context, id uint64) (userAdminFlags, error) { + var flags userAdminFlags + if err := getDB(ctx).Table("w_users").Select("id, is_admin").Where("id = ?", id).First(&flags).Error; err != nil { + return userAdminFlags{}, err + } + return flags, nil +} + +// deleteUserCascadeAdmin 在事务中删除用户的访问令牌、外部账号绑定与用户行 +func deleteUserCascadeAdmin(ctx context.Context, id uint64) error { + return getDB(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Table("w_access_tokens").Where("user_id = ?", id).Delete(map[string]any{}).Error; err != nil { + return err + } + if err := tx.Table("w_external_accounts").Where("user_id = ?", id).Delete(map[string]any{}).Error; err != nil { + return err + } + return tx.Table("w_users").Where("id = ?", id).Delete(map[string]any{}).Error + }) +} + +// listAccessTokensByUser 列出指定用户的访问令牌 +func listAccessTokensByUser(ctx context.Context, userID uint64) ([]AccessToken, error) { + var tokens []AccessToken + err := getDB(ctx).Where("user_id = ?", userID).Find(&tokens).Error + return tokens, err +} + +// getAccessTokenOfUser 按 ID 与所属用户读取访问令牌 +func getAccessTokenOfUser(ctx context.Context, id, userID uint64) (*AccessToken, error) { + var token AccessToken + if err := getDB(ctx).Where("id = ? AND user_id = ?", id, userID).First(&token).Error; err != nil { + return nil, err + } + return &token, nil +} + +// createAccessTokenRow 写入访问令牌记录 +func createAccessTokenRow(ctx context.Context, token *AccessToken) error { + return getDB(ctx).Create(token).Error +} + +// saveAccessTokenRow 全量保存访问令牌记录 +func saveAccessTokenRow(ctx context.Context, token *AccessToken) error { + return getDB(ctx).Save(token).Error +} + +// deleteAccessTokenRow 删除访问令牌记录 +func deleteAccessTokenRow(ctx context.Context, token *AccessToken) error { + return getDB(ctx).Delete(token).Error +} diff --git a/backend/plugins/domain/user/service.go b/backend/plugins/domain/user/service.go new file mode 100644 index 00000000..ee31ed04 --- /dev/null +++ b/backend/plugins/domain/user/service.go @@ -0,0 +1,494 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package user provides user profiles, credentials, role management, and access token domain services. +package user + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "Wavelet/pkg/idgen" + "context" + "errors" + "fmt" + "strings" + "time" + + pkgu "Wavelet/pkg/util" +) + +const columnUpdatedAt = "updated_at" + +func toUserDTO(u *User) *contracts.UserDTO { + if u == nil { + return nil + } + return &contracts.UserDTO{ + ID: u.ID, + Username: u.Username, + Nickname: u.Nickname, + Email: u.Email, + AvatarURL: u.AvatarURL, + IsActive: u.IsActive, + IsAdmin: u.IsAdmin, + NeedChangePassword: u.NeedChangePassword || u.IsPlaintextPassword(), + Bio: u.Bio, + Phone: u.Phone, + Gender: u.Gender, + Website: u.Website, + Location: u.Location, + LastLoginAt: u.LastLoginAt, + CreatedAt: u.CreatedAt, + UpdatedAt: u.UpdatedAt, + } +} + +type userServiceImpl struct { + events *core.EventBus +} + +func newUserService(events ...*core.EventBus) contracts.UserService { + var bus *core.EventBus + if len(events) > 0 { + bus = events[0] + } + return &userServiceImpl{events: bus} +} + +func (s *userServiceImpl) GetUserByID(ctx context.Context, id uint64) (*contracts.UserDTO, error) { + u, err := GetUserByID(ctx, id) + if err != nil { + return nil, err + } + return toUserDTO(u), nil +} + +func (s *userServiceImpl) GetUsersByIDs(ctx context.Context, ids []uint64) ([]*contracts.UserDTO, error) { + users, err := GetUsersByIDs(ctx, ids) + if err != nil { + return nil, err + } + dtos := make([]*contracts.UserDTO, 0, len(users)) + for i := range users { + dtos = append(dtos, toUserDTO(&users[i])) + } + return dtos, nil +} + +func (s *userServiceImpl) GetUserByUsername(ctx context.Context, username string) (*contracts.UserDTO, error) { + u, err := GetUserByUsername(ctx, username) + if err != nil { + return nil, err + } + return toUserDTO(u), nil +} + +func (s *userServiceImpl) GetUserByEmail(ctx context.Context, email string) (*contracts.UserDTO, error) { + u, err := GetUserByEmail(ctx, email) + if err != nil { + return nil, err + } + return toUserDTO(u), nil +} + +func (s *userServiceImpl) CreateUser(ctx context.Context, req contracts.CreateUserRequest) (*contracts.UserDTO, error) { + if req.Username == "" { + return nil, errors.New(errServiceUsernameEmpty) + } + + user := User{ + ID: idgen.NextUint64ID(), + Username: req.Username, + Nickname: req.Nickname, + Email: req.Email, + IsActive: true, + IsAdmin: req.IsAdmin, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + LastLoginAt: time.Now(), + } + + if user.Nickname == "" { + user.Nickname = req.Username + } + + if req.Password != "" { + if err := user.SetEncryptedPassword(req.Password); err != nil { + return nil, err + } + } + + if err := CreateUser(ctx, &user); err != nil { + return nil, err + } + + return toUserDTO(&user), nil +} + +func (s *userServiceImpl) UpdateProfile(ctx context.Context, id uint64, req contracts.UpdateUserProfileRequest) (*contracts.UserDTO, error) { + updates := make(map[string]any) + if req.Nickname != nil { + updates["nickname"] = *req.Nickname + } + if req.Email != nil { + updates["email"] = *req.Email + } + if req.AvatarURL != nil { + updates["avatar_url"] = *req.AvatarURL + } + if req.Bio != nil { + updates["bio"] = *req.Bio + } + if req.Phone != nil { + updates["phone"] = *req.Phone + } + if req.Gender != nil { + updates["gender"] = *req.Gender + } + if req.Website != nil { + updates["website"] = *req.Website + } + if req.Location != nil { + updates["location"] = *req.Location + } + updates[columnUpdatedAt] = time.Now() + + if err := updateUserColumns(ctx, id, updates); err != nil { + return nil, err + } + + return s.GetUserByID(ctx, id) +} + +func (s *userServiceImpl) UpdatePassword(ctx context.Context, id uint64, oldPassword, newPassword string) error { + user, err := GetUserByID(ctx, id) + if err != nil { + return err + } + + if !user.CheckPassword(oldPassword) { + return errors.New(errServiceOldPasswordIncorrect) + } + + if err := user.SetEncryptedPassword(newPassword); err != nil { + return err + } + + if err := updateUserColumns(ctx, id, map[string]any{ + "password": user.Password, + columnUpdatedAt: time.Now(), + }); err != nil { + return err + } + invalidateUserCache(ctx, id) + return nil +} + +func (s *userServiceImpl) VerifyPassword(ctx context.Context, id uint64, password string) bool { + user, err := GetUserByID(ctx, id) + if err != nil { + pkgu.DummyCheckPassword(password) + return false + } + return user.CheckPassword(password) +} + +func (s *userServiceImpl) UpdateLastLogin(ctx context.Context, id uint64, _ string) error { + return updateUserColumns(ctx, id, map[string]any{ + "last_login_at": time.Now(), + columnUpdatedAt: time.Now(), + }) +} + +func (s *userServiceImpl) ListUsers(ctx context.Context, page, pageSize int, keyword string) ([]*contracts.UserDTO, int64, error) { + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 20 + } + + filter := AdminUserListFilter{ + Username: keyword, + Page: page, + PageSize: pageSize, + } + + total, users, err := ListAdminUsers(ctx, filter) + if err != nil { + return nil, 0, err + } + + dtos := make([]*contracts.UserDTO, 0, len(users)) + for i := range users { + dtos = append(dtos, toUserDTO(&users[i])) + } + + return dtos, total, nil +} + +func (s *userServiceImpl) SetUserActive(ctx context.Context, id uint64, active bool) error { + return UpdateUserActive(ctx, id, active) +} + +func (s *userServiceImpl) SetUserAdmin(ctx context.Context, id uint64, admin bool) error { + return setUserAdminFlag(ctx, id, admin) +} + +func (s *userServiceImpl) VerifyAccessToken(ctx context.Context, tokenHash string) (*contracts.UserDTO, bool, error) { + tokenRecord, err := GetAccessTokenByHash(ctx, tokenHash) + if err != nil { + return nil, false, err + } + + user, err := GetActiveUserByID(ctx, tokenRecord.UserID) + if err != nil { + return nil, false, err + } + + return toUserDTO(user), tokenRecord.IsAdmin, nil +} + +func (s *userServiceImpl) DeleteUser(ctx context.Context, id uint64) error { + return DeleteUserWithRelations(ctx, id) +} + +func (s *userServiceImpl) CountUsers(ctx context.Context) (int64, error) { + return countAllUsers(ctx) +} + +func (s *userServiceImpl) CountActiveUsers(ctx context.Context) (int64, error) { + return countActiveUsers(ctx) +} + +func (s *userServiceImpl) GetFirstAdminUser(ctx context.Context) (*contracts.UserDTO, error) { + u, err := GetFirstAdminUser(ctx) + if err != nil { + return nil, err + } + return toUserDTO(u), nil +} + +func (s *userServiceImpl) UniqueUsername(ctx context.Context, base string) (string, error) { + base = strings.TrimSpace(base) + if base == "" { + base = PluginName + } + + existingUsernames, err := ListUsernamesMatchingBase(ctx, base) + if err != nil { + return "", err + } + + exists := make(map[string]bool, len(existingUsernames)) + for _, u := range existingUsernames { + exists[strings.ToLower(u)] = true + } + + if !exists[strings.ToLower(base)] { + return base, nil + } + + for i := 1; i <= 1000; i++ { + candidate := fmt.Sprintf("%s-%d", base, i) + if !exists[strings.ToLower(candidate)] { + return candidate, nil + } + } + + return "", errors.New(errUniqueUsernameFailed) +} + +func (s *userServiceImpl) AdminListUsers(ctx context.Context, filter contracts.AdminListUsersFilter) (int64, []*contracts.UserDTO, error) { + if filter.Page <= 0 { + filter.Page = 1 + } + if filter.PageSize <= 0 { + filter.PageSize = 20 + } + return adminListUserRows(ctx, filter) +} + +func (s *userServiceImpl) AdminGetUser(ctx context.Context, id uint64) (*contracts.UserDTO, error) { + return adminGetUserRow(ctx, id) +} + +func (s *userServiceImpl) AdminCreateUser(ctx context.Context, req contracts.AdminCreateUserRequest) (*contracts.UserDTO, error) { + req.Username = strings.TrimSpace(req.Username) + req.Nickname = strings.TrimSpace(req.Nickname) + req.Password = strings.TrimSpace(req.Password) + req.Email = strings.TrimSpace(req.Email) + + if req.Username == "" { + return nil, errors.New(errUsernameEmpty) + } + if req.Email == "" { + return nil, errors.New(errEmailEmpty) + } + const minPasswordLen = 8 + if len(req.Password) < minPasswordLen { + return nil, errors.New(errServicePasswordTooShort) + } + + count, err := countUsersByUsername(ctx, req.Username) + if err != nil { + return nil, err + } + if count > 0 { + return nil, errors.New(errUsernameTaken) + } + + emailCount, err := countUsersByEmail(ctx, req.Email) + if err != nil { + return nil, err + } + if emailCount > 0 { + return nil, errors.New(errEmailTaken) + } + + hash, err := pkgu.HashPassword(req.Password) + if err != nil { + return nil, err + } + + if req.Nickname == "" { + req.Nickname = req.Username + } + + now := time.Now() + newUser := contracts.UserDTO{ + ID: idgen.NextUint64ID(), + Username: req.Username, + Nickname: req.Nickname, + Email: req.Email, + IsActive: req.IsActive, + IsAdmin: req.IsAdmin, + CreatedAt: now, + UpdatedAt: now, + } + + row := map[string]any{ + "id": newUser.ID, + "username": newUser.Username, + "password": hash, + "nickname": newUser.Nickname, + "email": newUser.Email, + "is_active": newUser.IsActive, + "is_admin": newUser.IsAdmin, + "created_at": now, + columnUpdatedAt: now, + } + if err := insertUserRow(ctx, row); err != nil { + return nil, err + } + + if s.events != nil { + _ = s.events.Emit(ctx, contracts.EventTopicUserCreated, contracts.UserCreatedEvent{ + User: &newUser, + Password: req.Password, + }) + } + + return &newUser, nil +} + +func (s *userServiceImpl) AdminUpdateUser(ctx context.Context, currentUserID uint64, req contracts.AdminUpdateUserRequest) error { + req.Nickname = strings.TrimSpace(req.Nickname) + req.Email = strings.TrimSpace(req.Email) + req.Password = strings.TrimSpace(req.Password) + + if req.Email == "" { + return errors.New(errEmailEmpty) + } + + targetUser, err := getUserRow(ctx, req.ID) + if err != nil { + return err + } + + if currentUserID == req.ID && !req.IsAdmin && targetUser.IsAdmin { + return errors.New(errCannotRevokeSelf) + } + + if targetUser.Email != req.Email { + count, err := countOtherUsersByEmail(ctx, req.Email, req.ID) + if err != nil { + return err + } + if count > 0 { + return errors.New(errEmailTaken) + } + } + + const minPasswordLen = 8 + if req.Password != "" && len(req.Password) < minPasswordLen { + return errors.New(errServicePasswordTooShort) + } + + if req.Nickname == "" { + req.Nickname = targetUser.Username + } + + updates := map[string]any{ + "nickname": req.Nickname, + "email": req.Email, + "is_admin": req.IsAdmin, + columnUpdatedAt: time.Now(), + } + if req.Password != "" { + hash, err := pkgu.HashPassword(req.Password) + if err != nil { + return err + } + updates["password"] = hash + } + + err = updateUserColumns(ctx, req.ID, updates) + if err == nil && s.events != nil { + _ = s.events.Emit(ctx, contracts.EventTopicUserUpdated, targetUser) + } + return err +} + +func (s *userServiceImpl) AdminUpdateUserStatus(ctx context.Context, id uint64, active bool) error { + flags, err := getUserAdminFlags(ctx, id) + if err != nil { + return err + } + if !active && flags.IsAdmin { + return errors.New(errAdminCannotDisable) + } + + err = setUserActiveColumn(ctx, id, active) + if err == nil && s.events != nil { + _ = s.events.Emit(ctx, contracts.EventTopicUserStatusChanged, contracts.UserStatusChangedEvent{ + UserID: id, + IsActive: active, + }) + } + return err +} + +func (s *userServiceImpl) AdminDeleteUser(ctx context.Context, currentUserID, targetID uint64) error { + if currentUserID == targetID { + return errors.New(errCannotDeleteSelf) + } + flags, err := getUserAdminFlags(ctx, targetID) + if err != nil { + return err + } + if flags.IsAdmin { + return errors.New(errAdminCannotDelete) + } + + if err := deleteUserCascadeAdmin(ctx, targetID); err != nil { + return err + } + if s.events != nil { + _ = s.events.Emit(ctx, contracts.EventTopicUserDeleted, contracts.UserDeletedEvent{ + CurrentUserID: currentUserID, + TargetUserID: targetID, + }) + } + return nil +} diff --git a/backend/plugins/domain/user/users_by_ids_test.go b/backend/plugins/domain/user/users_by_ids_test.go new file mode 100644 index 00000000..2c71ce14 --- /dev/null +++ b/backend/plugins/domain/user/users_by_ids_test.go @@ -0,0 +1,67 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package user_test + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "Wavelet/plugins/domain/user" + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + + database "Wavelet/plugins/infra/database" +) + +// TestGetUsersByIDsUsesSingleQuery 回归:批量取用户必须只发一条 SQL, +// 否则调用方(如访问日志按用户补全)会按 ID 逐条打库。 +func TestGetUsersByIDsUsesSingleQuery(t *testing.T) { + ctx := core.NewContext(context.Background()) + ctx.Config().SetSource(core.NewMapSource(nil)) + require.NoError(t, ctx.Config().Resolve()) + testDB := setupTestDB(t) + require.NoError(t, database.New(database.WithDB(testDB)).Apply(ctx)) + require.NoError(t, user.New().Apply(ctx)) + + userSvc, err := core.Inject[contracts.UserService](ctx) + require.NoError(t, err) + + bg := context.Background() + ids := make([]uint64, 0, 3) + for _, name := range []string{"batch_a", "batch_b", "batch_c"} { + u, err := userSvc.CreateUser(bg, contracts.CreateUserRequest{ + Username: name, + Password: "Password789!", + Email: name + "@example.com", + }) + require.NoError(t, err) + ids = append(ids, u.ID) + } + + queries := 0 + require.NoError(t, testDB.Callback().Query().Register("count_queries", func(_ *gorm.DB) { + queries++ + })) + + got, err := userSvc.GetUsersByIDs(bg, ids) + require.NoError(t, err) + assert.Len(t, got, 3) + assert.Equal(t, 1, queries, "batch lookup must issue exactly one query") + + queries = 0 + for _, id := range ids { + _, err := userSvc.GetUserByID(bg, id) + require.NoError(t, err) + } + assert.Equal(t, 3, queries, "per-id lookups cost one query each") + + queries = 0 + empty, err := userSvc.GetUsersByIDs(bg, nil) + require.NoError(t, err) + assert.Empty(t, empty) + assert.Equal(t, 0, queries, "empty id list must not touch storage") +} diff --git a/backend/plugins/drivers/driver_asynq_cron/db_helper.go b/backend/plugins/drivers/driver_asynq_cron/db_helper.go new file mode 100644 index 00000000..b4f5c7b2 --- /dev/null +++ b/backend/plugins/drivers/driver_asynq_cron/db_helper.go @@ -0,0 +1,39 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package driver_asynq_cron + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "context" + "sync" + + "gorm.io/gorm" +) + +var ( + dbMu sync.RWMutex + dbSvc contracts.DBService +) + +func setDBService(s contracts.DBService) { + dbMu.Lock() + defer dbMu.Unlock() + dbSvc = s +} + +func getDB(ctx context.Context) *gorm.DB { + if c, ok := ctx.(*core.Context); ok && c != nil { + if s, err := core.Inject[contracts.DBService](c); err == nil && s != nil { + return s.DB(ctx) + } + } + dbMu.RLock() + s := dbSvc + dbMu.RUnlock() + if s != nil { + return s.DB(ctx) + } + return nil +} diff --git a/backend/plugins/drivers/driver_asynq_cron/errs.go b/backend/plugins/drivers/driver_asynq_cron/errs.go new file mode 100644 index 00000000..1e820ecf --- /dev/null +++ b/backend/plugins/drivers/driver_asynq_cron/errs.go @@ -0,0 +1,9 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package driver_asynq_cron provides Asynq cron schedule driver plugin for Cordis. +package driver_asynq_cron + +const ( + errLoadLocationFailed = "failed to load location: %w" +) diff --git a/backend/plugins/drivers/driver_asynq_cron/plugin.go b/backend/plugins/drivers/driver_asynq_cron/plugin.go new file mode 100644 index 00000000..d822d894 --- /dev/null +++ b/backend/plugins/drivers/driver_asynq_cron/plugin.go @@ -0,0 +1,296 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package driver_asynq_cron provides the Asynq cron schedule driver plugin for Cordis. +package driver_asynq_cron + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "context" + "encoding/json" + "fmt" + "sync" + "time" + + "github.com/hibiken/asynq" +) + +// Option configures the Asynq cron scheduler driver plugin. +type Option func(*Plugin) + +// WithRedisOpt sets the Redis connection options for Asynq scheduler. +func WithRedisOpt(opt asynq.RedisConnOpt) Option { + return func(p *Plugin) { + p.redisOpt = opt + } +} + +// WithLocation sets the timezone location for the cron scheduler. +func WithLocation(loc *time.Location) Option { + return func(p *Plugin) { + p.location = loc + } +} + +// WithSchedulerOpts sets custom Asynq SchedulerOpts. +func WithSchedulerOpts(opts *asynq.SchedulerOpts) Option { + return func(p *Plugin) { + p.schedulerOpts = opts + } +} + +// WithScheduler injects a pre-configured Asynq Scheduler instance. +func WithScheduler(sched *asynq.Scheduler) Option { + return func(p *Plugin) { + p.scheduler = sched + } +} + +// Plugin implements core.Plugin and core.Driver for Asynq Cron Scheduler. +type Plugin struct { + mu sync.RWMutex + redisOpt asynq.RedisConnOpt + location *time.Location + schedulerOpts *asynq.SchedulerOpts + scheduler *asynq.Scheduler + running bool + coreCtx *core.Context +} + +// New creates a new Asynq Cron Scheduler driver plugin. +func New(opts ...Option) *Plugin { + p := &Plugin{ + redisOpt: RedisOpt, + location: time.Local, + } + + for _, opt := range opts { + if opt != nil { + opt(p) + } + } + + return p +} + +// Name returns the unique plugin identifier. +func (p *Plugin) Name() string { + return "driver_asynq_cron" +} + +type redisCronConfig struct { + Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"` + Addrs []string `config:"addrs" env:"REDIS_ADDR"` + Username string `config:"username" env:"REDIS_USERNAME"` + Password string `config:"password" env:"REDIS_PASSWORD" secret:"true"` + DB int `config:"db" env:"REDIS_DB"` +} + +// DeclareConfig declares configuration bindings for driver_asynq_cron. +func (p *Plugin) DeclareConfig() []core.ConfigBinding { + return []core.ConfigBinding{ + {Prefix: "redis", Target: &redisCronConfig{}}, + } +} + +// ConfigEnabled gates plugin activation when Redis is enabled. +func (p *Plugin) ConfigEnabled(view core.ConfigView) bool { + return view.Bool("redis.enabled", false) +} + +// Apply mounts the Asynq Cron Scheduler driver into the micro-kernel Context. +func (p *Plugin) Apply(ctx *core.Context) error { + var rCfg redisCronConfig + _ = ctx.Config().Bind("redis", &rCfg) + + p.mu.Lock() + p.coreCtx = ctx + if p.redisOpt == nil { + addr := "127.0.0.1:6379" + if len(rCfg.Addrs) > 0 && rCfg.Addrs[0] != "" { + addr = rCfg.Addrs[0] + } + p.redisOpt = asynq.RedisClientOpt{ + Addr: addr, + Username: rCfg.Username, + Password: rCfg.Password, + DB: rCfg.DB, + } + } + p.mu.Unlock() + + // Bind DBService + if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil { + setDBService(db) + } else { + core.When[contracts.DBService](ctx, func(db contracts.DBService) { + setDBService(db) + }) + } + + // Bind TaskService + if taskSvc, err := core.Inject[contracts.TaskService](ctx); err == nil && taskSvc != nil { + setTaskService(taskSvc) + } else { + core.When[contracts.TaskService](ctx, func(taskSvc contracts.TaskService) { + setTaskService(taskSvc) + }) + } + + ctx.OnDispose(func() error { + setDBService(nil) + setTaskService(nil) + return nil + }) + + ctx.OnDispose(func() error { + return p.Stop(context.Background()) + }) + + return ctx.RegisterDriver(p) +} + +// Type returns DriverTypeScheduler. +func (p *Plugin) Type() core.DriverType { + return core.DriverTypeScheduler +} + +// Start registers scheduled cron entries from Context and boots the Asynq scheduler. +func (p *Plugin) Start(_ context.Context) error { + p.mu.Lock() + defer p.mu.Unlock() + + if p.running { + return nil + } + + if p.scheduler == nil { + p.scheduler = p.initScheduler() + } + + if p.coreCtx != nil && p.coreCtx.Schedules() != nil { + for _, sd := range p.coreCtx.Schedules().Schedules() { + payloadBytes, err := encodePayload(sd.Payload) + if err != nil { + return fmt.Errorf("driver_asynq_cron: encode payload for task %q failed: %w", sd.TaskType, err) + } + + task := asynq.NewTask(sd.TaskType, payloadBytes) + asynqOpts := buildAsynqOptions(sd.Options) + + if _, err := p.scheduler.Register(sd.Spec, task, asynqOpts...); err != nil { + return fmt.Errorf("driver_asynq_cron: register schedule %q for task %q failed: %w", sd.Spec, sd.TaskType, err) + } + } + } + + if err := p.scheduler.Start(); err != nil { + return fmt.Errorf("driver_asynq_cron: start scheduler failed: %w", err) + } + + p.running = true + return nil +} + +// Stop gracefully shuts down the Asynq cron scheduler. +func (p *Plugin) Stop(_ context.Context) error { + p.mu.Lock() + defer p.mu.Unlock() + + if !p.running { + return nil + } + + p.running = false + + if p.scheduler != nil { + p.scheduler.Shutdown() + p.scheduler = nil + } + + return nil +} + +// IsRunning returns whether the scheduler is running. +func (p *Plugin) IsRunning() bool { + p.mu.RLock() + defer p.mu.RUnlock() + return p.running +} + +// Scheduler returns the underlying Asynq scheduler instance. +func (p *Plugin) Scheduler() *asynq.Scheduler { + p.mu.RLock() + defer p.mu.RUnlock() + return p.scheduler +} + +func encodePayload(payload any) ([]byte, error) { + if payload == nil { + return nil, nil + } + + switch val := payload.(type) { + case []byte: + return val, nil + case string: + return []byte(val), nil + default: + return json.Marshal(val) + } +} + +func buildAsynqOptions(opts map[string]any) []asynq.Option { + if len(opts) == 0 { + return nil + } + + var res []asynq.Option + + if q, ok := opts["queue"].(string); ok && q != "" { + res = append(res, asynq.Queue(q)) + } + + if retry, ok := opts["retry"].(int); ok { + res = append(res, asynq.MaxRetry(retry)) + } else if maxRetry, ok := opts["max_retry"].(int); ok { + res = append(res, asynq.MaxRetry(maxRetry)) + } + + if timeout, ok := opts["timeout"].(time.Duration); ok { + res = append(res, asynq.Timeout(timeout)) + } + + if retention, ok := opts["retention"].(time.Duration); ok { + res = append(res, asynq.Retention(retention)) + } + + return res +} + +func (p *Plugin) initScheduler() *asynq.Scheduler { + opts := p.schedulerOpts + if opts == nil { + opts = &asynq.SchedulerOpts{ + Location: p.location, + } + } else if opts.Location == nil && p.location != nil { + opts.Location = p.location + } + + opt := p.resolveRedisOpt() + return asynq.NewScheduler(opt, opts) +} + +func (p *Plugin) resolveRedisOpt() asynq.RedisConnOpt { + if p.redisOpt != nil { + return p.redisOpt + } + if RedisOpt != nil { + return RedisOpt + } + return asynq.RedisClientOpt{ + Addr: "127.0.0.1:6379", + } +} diff --git a/backend/plugins/drivers/driver_asynq_cron/schedule.go b/backend/plugins/drivers/driver_asynq_cron/schedule.go new file mode 100644 index 00000000..ad1e6b93 --- /dev/null +++ b/backend/plugins/drivers/driver_asynq_cron/schedule.go @@ -0,0 +1,39 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package driver_asynq_cron + +import ( + "context" + "time" +) + +// Schedule 定时任务配置表 +type Schedule struct { + ID uint64 `json:"id,string" gorm:"primaryKey"` + Name string `json:"name" gorm:"size:128;not null"` + TaskType string `json:"task_type" gorm:"size:64;not null"` + Cron string `json:"cron" gorm:"size:64;not null"` + Payload string `json:"payload" gorm:"type:text"` + IsActive bool `json:"is_active" gorm:"not null;default:true"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` +} + +// TableName 表名 +func (Schedule) TableName() string { + return "w_schedules" +} + +// ListActiveSchedules 查询所有已启用的定时任务配置 +func ListActiveSchedules(ctx context.Context) ([]Schedule, error) { + var schedules []Schedule + db := getDB(ctx) + if db == nil { + return nil, nil + } + if err := db.Where("is_active = ?", true).Find(&schedules).Error; err != nil { + return nil, err + } + return schedules, nil +} diff --git a/backend/plugins/drivers/driver_asynq_cron/scheduler.go b/backend/plugins/drivers/driver_asynq_cron/scheduler.go new file mode 100644 index 00000000..5915186c --- /dev/null +++ b/backend/plugins/drivers/driver_asynq_cron/scheduler.go @@ -0,0 +1,169 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package driver_asynq_cron + +import ( + "Wavelet/core/contracts" + "Wavelet/pkg/logger" + "context" + "fmt" + "os/signal" + "sync" + "syscall" + "time" + + "github.com/hibiken/asynq" +) + +var ( + activeScheduler *asynq.Scheduler + schedulerMutex sync.Mutex + quitChan chan struct{} + schedulerOnce sync.Once + taskSvcMu sync.RWMutex + taskSvcInstance contracts.TaskService + // RedisOpt is the Redis connection option for the scheduler. + RedisOpt asynq.RedisConnOpt +) + +func setTaskService(s contracts.TaskService) { + taskSvcMu.Lock() + defer taskSvcMu.Unlock() + taskSvcInstance = s +} + +func getTaskService() contracts.TaskService { + taskSvcMu.RLock() + defer taskSvcMu.RUnlock() + return taskSvcInstance +} + +// StartScheduler 启动调度器 (该函数阻塞,直到调度器退出) +func StartScheduler() error { + var err error + schedulerOnce.Do(func() { + quitChan = make(chan struct{}) + done := quitChan + + // 初始化并运行首次调度 + if err = ReloadScheduler(); err != nil { + err = fmt.Errorf("initial reload failed: %w", err) + return + } + + signalCtx, stopSignals := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stopSignals() + + if waitForStop(done, signalCtx.Done()) { + StopScheduler() + } + }) + return err +} + +// StopScheduler 停止调度服务并解除 StartScheduler 阻塞 +func StopScheduler() { + schedulerMutex.Lock() + defer schedulerMutex.Unlock() + + if activeScheduler != nil { + activeScheduler.Shutdown() + activeScheduler = nil + } + + if quitChan != nil { + close(quitChan) + quitChan = nil + } +} + +// ReloadScheduler 重载调度器配置 (线程安全) +func ReloadScheduler() error { + schedulerMutex.Lock() + defer schedulerMutex.Unlock() + + // 1. 如果有运行中的调度器,先关闭它 + if activeScheduler != nil { + activeScheduler.Shutdown() + activeScheduler = nil + } + + // 2. 从数据库载入启用的定时任务配置 + schedules, err := ListActiveSchedules(context.Background()) + if err != nil { + return fmt.Errorf("load schedules from db failed: %w", err) + } + + location, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + return fmt.Errorf(errLoadLocationFailed, err) + } + + // 3. 实例化新的调度器 + newScheduler := asynq.NewScheduler( + RedisOpt, + &asynq.SchedulerOpts{ + Location: location, + }, + ) + + // 4. 遍历并注册任务 + taskSvc := getTaskService() + for _, s := range schedules { + taskName, maxRetry, queue := resolveTaskScheduleMeta(taskSvc, s.TaskType) + + // 构造 Asynq 载荷 + t := asynq.NewTask(taskName, []byte(s.Payload)) + + if _, err := newScheduler.Register( + s.Cron, + t, + asynq.MaxRetry(maxRetry), + asynq.Queue(queue), + ); err != nil { + // 定时任务配置可能有误(如 Cron 格式不被 Asynq 识别),记录日志并跳过 + logger.ErrorF(context.Background(), "[Scheduler] 注册定时任务失败 id=%d name=%s: %v", s.ID, s.Name, err) + continue + } + } + + // 5. 启动并替换全局调度器。进程信号由 StartScheduler 统一处理。 + if err := newScheduler.Start(); err != nil { + return fmt.Errorf("start scheduler failed: %w", err) + } + activeScheduler = newScheduler + + logger.InfoF(context.Background(), "[Scheduler] 成功重新加载定时任务,共注册 %d 个活动任务", len(schedules)) + return nil +} + +func waitForStop(done, signals <-chan struct{}) bool { + select { + case <-done: + return false + case <-signals: + return true + } +} + +func resolveTaskScheduleMeta(taskSvc contracts.TaskService, taskType string) (name string, maxRetry int, queue string) { + name = taskType + maxRetry = 3 + queue = "default" + if taskSvc == nil { + return + } + meta, ok := taskSvc.GetTaskMeta(taskType) + if !ok { + return + } + name = meta.Name + if meta.MaxRetry > 0 { + maxRetry = meta.MaxRetry + } + if meta.Queue != "" { + queue = meta.Queue + } + return +} diff --git a/backend/plugins/drivers/driver_asynq_cron/scheduler_test.go b/backend/plugins/drivers/driver_asynq_cron/scheduler_test.go new file mode 100644 index 00000000..f591a625 --- /dev/null +++ b/backend/plugins/drivers/driver_asynq_cron/scheduler_test.go @@ -0,0 +1,42 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package driver_asynq_cron + +import "testing" + +func TestWaitForStop(t *testing.T) { + tests := []struct { + name string + closeDone bool + closeSignal bool + wantSignal bool + }{ + { + name: "explicit stop", + closeDone: true, + }, + { + name: "process signal", + closeSignal: true, + wantSignal: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + done := make(chan struct{}) + signals := make(chan struct{}) + if tt.closeDone { + close(done) + } + if tt.closeSignal { + close(signals) + } + + if got := waitForStop(done, signals); got != tt.wantSignal { + t.Errorf("waitForStop() = %t, want %t", got, tt.wantSignal) + } + }) + } +} diff --git a/backend/plugins/drivers/driver_asynq_worker/config.go b/backend/plugins/drivers/driver_asynq_worker/config.go new file mode 100644 index 00000000..efaf01cc --- /dev/null +++ b/backend/plugins/drivers/driver_asynq_worker/config.go @@ -0,0 +1,28 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package driver_asynq_worker + +type queueConfig struct { + Name string `config:"name"` + Priority int `config:"priority"` +} + +type workerConfig struct { + Concurrency int `config:"concurrency" env:"WORKER_CONCURRENCY" default:"10"` + StrictPriority bool `config:"strict_priority" env:"WORKER_STRICT_PRIORITY" default:"false"` + Queues []queueConfig `config:"queues"` +} + +type redisWorkerConfig struct { + Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"` + Addrs []string `config:"addrs" env:"REDIS_ADDR"` + Username string `config:"username" env:"REDIS_USERNAME"` + Password string `config:"password" env:"REDIS_PASSWORD" secret:"true"` + DB int `config:"db" env:"REDIS_DB"` + ClusterMode bool `config:"cluster_mode" env:"REDIS_CLUSTER_MODE"` + MasterName string `config:"master_name" env:"REDIS_MASTER_NAME"` + KeyPrefix string `config:"key_prefix" env:"REDIS_KEY_PREFIX"` + PoolSize int `config:"pool_size" env:"REDIS_POOL_SIZE"` + MaintNotifications bool `config:"maint_notifications" env:"REDIS_MAINT_NOTIFICATIONS" default:"false"` +} diff --git a/backend/plugins/drivers/driver_asynq_worker/constants.go b/backend/plugins/drivers/driver_asynq_worker/constants.go new file mode 100644 index 00000000..65f22348 --- /dev/null +++ b/backend/plugins/drivers/driver_asynq_worker/constants.go @@ -0,0 +1,12 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package driver_asynq_worker + +// 任务队列名称 +const ( + QueueDefault = "default" +) + +// DefaultMaxRetry 任务默认最大重试次数 +const DefaultMaxRetry = 3 diff --git a/backend/plugins/drivers/driver_asynq_worker/db_helper.go b/backend/plugins/drivers/driver_asynq_worker/db_helper.go new file mode 100644 index 00000000..025fbca1 --- /dev/null +++ b/backend/plugins/drivers/driver_asynq_worker/db_helper.go @@ -0,0 +1,55 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package driver_asynq_worker + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "context" + "sync" + + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +var ( + dbMu sync.RWMutex + dbSvc contracts.DBService + redisMu sync.RWMutex + rdbClient redis.UniversalClient +) + +func setDBService(s contracts.DBService) { + dbMu.Lock() + defer dbMu.Unlock() + dbSvc = s +} + +// SetRedisClient sets the redis client used for task logs. +func SetRedisClient(c redis.UniversalClient) { + redisMu.Lock() + defer redisMu.Unlock() + rdbClient = c +} + +func getDB(ctx context.Context) *gorm.DB { + if c, ok := ctx.(*core.Context); ok && c != nil { + if s, err := core.Inject[contracts.DBService](c); err == nil && s != nil { + return s.DB(ctx) + } + } + dbMu.RLock() + s := dbSvc + dbMu.RUnlock() + if s != nil { + return s.DB(ctx) + } + return nil +} + +func getRedisClient() redis.UniversalClient { + redisMu.RLock() + defer redisMu.RUnlock() + return rdbClient +} diff --git a/backend/plugins/drivers/driver_asynq_worker/errs.go b/backend/plugins/drivers/driver_asynq_worker/errs.go new file mode 100644 index 00000000..f73967c0 --- /dev/null +++ b/backend/plugins/drivers/driver_asynq_worker/errs.go @@ -0,0 +1,17 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package driver_asynq_worker + +const ( + errUnknownTaskType = "未知的任务类型: %s" + errCreateTaskExecutionFailed = "创建任务执行记录失败: %w" + errTaskEnqueueFailed = "任务入队失败: %w" + errTaskExecutionNotFound = "任务执行记录不存在: %w" + errRetryOnlyFailedTask = "只有失败的任务才能重试,当前状态: %s" + errTaskNotRetryable = "该任务不支持重试" + errTaskMaxRetryExceeded = "已达到最大重试次数 %d" + errCreateRetryExecutionFailed = "创建重试任务执行记录失败: %w" + errRetryTaskEnqueueFailed = "重试任务入队失败: %w" + errUnregisteredTaskHandler = "未注册的任务处理器: %s" +) diff --git a/backend/plugins/drivers/driver_asynq_worker/executor.go b/backend/plugins/drivers/driver_asynq_worker/executor.go new file mode 100644 index 00000000..fc8459f5 --- /dev/null +++ b/backend/plugins/drivers/driver_asynq_worker/executor.go @@ -0,0 +1,502 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package driver_asynq_worker + +import ( + "Wavelet/pkg/idgen" + "Wavelet/pkg/logger" + "Wavelet/pkg/util" + "context" + "encoding/json" + "errors" + "fmt" + "sync" + "time" + + "github.com/hibiken/asynq" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/trace" + + otel_trace "Wavelet/pkg/trace" +) + +var ( + handlerRegistryMutex sync.RWMutex + handlerRegistry = make(map[string]TaskHandler) + + completedHandlersMutex sync.RWMutex + taskCompletedHandlers []CompletedHandler +) + +// CompletedHandler is called when a task execution completes. +type CompletedHandler func(ctx context.Context, execution *TaskExecution, result *TaskResult, execErr error) + +// OnTaskCompleted registers a handler for task completion events. +// Handlers must be registered during application bootstrap before processing tasks. +func OnTaskCompleted(handler CompletedHandler) { + completedHandlersMutex.Lock() + defer completedHandlersMutex.Unlock() + taskCompletedHandlers = append(taskCompletedHandlers, handler) +} + +// RegisterHandler 注册任务处理器 +// 传入任务类型标识(对应 AsynqTask 常量)和 TaskHandler 实现 +func RegisterHandler(asynqTaskType string, handler TaskHandler) { + handlerRegistryMutex.Lock() + defer handlerRegistryMutex.Unlock() + handlerRegistry[asynqTaskType] = handler +} + +// getHandler 获取已注册的处理器 +func getHandler(asynqTaskType string) (TaskHandler, bool) { + handlerRegistryMutex.RLock() + defer handlerRegistryMutex.RUnlock() + h, ok := handlerRegistry[asynqTaskType] + return h, ok +} + +// ValidateAndNormalizePayload 校验并标准化任务参数。 +// 如果 Handler 实现了 PayloadValidator,调用其 ValidatePayload 方法; +// 否则直接返回原始 payload。 +func ValidateAndNormalizePayload(asynqTaskType string, payload []byte) ([]byte, error) { + handler, ok := getHandler(asynqTaskType) + if !ok { + return payload, nil + } + if validator, ok := handler.(PayloadValidator); ok { + return validator.ValidatePayload(payload) + } + return payload, nil +} + +// contextKey 用于 context 存取 taskID +type contextKey string + +const ( + taskIDKey contextKey = "task_execution_task_id" + + traceEnvelopeVersion = 1 +) + +type traceEnvelope struct { + WaveletTraceEnvelope bool `json:"_wavelet_trace_envelope"` + Version int `json:"version"` + TraceContext map[string]string `json:"trace_context,omitempty"` + Payload []byte `json:"payload"` +} + +// withTaskID 将 taskID 注入 context +func withTaskID(ctx context.Context, taskID string) context.Context { + return context.WithValue(ctx, taskIDKey, taskID) +} + +// GetTaskID 从 context 中获取 taskID +func GetTaskID(ctx context.Context) string { + if v, ok := ctx.Value(taskIDKey).(string); ok { + return v + } + return "" +} + +// IsFinalAttempt 判断当前任务执行是否为最后一次重试尝试(若再次失败即为最终失败) +func IsFinalAttempt(ctx context.Context) bool { + retryCount, hasRetryCount := asynq.GetRetryCount(ctx) + maxRetry, hasMaxRetry := asynq.GetMaxRetry(ctx) + if !hasRetryCount || !hasMaxRetry { + return true + } + return retryCount >= maxRetry +} + +// AppendLog 追加日志到任务执行记录 +// 在 TaskHandler.Execute 中调用,日志会自动追加到 TaskExecution.Log 字段 +func AppendLog(ctx context.Context, format string, args ...interface{}) { + taskID := GetTaskID(ctx) + if taskID == "" { + // 上下文中没有 taskID,降级到普通日志 + logger.InfoF(ctx, format, args...) + return + } + + logLine := fmt.Sprintf(format, args...) + if err := appendTaskExecutionLog(ctx, taskID, logLine); err != nil { + logger.ErrorF(ctx, "[TaskExecutor] 追加任务日志失败 taskID=%s: %v", taskID, err) + } +} + +// DispatchTask 下发任务(创建 TaskExecution 记录 → 入队 Asynq) +func DispatchTask(ctx context.Context, taskType string, payload []byte, triggeredBy string) (string, error) { + meta := GetTaskMeta(taskType) + if meta == nil { + return "", fmt.Errorf(errUnknownTaskType, taskType) + } + + // 生成唯一的 TaskID + taskID := generateTaskID(taskType, triggeredBy) + + // 创建任务执行记录 + execution := &TaskExecution{ + TaskID: taskID, + TaskType: meta.AsynqTask, + TaskName: meta.Name, + Status: TaskExecutionStatusPending, + Retryable: meta.Retryable, + MaxRetry: meta.MaxRetry, + RetryCount: 0, + Payload: string(payload), + TriggeredBy: triggeredBy, + } + + if err := createTaskExecution(ctx, execution); err != nil { + return "", fmt.Errorf(errCreateTaskExecutionFailed, err) + } + + // 入队 Asynq + taskInfo := asynq.NewTask(meta.AsynqTask, injectTaskTraceContext(ctx, payload)) + if _, err := GetAsynqClient().Enqueue( + taskInfo, + asynq.TaskID(taskID), + asynq.MaxRetry(meta.MaxRetry), + asynq.Queue(meta.Queue), + ); err != nil { + // 入队失败,更新执行记录状态 + execution.Status = TaskExecutionStatusFailed + execution.ErrorMessage = fmt.Sprintf("入队失败: %v", err) + now := time.Now() + execution.StartedAt = &now + execution.FinishedAt = &now + _ = updateTaskExecution(ctx, execution) + return "", fmt.Errorf(errTaskEnqueueFailed, err) + } + + if err := appendTaskExecutionLog(ctx, taskID, fmt.Sprintf("[系统] 任务已成功入队,等待调度执行 (队列: %s, 最大重试次数: %d)", meta.Queue, meta.MaxRetry)); err != nil { + logger.ErrorF(ctx, "[TaskExecutor] 追加入队日志失败 taskID=%s: %v", taskID, err) + } + + return taskID, nil +} + +// RetryTask 重试失败的任务 +func RetryTask(ctx context.Context, id uint64) (string, error) { + execution, err := getTaskExecutionByID(ctx, id) + if err != nil { + return "", fmt.Errorf(errTaskExecutionNotFound, err) + } + + if execution.Status != TaskExecutionStatusFailed { + return "", fmt.Errorf(errRetryOnlyFailedTask, execution.Status) + } + + if !execution.Retryable { + return "", errors.New(errTaskNotRetryable) + } + + // 生成新的 TaskID + newTaskID := generateRetryTaskID(execution.TaskID, execution.RetryCount+1) + + // 创建新的执行记录 + newExecution := &TaskExecution{ + TaskID: newTaskID, + TaskType: execution.TaskType, + TaskName: execution.TaskName, + Status: TaskExecutionStatusPending, + Retryable: execution.Retryable, + MaxRetry: execution.MaxRetry, + RetryCount: execution.RetryCount + 1, + Payload: execution.Payload, + TriggeredBy: "retry", + } + + if err := createTaskExecution(ctx, newExecution); err != nil { + return "", fmt.Errorf(errCreateRetryExecutionFailed, err) + } + + meta := GetTaskMeta(execution.TaskType) + queueName := QueueDefault + if meta != nil { + queueName = meta.Queue + } + + // 入队 Asynq + taskInfo := asynq.NewTask(execution.TaskType, injectTaskTraceContext(ctx, []byte(execution.Payload))) + if _, err := GetAsynqClient().Enqueue( + taskInfo, + asynq.TaskID(newTaskID), + asynq.MaxRetry(execution.MaxRetry), + asynq.Queue(queueName), + ); err != nil { + newExecution.Status = TaskExecutionStatusFailed + newExecution.ErrorMessage = fmt.Sprintf("重试入队失败: %v", err) + now := time.Now() + newExecution.StartedAt = &now + newExecution.FinishedAt = &now + _ = updateTaskExecution(ctx, newExecution) + return "", fmt.Errorf(errRetryTaskEnqueueFailed, err) + } + + if err := appendTaskExecutionLog(ctx, newTaskID, fmt.Sprintf("[系统] 手动触发重试,已重新创建任务并入队 (原任务ID: %s, 重试次数: %d/%d)", execution.TaskID, execution.RetryCount+1, execution.MaxRetry)); err != nil { + logger.ErrorF(ctx, "[TaskExecutor] 追加重试日志失败 taskID=%s: %v", newTaskID, err) + } + + return newTaskID, nil +} + +// ProcessTask Asynq 实际调用的统一处理函数 +// Worker 注册时统一使用此函数,内部自动分发到对应的 TaskHandler +func ProcessTask(ctx context.Context, t *asynq.Task) error { + taskPayload := t.Payload() + ctx, taskPayload, hasRemoteTraceContext := extractTaskTraceContext(ctx, taskPayload) + + // 初始化 Trace + ctx, span := otel_trace.Start(ctx, "TaskProcess_"+t.Type(), trace.WithSpanKind(trace.SpanKindConsumer)) + defer span.End() + + // 添加任务信息到 Span + span.SetAttributes( + attribute.String("task.type", t.Type()), + attribute.Int("task.payload_size", len(taskPayload)), + attribute.Bool("task.trace_context_propagated", hasRemoteTraceContext), + attribute.String("task.id", t.ResultWriter().TaskID()), + ) + + taskID := t.ResultWriter().TaskID() + + // 注入 taskID 到 context + ctx = withTaskID(ctx, taskID) + + // 查找处理器 + handler, ok := getHandler(t.Type()) + if !ok { + err := fmt.Errorf(errUnregisteredTaskHandler, t.Type()) + logger.ErrorF(ctx, "[TaskExecutor] %v", err) + span.SetStatus(codes.Error, err.Error()) + return err + } + + // 加载或动态创建执行记录 + now := time.Now() + execution, err := getOrCreateTaskExecution(ctx, taskID, t, taskPayload, now) + if err == nil { + updateExecutionOnStart(ctx, execution, now) + } + + if execution != nil { + AppendLog(ctx, "[系统] 开始执行异步任务 [名称: %s, 类型: %s],重试次数: %d/%d", + execution.TaskName, t.Type(), execution.RetryCount, execution.MaxRetry) + } else { + AppendLog(ctx, "[系统] 开始执行异步任务 [类型: %s]", t.Type()) + } + + // 开始计时 + start := time.Now() + + // 执行业务逻辑 + result, execErr := handler.Execute(ctx, taskPayload) + + // 计算耗时并归档记录 + duration := time.Since(start) + finishTime := time.Now() + + completeTaskExecution(ctx, execution, t, duration, finishTime, result, execErr, span) + + if execution == nil && execErr != nil { + span.SetStatus(codes.Error, execErr.Error()) + return execErr + } + + return execErr +} + +func injectTaskTraceContext(ctx context.Context, payload []byte) []byte { + carrier := propagation.MapCarrier{} + otel.GetTextMapPropagator().Inject(ctx, carrier) + if len(carrier) == 0 { + return payload + } + + envelope := traceEnvelope{ + WaveletTraceEnvelope: true, + Version: traceEnvelopeVersion, + TraceContext: map[string]string(carrier), + Payload: payload, + } + data, err := json.Marshal(envelope) + if err != nil { + logger.ErrorF(ctx, "[TaskExecutor] 序列化任务 Trace 上下文失败: %v", err) + return payload + } + return data +} + +func extractTaskTraceContext(ctx context.Context, payload []byte) (context.Context, []byte, bool) { + var envelope traceEnvelope + if err := json.Unmarshal(payload, &envelope); err != nil { + return ctx, payload, false + } + if !envelope.WaveletTraceEnvelope || envelope.Version != traceEnvelopeVersion { + return ctx, payload, false + } + if len(envelope.TraceContext) == 0 { + return ctx, envelope.Payload, false + } + + extractedCtx := otel.GetTextMapPropagator().Extract(ctx, propagation.MapCarrier(envelope.TraceContext)) + return extractedCtx, envelope.Payload, true +} + +func updateExecutionOnStart(ctx context.Context, execution *TaskExecution, now time.Time) { + if execution == nil { + return + } + dirty := false + if retryCount, hasRetry := asynq.GetRetryCount(ctx); hasRetry && execution.RetryCount != retryCount { + execution.RetryCount = retryCount + dirty = true + } + if execution.Status != TaskExecutionStatusRunning { + execution.Status = TaskExecutionStatusRunning + execution.StartedAt = &now + dirty = true + } + if dirty { + if updateErr := updateTaskExecution(ctx, execution); updateErr != nil { + logger.ErrorF(ctx, "[TaskExecutor] 更新执行状态失败 taskID=%s: %v", execution.TaskID, updateErr) + } + } +} + +// getOrCreateTaskExecution 获取已有的任务执行记录,如果不存在则针对已知任务类型动态创建记录 +func getOrCreateTaskExecution(ctx context.Context, taskID string, t *asynq.Task, payload []byte, now time.Time) (*TaskExecution, error) { + execution, err := getTaskExecutionByTaskID(ctx, taskID) + if err == nil { + return execution, nil + } + + meta := GetTaskMetaByAsynqTask(t.Type()) + if meta == nil { + return nil, err + } + + execution = &TaskExecution{ + TaskID: taskID, + TaskType: meta.AsynqTask, + TaskName: meta.Name, + Status: TaskExecutionStatusRunning, + Retryable: meta.Retryable, + MaxRetry: meta.MaxRetry, + RetryCount: 0, + Payload: string(payload), + TriggeredBy: "schedule", + StartedAt: &now, + } + + if createErr := createTaskExecution(ctx, execution); createErr != nil { + logger.ErrorF(ctx, "[TaskExecutor] 动态创建执行记录失败 taskID=%s: %v", taskID, createErr) + return nil, createErr + } + + return execution, nil +} + +// completeTaskExecution 完成并更新任务执行记录的状态和执行结果 +func completeTaskExecution(ctx context.Context, execution *TaskExecution, t *asynq.Task, duration time.Duration, finishTime time.Time, result *TaskResult, execErr error, span trace.Span) { + if execution == nil { + return + } + + execution.Duration = duration.Milliseconds() + execution.FinishedAt = &finishTime + + if execErr != nil { + handleFailedTask(ctx, execution, t, duration, execErr, span) + } else { + handleSuccessfulTask(ctx, execution, t, duration, result) + } + + if err := updateTaskExecution(ctx, execution); err != nil { + logger.ErrorF(ctx, "[TaskExecutor] 更新执行记录失败 taskID=%s: %v", execution.TaskID, err) + } + if shouldFlushTaskExecutionLog(ctx, execErr) { + if err := flushTaskExecutionLog(ctx, execution.TaskID); err != nil { + logger.ErrorF(ctx, "[TaskExecutor] 持久化任务日志失败 taskID=%s: %v", execution.TaskID, err) + } + } + + notifyTaskCompleted(ctx, execution, result, execErr) +} + +func notifyTaskCompleted(ctx context.Context, execution *TaskExecution, result *TaskResult, execErr error) { + completedHandlersMutex.RLock() + defer completedHandlersMutex.RUnlock() + + if len(taskCompletedHandlers) == 0 { + return + } + + asyncCtx := context.WithoutCancel(ctx) + for _, handler := range taskCompletedHandlers { + h := handler + util.Go(func() { + h(asyncCtx, execution, result, execErr) + }) + } +} + +func shouldFlushTaskExecutionLog(ctx context.Context, execErr error) bool { + if isTerminalTaskExecutionError(execErr) { + return true + } + + retryCount, hasRetryCount := asynq.GetRetryCount(ctx) + maxRetry, hasMaxRetry := asynq.GetMaxRetry(ctx) + if !hasRetryCount || !hasMaxRetry { + return true + } + return retryCount >= maxRetry +} + +func isTerminalTaskExecutionError(execErr error) bool { + return execErr == nil || errors.Is(execErr, asynq.SkipRetry) +} + +func handleFailedTask(ctx context.Context, execution *TaskExecution, t *asynq.Task, duration time.Duration, execErr error, span trace.Span) { + execution.Status = TaskExecutionStatusFailed + execution.ErrorMessage = execErr.Error() + logger.ErrorF(ctx, "[TaskExecutor] 任务处理失败 Type: %s TaskID: %s Duration: %d ms Error: %v", t.Type(), execution.TaskID, duration.Milliseconds(), execErr) + span.SetStatus(codes.Error, execErr.Error()) + span.RecordError(execErr) + + AppendLog(ctx, "[系统] 任务执行失败,耗时: %d ms,错误原因: %v", duration.Milliseconds(), execErr) +} + +func handleSuccessfulTask(ctx context.Context, execution *TaskExecution, t *asynq.Task, duration time.Duration, result *TaskResult) { + execution.Status = TaskExecutionStatusSucceeded + execution.ErrorMessage = "" // 清除历史重试失败遗留的错误信息 + if result != nil { + execution.Result = result.Message + if result.Detail != "" { + execution.Result = fmt.Sprintf("%s\n%s", result.Message, result.Detail) + } + } + logger.InfoF(ctx, "[TaskExecutor] 任务处理完成 Type: %s TaskID: %s Duration: %d ms", t.Type(), execution.TaskID, duration.Milliseconds()) + + resultMsg := "成功" + if result != nil { + resultMsg = result.Message + } + AppendLog(ctx, "[系统] 任务执行成功,耗时: %d ms,执行结果: %s", duration.Milliseconds(), resultMsg) +} + +// generateTaskID 生成任务 ID +func generateTaskID(taskType, triggeredBy string) string { + return fmt.Sprintf("%s_%s_%d", triggeredBy, taskType, idgen.NextUint64ID()) +} + +// generateRetryTaskID 生成重试任务 ID +func generateRetryTaskID(originalTaskID string, retryCount int) string { + return fmt.Sprintf("retry_%d_%s", retryCount, originalTaskID) +} diff --git a/backend/plugins/drivers/driver_asynq_worker/executor_test.go b/backend/plugins/drivers/driver_asynq_worker/executor_test.go new file mode 100644 index 00000000..25774a19 --- /dev/null +++ b/backend/plugins/drivers/driver_asynq_worker/executor_test.go @@ -0,0 +1,431 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package driver_asynq_worker + +import ( + "Wavelet/pkg/idgen" + "Wavelet/pkg/testhelper" + "context" + "errors" + "fmt" + "testing" + "time" + + "github.com/hibiken/asynq" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/trace" + + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +func init() { + _ = idgen.Init(1) +} + +type mockDBService struct { + db *gorm.DB +} + +func (m *mockDBService) GORM() *gorm.DB { + return m.db +} + +func (m *mockDBService) DB(ctx context.Context) *gorm.DB { + return m.db.WithContext(ctx) +} + +func (m *mockDBService) Named(_ string) *gorm.DB { + return m.db +} + +// mockHandler 用于测试的模拟任务处理器 +type mockHandler struct { + executeFunc func(ctx context.Context, payload []byte) (*TaskResult, error) +} + +func (h *mockHandler) Execute(ctx context.Context, payload []byte) (*TaskResult, error) { + if h.executeFunc != nil { + return h.executeFunc(ctx, payload) + } + return &TaskResult{Message: "mock success"}, nil +} + +// successHandler 返回成功的处理器 +func successHandler() *mockHandler { + return &mockHandler{ + executeFunc: func(ctx context.Context, payload []byte) (*TaskResult, error) { + AppendLog(ctx, "执行成功,处理了 %d 条数据", 100) + return &TaskResult{Message: "处理完成,共 100 条"}, nil + }, + } +} + +// failHandler 返回失败的处理器 +func failHandler() *mockHandler { + return &mockHandler{ + executeFunc: func(ctx context.Context, payload []byte) (*TaskResult, error) { + AppendLog(ctx, "开始执行任务") + return nil, fmt.Errorf("模拟执行失败: 数据库连接超时") + }, + } +} + +const testTaskType = "test:mock_task" + +func setupTest(t *testing.T) func() { + testDB, mr, cleanup := testhelper.SetupTestEnvironment(t) + setDBService(&mockDBService{db: testDB}) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + SetRedisClient(rdb) + + AsynqClient = asynq.NewClient(asynq.RedisClientOpt{ + Addr: mr.Addr(), + }) + // 注册测试用 handler + RegisterHandler(testTaskType, successHandler()) + return func() { + if AsynqClient != nil { + _ = AsynqClient.Close() + AsynqClient = nil + } + _ = rdb.Close() + setDBService(nil) + SetRedisClient(nil) + cleanup() + } +} + +func TestRegisterAndGetHandler(t *testing.T) { + _ = testTaskType + cleanup := setupTest(t) + defer cleanup() + + // 验证 handler 已注册 + h, ok := getHandler(testTaskType) + assert.True(t, ok, "handler should be registered") + assert.NotNil(t, h) + + // 未注册的 handler + _, ok = getHandler("nonexistent") + assert.False(t, ok, "non-existent handler should return false") +} + +func TestGetTaskIDFromContext(t *testing.T) { + ctx := context.Background() + + // 空 context + taskID := GetTaskID(ctx) + assert.Equal(t, "", taskID) + + // 注入 taskID + ctx = withTaskID(ctx, "test_task_123") + taskID = GetTaskID(ctx) + assert.Equal(t, "test_task_123", taskID) +} + +func TestAppendLogWithoutTaskID(t *testing.T) { + cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + // 没有 taskID 的 context,应降级到普通日志,不报错 + AppendLog(ctx, "这条日志应该降级处理,不会报错") +} + +func TestAppendLogWithTaskID(t *testing.T) { + cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + // 先创建一条执行记录 + execution := &TaskExecution{ + TaskID: "log_test_001", + TaskType: testTaskType, + TaskName: "测试任务", + Status: TaskExecutionStatusRunning, + TriggeredBy: "manual", + } + err := CreateTaskExecution(ctx, execution) + require.NoError(t, err) + + // 注入 taskID 并追加日志 + ctx = withTaskID(ctx, "log_test_001") + AppendLog(ctx, "第一条日志") + AppendLog(ctx, "处理了 %d 条数据", 50) + + // 验证日志 + found, err := GetTaskExecutionByTaskID(ctx, "log_test_001") + require.NoError(t, err) + assert.Contains(t, found.Log, "第一条日志") + assert.Contains(t, found.Log, "处理了 50 条数据") +} + +func TestTaskTraceContextEnvelope(t *testing.T) { + payload := []byte(`{"hello":"wavelet"}`) + traceID := "4bf92f3577b34da6a3ce929d0e0e4736" + parentCtx := otel.GetTextMapPropagator().Extract( + context.Background(), + propagation.MapCarrier{ + "traceparent": "00-" + traceID + "-00f067aa0ba902b7-01", + }, + ) + + wrappedPayload := injectTaskTraceContext(parentCtx, payload) + require.NotEqual(t, string(payload), string(wrappedPayload)) + + gotCtx, gotPayload, ok := extractTaskTraceContext(context.Background(), wrappedPayload) + require.True(t, ok) + assert.Equal(t, payload, gotPayload) + assert.Equal(t, traceID, trace.SpanContextFromContext(gotCtx).TraceID().String()) +} + +func TestTaskTraceContextEnvelopeKeepsLegacyPayload(t *testing.T) { + payload := []byte(`{"legacy":true}`) + + gotCtx, gotPayload, ok := extractTaskTraceContext(context.Background(), payload) + require.False(t, ok) + assert.Equal(t, context.Background(), gotCtx) + assert.Equal(t, payload, gotPayload) +} + +func TestTaskTraceContextEnvelopeSkipsEmptyContext(t *testing.T) { + payload := []byte(`{"background":true}`) + + wrappedPayload := injectTaskTraceContext(context.Background(), payload) + assert.Equal(t, payload, wrappedPayload) +} + +func TestProcessTaskSuccess(t *testing.T) { + cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + // 注册成功 handler + RegisterHandler(testTaskType, successHandler()) + + // 创建执行记录 + execution := &TaskExecution{ + TaskID: "process_success_001", + TaskType: testTaskType, + TaskName: "测试任务", + Status: TaskExecutionStatusPending, + Retryable: true, + MaxRetry: 3, + TriggeredBy: "manual", + } + err := CreateTaskExecution(ctx, execution) + require.NoError(t, err) + + // 直接通过 handler 测试 + handler, ok := getHandler(testTaskType) + require.True(t, ok) + + ctx = withTaskID(ctx, "process_success_001") + result, err := handler.Execute(ctx, nil) + require.NoError(t, err) + assert.Equal(t, "处理完成,共 100 条", result.Message) + + // 验证日志被追加 + found, err := GetTaskExecutionByTaskID(ctx, "process_success_001") + require.NoError(t, err) + assert.Contains(t, found.Log, "执行成功,处理了 100 条数据") +} + +func TestProcessTaskFailure(t *testing.T) { + cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + // 注册失败 handler + RegisterHandler(testTaskType, failHandler()) + + // 创建执行记录 + execution := &TaskExecution{ + TaskID: "process_fail_001", + TaskType: testTaskType, + TaskName: "测试任务", + Status: TaskExecutionStatusPending, + Retryable: true, + MaxRetry: 3, + TriggeredBy: "manual", + } + err := CreateTaskExecution(ctx, execution) + require.NoError(t, err) + + // 直接调用 handler + handler, ok := getHandler(testTaskType) + require.True(t, ok) + + ctx = withTaskID(ctx, "process_fail_001") + _, err = handler.Execute(ctx, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "模拟执行失败") + + // 验证日志 + found, err := GetTaskExecutionByTaskID(ctx, "process_fail_001") + require.NoError(t, err) + assert.Contains(t, found.Log, "开始执行任务") +} + +func TestCompleteTaskExecutionFlushesLog(t *testing.T) { + cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + execution := &TaskExecution{ + TaskID: "complete_flush_001", + TaskType: testTaskType, + TaskName: "测试任务", + Status: TaskExecutionStatusRunning, + TriggeredBy: "manual", + } + err := CreateTaskExecution(ctx, execution) + require.NoError(t, err) + + ctx = withTaskID(ctx, execution.TaskID) + AppendLog(ctx, "任务执行中的日志") + + finishTime := time.Now() + completeTaskExecution( + ctx, + execution, + asynq.NewTask(testTaskType, nil), + 100*time.Millisecond, + finishTime, + &TaskResult{Message: "处理完成"}, + nil, + trace.SpanFromContext(ctx), + ) + + found, err := GetTaskExecutionByTaskID(ctx, execution.TaskID) + require.NoError(t, err) + assert.Equal(t, TaskExecutionStatusSucceeded, found.Status) + assert.Contains(t, found.Log, "任务执行中的日志") + assert.Contains(t, found.Log, "任务执行成功") +} + +func TestPermanentErrorIsTerminalForLogFlush(t *testing.T) { + assert.True(t, isTerminalTaskExecutionError(PermanentError("配置无效"))) + assert.False(t, isTerminalTaskExecutionError(errors.New("temporary failure"))) +} + +func TestRetryTask(t *testing.T) { + cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + // 创建一条失败的执行记录(可重试) + now := time.Now() + execution := &TaskExecution{ + TaskID: "retry_test_001", + TaskType: testTaskType, + TaskName: "测试任务", + Status: TaskExecutionStatusFailed, + Retryable: true, + MaxRetry: 3, + RetryCount: 0, + ErrorMessage: "首次执行失败", + StartedAt: &now, + FinishedAt: &now, + Duration: 100, + TriggeredBy: "manual", + } + err := CreateTaskExecution(ctx, execution) + require.NoError(t, err) + + // 重试 + newTaskID, err := RetryTask(ctx, execution.ID) + require.NoError(t, err) + assert.NotEmpty(t, newTaskID) + assert.Contains(t, newTaskID, "retry_1_") + + // 验证新记录 + newExecution, err := GetTaskExecutionByTaskID(ctx, newTaskID) + require.NoError(t, err) + assert.Equal(t, TaskExecutionStatusPending, newExecution.Status) + assert.Equal(t, 1, newExecution.RetryCount) + assert.Equal(t, "retry", newExecution.TriggeredBy) + assert.Equal(t, execution.TaskType, newExecution.TaskType) + assert.True(t, newExecution.Retryable) + + // 原记录不变 + original, err := GetTaskExecutionByID(ctx, execution.ID) + require.NoError(t, err) + assert.Equal(t, TaskExecutionStatusFailed, original.Status) + assert.Equal(t, 0, original.RetryCount) +} + +func TestRetryTaskNotFailed(t *testing.T) { + cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + // 创建一条成功的记录 + execution := &TaskExecution{ + TaskID: "retry_not_failed_001", + TaskType: testTaskType, + TaskName: "测试任务", + Status: TaskExecutionStatusSucceeded, + Retryable: true, + MaxRetry: 3, + TriggeredBy: "manual", + } + err := CreateTaskExecution(ctx, execution) + require.NoError(t, err) + + // 尝试重试成功的任务 + _, err = RetryTask(ctx, execution.ID) + assert.Error(t, err) + assert.Contains(t, err.Error(), "只有失败的任务才能重试") +} + +func TestRetryTaskNotRetryable(t *testing.T) { + cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + execution := &TaskExecution{ + TaskID: "retry_not_allowed_001", + TaskType: testTaskType, + TaskName: "测试任务", + Status: TaskExecutionStatusFailed, + Retryable: false, + MaxRetry: 0, + TriggeredBy: "manual", + } + err := CreateTaskExecution(ctx, execution) + require.NoError(t, err) + + _, err = RetryTask(ctx, execution.ID) + assert.Error(t, err) + assert.Contains(t, err.Error(), "不支持重试") +} + +func TestRetryTaskNonExistent(t *testing.T) { + cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + _, err := RetryTask(ctx, 99999999) + assert.Error(t, err) + assert.Contains(t, err.Error(), "不存在") +} + +func TestGenerateTaskID(t *testing.T) { + id1 := generateTaskID("test_type", "manual") + id2 := generateTaskID("test_type", "manual") + + // 两个 ID 应不同(包含 Snowflake ID) + assert.NotEqual(t, id1, id2) + assert.Contains(t, id1, "manual_test_type_") +} + +func TestGenerateRetryTaskID(t *testing.T) { + id := generateRetryTaskID("original_task_123", 2) + assert.Equal(t, "retry_2_original_task_123", id) +} diff --git a/backend/plugins/drivers/driver_asynq_worker/handler.go b/backend/plugins/drivers/driver_asynq_worker/handler.go new file mode 100644 index 00000000..92724fd9 --- /dev/null +++ b/backend/plugins/drivers/driver_asynq_worker/handler.go @@ -0,0 +1,36 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package driver_asynq_worker + +import "context" + +// TaskResult 任务执行结果 +// +// TaskResult 保留完整名称以避免与通用 Result 混淆 +type TaskResult struct { + Message string // 结果摘要,如 "共清理 120 个文件,耗时 3.2s" + Detail string // 可选的详细结果 JSON +} + +// PayloadValidator 可选接口,带参数的任务 Handler 应实现此接口。 +// 框架在 Admin 下发时自动调用,完成参数校验和标准化(如 Trim 空白)。 +// 无参数的任务无需实现,框架会直接透传 payload。 +type PayloadValidator interface { + ValidatePayload(payload []byte) ([]byte, error) +} + +// TaskHandler 异步任务处理器接口 +// 所有异步任务必须实现此接口,框架将自动管理任务执行记录的创建、状态流转和日志写入。 +// +// 开发者只需实现 Execute 方法编写业务逻辑,在方法内通过 driver_asynq_worker.AppendLog(ctx, ...) 追加执行日志。 +// 任务的创建、状态更新、错误记录、重试计数全部由框架透明处理。 +// +// TaskHandler 保留完整名称以避免与通用 Handler 混淆 +type TaskHandler interface { + // Execute 执行任务业务逻辑 + // - ctx: 已注入 Trace Span 和 taskID 的上下文 + // - payload: 调度时传入的原始参数(可为 nil) + // - 返回 TaskResult 描述执行结果,或 error 表示执行失败 + Execute(ctx context.Context, payload []byte) (*TaskResult, error) +} diff --git a/backend/plugins/drivers/driver_asynq_worker/meta.go b/backend/plugins/drivers/driver_asynq_worker/meta.go new file mode 100644 index 00000000..0504a3d2 --- /dev/null +++ b/backend/plugins/drivers/driver_asynq_worker/meta.go @@ -0,0 +1,194 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package driver_asynq_worker + +import ( + "Wavelet/core/contracts" + "Wavelet/core/extpoints" + "sync" +) + +// TaskParam 任务参数定义 +// +// TaskParam 保留完整名称以避免与通用 Param 混淆 +type TaskParam struct { + Name string `json:"name"` // 参数键名 + Label string `json:"label"` // 显示名称 + Type string `json:"type"` // 类型:string, text, number, boolean + Required bool `json:"required"` // 是否必填 + Placeholder string `json:"placeholder"` // 占位符 + Description string `json:"description"` // 描述 +} + +// TaskMeta 任务元数据 +// +// TaskMeta 保留完整名称以避免与通用 Meta 混淆 +type TaskMeta struct { + Type string `json:"type"` + AsynqTask string `json:"asynq_task"` + Name string `json:"name"` + Description string `json:"description"` + Category string `json:"category,omitempty"` + SupportsTime bool `json:"supports_time"` + MaxRetry int `json:"max_retry"` + Queue string `json:"queue"` + Retryable bool `json:"retryable"` // 是否支持手动重试 + Params []TaskParam `json:"params,omitempty"` +} + +var ( + dispatchableTasksMutex sync.RWMutex + dispatchableTasks []TaskMeta +) + +// RegisterTaskMeta 注册任务元数据到全局列表 +func RegisterTaskMeta(meta TaskMeta) { + dispatchableTasksMutex.Lock() + defer dispatchableTasksMutex.Unlock() + for _, t := range dispatchableTasks { + if t.Type == meta.Type { + return + } + } + dispatchableTasks = append(dispatchableTasks, meta) +} + +// GetDispatchableTasks 获取所有已注册的元数据列表(优先结合 activeTaskReg 和 dispatchableTasks) +func GetDispatchableTasks() []TaskMeta { + activeTaskRegMutex.RLock() + reg := activeTaskReg + activeTaskRegMutex.RUnlock() + + var metas []TaskMeta + seen := make(map[string]bool) + + if reg != nil { + for _, td := range reg.Tasks() { + dto := td.ToDTO() + m := toInternalTaskMeta(dto) + metas = append(metas, m) + seen[m.Type] = true + seen[m.AsynqTask] = true + } + } + + dispatchableTasksMutex.RLock() + for _, t := range dispatchableTasks { + if !seen[t.Type] && !seen[t.AsynqTask] { + metas = append(metas, t) + seen[t.Type] = true + seen[t.AsynqTask] = true + } + } + dispatchableTasksMutex.RUnlock() + + return metas +} + +func toInternalTaskMeta(dto contracts.TaskMetaDTO) TaskMeta { + params := make([]TaskParam, 0, len(dto.Params)) + for _, p := range dto.Params { + params = append(params, TaskParam{ + Name: p.Name, + Label: p.Label, + Type: p.Type, + Required: p.Required, + Placeholder: p.Placeholder, + Description: p.Description, + }) + } + taskType := dto.Type + if taskType == "" { + taskType = dto.AsynqTask + } + if taskType == "" { + taskType = dto.Name + } + asynqTask := dto.AsynqTask + if asynqTask == "" { + asynqTask = taskType + } + name := dto.Name + if name == "" { + name = dto.DisplayName + } + if name == "" { + name = taskType + } + return TaskMeta{ + Type: taskType, + AsynqTask: asynqTask, + Name: name, + Description: dto.Description, + Category: dto.Category, + SupportsTime: dto.SupportsTime, + MaxRetry: dto.MaxRetry, + Queue: dto.Queue, + Retryable: dto.Retryable, + Params: params, + } +} + +var ( + activeTaskRegMutex sync.RWMutex + activeTaskReg extpoints.TaskExtension +) + +// SetActiveTaskExtension sets the active task extension registry for task resolution. +func SetActiveTaskExtension(reg extpoints.TaskExtension) { + activeTaskRegMutex.Lock() + defer activeTaskRegMutex.Unlock() + activeTaskReg = reg +} + +func getFromActiveTaskExtension(taskType string) *TaskMeta { + activeTaskRegMutex.RLock() + defer activeTaskRegMutex.RUnlock() + if activeTaskReg == nil { + return nil + } + for _, td := range activeTaskReg.Tasks() { + if td.Type == taskType || td.Pattern == taskType { + m := toInternalTaskMeta(td.ToDTO()) + return &m + } + } + return nil +} + +// GetTaskMeta 根据任务类型获取元数据 +func GetTaskMeta(taskType string) *TaskMeta { + if m := getFromActiveTaskExtension(taskType); m != nil { + return m + } + + dispatchableTasksMutex.RLock() + for _, t := range dispatchableTasks { + if t.Type == taskType || t.AsynqTask == taskType { + copied := t + dispatchableTasksMutex.RUnlock() + return &copied + } + } + dispatchableTasksMutex.RUnlock() + + return nil +} + +// GetTaskMetaByAsynqTask 根据 Asynq 任务名称获取元数据 +func GetTaskMetaByAsynqTask(asynqTask string) *TaskMeta { + return GetTaskMeta(asynqTask) +} + +// GetRegisteredAsynqTasks 返回所有已注册的 Asynq 任务名称,以便动态注册路由 +func GetRegisteredAsynqTasks() []string { + handlerRegistryMutex.RLock() + defer handlerRegistryMutex.RUnlock() + + keys := make([]string, 0, len(handlerRegistry)) + for k := range handlerRegistry { + keys = append(keys, k) + } + return keys +} diff --git a/backend/plugins/drivers/driver_asynq_worker/meta_test.go b/backend/plugins/drivers/driver_asynq_worker/meta_test.go new file mode 100644 index 00000000..9c05c0cd --- /dev/null +++ b/backend/plugins/drivers/driver_asynq_worker/meta_test.go @@ -0,0 +1,98 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package driver_asynq_worker + +import ( + "sync" + "testing" +) + +func resetDispatchableTasks() { + dispatchableTasksMutex.Lock() + defer dispatchableTasksMutex.Unlock() + dispatchableTasks = nil +} + +func TestRegisterTaskMeta_ThreadSafe(t *testing.T) { + resetDispatchableTasks() + defer resetDispatchableTasks() + + var wg sync.WaitGroup + workers := 20 + iterations := 100 + + for i := 0; i < workers; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + for j := 0; j < iterations; j++ { + RegisterTaskMeta(TaskMeta{ + Type: "task_type_a", + AsynqTask: "asynq_task_a", + Name: "Task A", + }) + RegisterTaskMeta(TaskMeta{ + Type: "task_type_b", + AsynqTask: "asynq_task_b", + Name: "Task B", + }) + } + }(i) + } + + wg.Wait() + + tasks := GetDispatchableTasks() + if len(tasks) != 2 { + t.Fatalf("expected 2 unique tasks, got %d", len(tasks)) + } +} + +func TestGetTaskMeta_Isolation(t *testing.T) { + resetDispatchableTasks() + defer resetDispatchableTasks() + + RegisterTaskMeta(TaskMeta{ + Type: "task_isolated", + AsynqTask: "asynq_isolated", + Name: "Original Name", + }) + + meta := GetTaskMeta("task_isolated") + if meta == nil { + t.Fatal("expected task to be found") + } + + // Modify returned struct + meta.Name = "Modified Name" + + // Fetch again and verify original data is unaffected + meta2 := GetTaskMeta("task_isolated") + if meta2.Name != "Original Name" { + t.Fatalf("expected name to be 'Original Name', got %s", meta2.Name) + } +} + +func TestGetTaskMetaByAsynqTask_Isolation(t *testing.T) { + resetDispatchableTasks() + defer resetDispatchableTasks() + + RegisterTaskMeta(TaskMeta{ + Type: "task_isolated", + AsynqTask: "asynq_isolated", + Name: "Original Name", + }) + + meta := GetTaskMetaByAsynqTask("asynq_isolated") + if meta == nil { + t.Fatal("expected task to be found") + } + + meta.Name = "Modified Name" + + meta2 := GetTaskMetaByAsynqTask("asynq_isolated") + if meta2.Name != "Original Name" { + t.Fatalf("expected name to be 'Original Name', got %s", meta2.Name) + } +} diff --git a/backend/plugins/drivers/driver_asynq_worker/permanent_error.go b/backend/plugins/drivers/driver_asynq_worker/permanent_error.go new file mode 100644 index 00000000..03dc77b8 --- /dev/null +++ b/backend/plugins/drivers/driver_asynq_worker/permanent_error.go @@ -0,0 +1,35 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package driver_asynq_worker + +import ( + "strings" + + "github.com/hibiken/asynq" +) + +const defaultPermanentErrorMessage = "任务无法继续执行" + +type permanentTaskError struct { + message string +} + +// PermanentError marks a safe domain message as a non-retryable task failure. +// It intentionally accepts no underlying error so Error never exposes provider, +// URL, header, response-body, or other sensitive implementation details. +func PermanentError(message string) error { + message = strings.TrimSpace(message) + if message == "" { + message = defaultPermanentErrorMessage + } + return &permanentTaskError{message: message} +} + +func (e *permanentTaskError) Error() string { + return e.message +} + +func (e *permanentTaskError) Unwrap() error { + return asynq.SkipRetry +} diff --git a/backend/plugins/drivers/driver_asynq_worker/permanent_error_test.go b/backend/plugins/drivers/driver_asynq_worker/permanent_error_test.go new file mode 100644 index 00000000..19d9a977 --- /dev/null +++ b/backend/plugins/drivers/driver_asynq_worker/permanent_error_test.go @@ -0,0 +1,37 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package driver_asynq_worker + +import ( + "errors" + "testing" + + "github.com/hibiken/asynq" +) + +func TestPermanentError(t *testing.T) { + err := PermanentError("自定义永久错误") + if err == nil { + t.Fatal("PermanentError returned nil") + } + if err.Error() != "自定义永久错误" { + t.Fatalf("Error() = %q, want %q", err.Error(), "自定义永久错误") + } + if !errors.Is(err, asynq.SkipRetry) { + t.Fatal("PermanentError does not unwrap to asynq.SkipRetry") + } +} + +func TestPermanentErrorDefaultMessage(t *testing.T) { + err := PermanentError(" ") + if err == nil { + t.Fatal("PermanentError returned nil") + } + if err.Error() != defaultPermanentErrorMessage { + t.Fatalf("Error() = %q, want default %q", err.Error(), defaultPermanentErrorMessage) + } + if !errors.Is(err, asynq.SkipRetry) { + t.Fatal("PermanentError does not unwrap to asynq.SkipRetry") + } +} diff --git a/backend/plugins/drivers/driver_asynq_worker/plugin.go b/backend/plugins/drivers/driver_asynq_worker/plugin.go new file mode 100644 index 00000000..05905182 --- /dev/null +++ b/backend/plugins/drivers/driver_asynq_worker/plugin.go @@ -0,0 +1,496 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package driver_asynq_worker provides the Asynq worker driver plugin for Cordis. +package driver_asynq_worker + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/hibiken/asynq" + "github.com/redis/go-redis/v9" +) + +const ( + defaultConcurrency = 10 + defaultShutdownTimeout = 10 * time.Second +) + +// Option configures the Asynq worker driver plugin. +type Option func(*Plugin) + +// WithRedisOpt sets the Redis connection options for Asynq. +func WithRedisOpt(opt asynq.RedisConnOpt) Option { + return func(p *Plugin) { + p.redisOpt = opt + } +} + +// WithConcurrency sets the worker concurrency limit. +func WithConcurrency(concurrency int) Option { + return func(p *Plugin) { + p.concurrency = concurrency + } +} + +// WithQueues sets the queue priorities mapping. +func WithQueues(queues map[string]int) Option { + return func(p *Plugin) { + p.queues = queues + } +} + +// WithStrictPriority sets whether to process queues strictly in priority order. +func WithStrictPriority(strict bool) Option { + return func(p *Plugin) { + p.strictPriority = strict + } +} + +// WithShutdownTimeout sets the timeout for graceful worker shutdown. +func WithShutdownTimeout(d time.Duration) Option { + return func(p *Plugin) { + p.shutdownTimeout = d + } +} + +// WithServer injects an existing Asynq server instance. +func WithServer(srv *asynq.Server) Option { + return func(p *Plugin) { + p.server = srv + } +} + +// Plugin implements core.Plugin and core.Driver for Asynq background worker server. +type Plugin struct { + mu sync.RWMutex + redisOpt asynq.RedisConnOpt + concurrency int + queues map[string]int + strictPriority bool + shutdownTimeout time.Duration + server *asynq.Server + mux *asynq.ServeMux + running bool + coreCtx *core.Context + taskSvc contracts.TaskService +} + +// New creates a new Asynq Worker driver plugin. +func New(opts ...Option) *Plugin { + p := &Plugin{ + concurrency: defaultConcurrency, + shutdownTimeout: defaultShutdownTimeout, + queues: map[string]int{"default": 1}, + } + + for _, opt := range opts { + if opt != nil { + opt(p) + } + } + + return p +} + +// Name returns the unique plugin identifier. +func (p *Plugin) Name() string { + return "driver_asynq_worker" +} + +// DeclareConfig declares configuration bindings consumed by the Asynq worker driver. +func (p *Plugin) DeclareConfig() []core.ConfigBinding { + return []core.ConfigBinding{ + {Prefix: "worker", Target: &workerConfig{}}, + {Prefix: "redis", Target: &redisWorkerConfig{}}, + } +} + +// ConfigEnabled gates plugin activation when Redis is enabled. +func (p *Plugin) ConfigEnabled(view core.ConfigView) bool { + return view.Bool("redis.enabled", false) +} + +// Apply mounts the Asynq Worker driver into the micro-kernel Context. +func (p *Plugin) Apply(ctx *core.Context) error { + var wCfg workerConfig + _ = ctx.Config().Bind("worker", &wCfg) + var rCfg redisWorkerConfig + _ = ctx.Config().Bind("redis", &rCfg) + + p.mu.Lock() + p.coreCtx = ctx + if p.concurrency == defaultConcurrency && wCfg.Concurrency > 0 { + p.concurrency = wCfg.Concurrency + } + p.strictPriority = wCfg.StrictPriority + if len(p.queues) == 1 && p.queues["default"] == 1 && len(wCfg.Queues) > 0 { + qMap := make(map[string]int, len(wCfg.Queues)) + for _, q := range wCfg.Queues { + qMap[q.Name] = q.Priority + } + p.queues = qMap + } + if p.redisOpt == nil { + p.redisOpt = NewRedisConnOptWithConfig(rCfg) + } + RedisOpt = p.redisOpt + ResetAsynqClient() + p.mu.Unlock() + + // 0. Bind DBService + if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil { + setDBService(db) + } else { + core.When[contracts.DBService](ctx, func(db contracts.DBService) { + setDBService(db) + }) + } + ctx.OnDispose(func() error { + setDBService(nil) + return nil + }) + + // 1. Provide contracts.TaskService + p.taskSvc = &taskServiceImpl{} + core.Provide[contracts.TaskService](ctx, p.taskSvc) + SetActiveTaskExtension(ctx.Tasks()) + + ctx.OnDispose(func() error { + SetActiveTaskExtension(nil) + SetRedisClient(nil) + ResetAsynqClient() + shutdownCtx, cancel := context.WithTimeout(context.Background(), p.shutdownTimeout) + defer cancel() + return p.Stop(shutdownCtx) + }) + + return ctx.RegisterDriver(p) +} + +// Type returns DriverTypeWorker. +func (p *Plugin) Type() core.DriverType { + return core.DriverTypeWorker +} + +// Start boots the Asynq worker server and starts processing background tasks. +func (p *Plugin) Start(_ context.Context) error { + p.mu.Lock() + defer p.mu.Unlock() + + if p.running { + return nil + } + + mux := asynq.NewServeMux() + + if p.coreCtx != nil && p.coreCtx.Tasks() != nil { + for _, td := range p.coreCtx.Tasks().Tasks() { + handler, err := toAsynqHandler(td.Handler) + if err != nil { + return fmt.Errorf("driver_asynq_worker: invalid handler for task pattern %q: %w", td.Pattern, err) + } + mux.Handle(td.Pattern, handler) + } + } + + opt := p.redisOpt + if opt == nil { + opt = NewRedisConnOpt() + } + RedisOpt = opt + + if getRedisClient() == nil { + if mk, ok := opt.(interface{ MakeRedisClient() interface{} }); ok { + if client, ok := mk.MakeRedisClient().(redis.UniversalClient); ok { + SetRedisClient(client) + } + } + } + + if p.server == nil { + p.server = asynq.NewServer( + opt, + asynq.Config{ + Concurrency: p.concurrency, + Queues: p.queues, + StrictPriority: p.strictPriority, + ShutdownTimeout: p.shutdownTimeout, + }, + ) + } + + if err := p.server.Start(mux); err != nil { + return fmt.Errorf("driver_asynq_worker: start server failed: %w", err) + } + + p.mux = mux + p.running = true + return nil +} + +// Stop gracefully shuts down the Asynq worker server. +func (p *Plugin) Stop(_ context.Context) error { + p.mu.Lock() + defer p.mu.Unlock() + + if !p.running { + return nil + } + + p.running = false + + if p.server != nil { + p.server.Stop() + p.server.Shutdown() + p.server = nil + } + + return nil +} + +// IsRunning returns whether the worker server is running. +func (p *Plugin) IsRunning() bool { + p.mu.RLock() + defer p.mu.RUnlock() + return p.running +} + +// Server returns the underlying Asynq server instance. +func (p *Plugin) Server() *asynq.Server { + p.mu.RLock() + defer p.mu.RUnlock() + return p.server +} + +// Mux returns the underlying Asynq serve mux. +func (p *Plugin) Mux() *asynq.ServeMux { + p.mu.RLock() + defer p.mu.RUnlock() + return p.mux +} + +func toAsynqHandler(h any) (asynq.Handler, error) { + if h == nil { + return nil, errors.New("nil handler") + } + + switch fn := h.(type) { + case asynq.HandlerFunc: + return fn, nil + case asynq.Handler: + return fn, nil + case TaskHandler: + return asynq.HandlerFunc(func(c context.Context, t *asynq.Task) error { + RegisterHandler(t.Type(), fn) + return ProcessTask(c, t) + }), nil + case func(context.Context, *asynq.Task) error: + return asynq.HandlerFunc(fn), nil + case func(context.Context, []byte) error: + return asynq.HandlerFunc(func(c context.Context, t *asynq.Task) error { + c, payload, _ := extractTaskTraceContext(c, t.Payload()) + return fn(c, payload) + }), nil + case func(context.Context) error: + return asynq.HandlerFunc(func(c context.Context, _ *asynq.Task) error { + return fn(c) + }), nil + case func([]byte) error: + return asynq.HandlerFunc(func(c context.Context, t *asynq.Task) error { + _, payload, _ := extractTaskTraceContext(c, t.Payload()) + return fn(payload) + }), nil + case func() error: + return asynq.HandlerFunc(func(_ context.Context, _ *asynq.Task) error { + return fn() + }), nil + default: + return nil, fmt.Errorf("unsupported task handler type: %T", h) + } +} + +type taskServiceImpl struct{} + +func (s *taskServiceImpl) Dispatch(ctx context.Context, taskType string, payload []byte, triggeredBy string) (string, error) { + return DispatchTask(ctx, taskType, payload, triggeredBy) +} + +func (s *taskServiceImpl) ListTasks() []contracts.TaskMetaDTO { + all := GetDispatchableTasks() + res := make([]contracts.TaskMetaDTO, 0, len(all)) + for _, m := range all { + params := make([]contracts.TaskParamDTO, 0, len(m.Params)) + for _, param := range m.Params { + params = append(params, contracts.TaskParamDTO{ + Name: param.Name, + Label: param.Label, + Type: param.Type, + Description: param.Description, + Placeholder: param.Placeholder, + Required: param.Required, + }) + } + taskType := m.Type + if taskType == "" { + taskType = m.AsynqTask + } + if taskType == "" { + taskType = m.Name + } + asynqTask := m.AsynqTask + if asynqTask == "" { + asynqTask = taskType + } + name := m.Name + if name == "" { + name = taskType + } + res = append(res, contracts.TaskMetaDTO{ + Type: taskType, + AsynqTask: asynqTask, + Name: name, + DisplayName: name, + Description: m.Description, + Category: m.Category, + SupportsTime: m.SupportsTime, + Params: params, + MaxRetry: m.MaxRetry, + Queue: m.Queue, + Retryable: m.Retryable, + }) + } + return res +} + +func (s *taskServiceImpl) GetTaskMeta(taskType string) (contracts.TaskMetaDTO, bool) { + m := GetTaskMeta(taskType) + if m == nil { + return contracts.TaskMetaDTO{}, false + } + params := make([]contracts.TaskParamDTO, 0, len(m.Params)) + for _, param := range m.Params { + params = append(params, contracts.TaskParamDTO{ + Name: param.Name, + Label: param.Label, + Type: param.Type, + Description: param.Description, + Placeholder: param.Placeholder, + Required: param.Required, + }) + } + tType := m.Type + if tType == "" { + tType = m.AsynqTask + } + if tType == "" { + tType = m.Name + } + asynqTask := m.AsynqTask + if asynqTask == "" { + asynqTask = tType + } + name := m.Name + if name == "" { + name = tType + } + return contracts.TaskMetaDTO{ + Type: tType, + AsynqTask: asynqTask, + Name: name, + DisplayName: name, + Description: m.Description, + Category: m.Category, + SupportsTime: m.SupportsTime, + Params: params, + MaxRetry: m.MaxRetry, + Queue: m.Queue, + Retryable: m.Retryable, + }, true +} + +func (s *taskServiceImpl) ListExecutions(ctx context.Context, taskType, status string, page, pageSize int) ([]contracts.TaskExecutionDTO, int64, error) { + db := getDB(ctx) + if db == nil { + return nil, 0, errors.New("db not initialized") + } + query := db.Model(&TaskExecution{}) + if taskType != "" { + query = query.Where("task_type = ?", taskType) + } + if status != "" { + query = query.Where("status = ?", status) + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + var rows []TaskExecution + offset := (page - 1) * pageSize + if err := query.Order("id DESC").Offset(offset).Limit(pageSize).Find(&rows).Error; err != nil { + return nil, 0, err + } + res := make([]contracts.TaskExecutionDTO, 0, len(rows)) + for i := range rows { + res = append(res, toTaskExecutionDTO(&rows[i])) + } + return res, total, nil +} + +func (s *taskServiceImpl) Retry(ctx context.Context, id uint64) (string, error) { + return RetryTask(ctx, id) +} + +func (s *taskServiceImpl) ValidatePayload(taskType string, payload []byte) ([]byte, error) { + meta := GetTaskMeta(taskType) + if meta == nil { + return payload, nil + } + return ValidateAndNormalizePayload(meta.AsynqTask, payload) +} + +func (s *taskServiceImpl) ReloadScheduler() error { + return nil +} + +func (s *taskServiceImpl) AppendLog(ctx context.Context, format string, args ...any) { + AppendLog(ctx, format, args...) +} + +func (s *taskServiceImpl) GetExecution(ctx context.Context, id uint64) (*contracts.TaskExecutionDTO, error) { + exec, err := GetTaskExecutionByID(ctx, id) + if err != nil { + return nil, err + } + dto := toTaskExecutionDTO(exec) + return &dto, nil +} + +func toTaskExecutionDTO(exec *TaskExecution) contracts.TaskExecutionDTO { + return contracts.TaskExecutionDTO{ + ID: exec.ID, + TaskID: exec.TaskID, + TaskType: exec.TaskType, + TaskName: exec.TaskName, + Status: string(exec.Status), + Retryable: exec.Retryable, + MaxRetry: exec.MaxRetry, + RetryCount: exec.RetryCount, + Log: exec.Log, + ErrorMessage: exec.ErrorMessage, + Result: exec.Result, + StartedAt: exec.StartedAt, + FinishedAt: exec.FinishedAt, + Duration: exec.Duration, + Payload: exec.Payload, + TriggeredBy: exec.TriggeredBy, + CreatedAt: exec.CreatedAt, + UpdatedAt: exec.UpdatedAt, + } +} diff --git a/backend/plugins/drivers/driver_asynq_worker/task_repo.go b/backend/plugins/drivers/driver_asynq_worker/task_repo.go new file mode 100644 index 00000000..26026bd6 --- /dev/null +++ b/backend/plugins/drivers/driver_asynq_worker/task_repo.go @@ -0,0 +1,126 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package driver_asynq_worker + +import ( + "Wavelet/pkg/idgen" + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/redis/go-redis/v9" +) + +const ( + taskExecutionLogRedisKeyPrefix = "task:execution:log:" + taskExecutionLogExpiration = 24 * time.Hour + taskExecutionLogMaxLines = 1000 +) + +func taskExecutionLogRedisKey(taskID string) string { + return taskExecutionLogRedisKeyPrefix + taskID +} + +func createTaskExecution(ctx context.Context, execution *TaskExecution) error { + if execution.ID == 0 { + execution.ID = idgen.NextUint64ID() + } + return getDB(ctx).Create(execution).Error +} + +func updateTaskExecution(ctx context.Context, execution *TaskExecution) error { + return getDB(ctx).Omit("log").Save(execution).Error +} + +func getTaskExecutionByID(ctx context.Context, id uint64) (*TaskExecution, error) { + var execution TaskExecution + if err := getDB(ctx).Where("id = ?", id).First(&execution).Error; err != nil { + return nil, err + } + _ = loadTaskExecutionLog(ctx, &execution) + return &execution, nil +} + +func getTaskExecutionByTaskID(ctx context.Context, taskID string) (*TaskExecution, error) { + var execution TaskExecution + if err := getDB(ctx).Where("task_id = ?", taskID).First(&execution).Error; err != nil { + return nil, err + } + _ = loadTaskExecutionLog(ctx, &execution) + return &execution, nil +} + +func appendTaskExecutionLog(ctx context.Context, taskID, logLine string) error { + rdb := getRedisClient() + if rdb == nil { + return errors.New("redis client is not initialized") + } + + now := time.Now().Format("15:04:05") + line := fmt.Sprintf("[%s] %s\n", now, logLine) + key := taskExecutionLogRedisKey(taskID) + + _, err := rdb.TxPipelined(ctx, func(pipe redis.Pipeliner) error { + pipe.RPush(ctx, key, line) + pipe.LTrim(ctx, key, -taskExecutionLogMaxLines, -1) + pipe.Expire(ctx, key, taskExecutionLogExpiration) + return nil + }) + if err != nil { + return fmt.Errorf("append task execution log to redis: %w", err) + } + return nil +} + +func flushTaskExecutionLog(ctx context.Context, taskID string) error { + rdb := getRedisClient() + if rdb == nil { + return errors.New("redis client is not initialized") + } + + key := taskExecutionLogRedisKey(taskID) + logLines, err := rdb.LRange(ctx, key, 0, -1).Result() + if err != nil { + return fmt.Errorf("get task execution log from redis: %w", err) + } + if len(logLines) == 0 { + return nil + } + logText := strings.Join(logLines, "") + + result := getDB(ctx).Model(&TaskExecution{}). + Where("task_id = ?", taskID). + Update("log", logText) + if result.Error != nil { + return fmt.Errorf("persist task execution log: %w", result.Error) + } + if result.RowsAffected == 0 { + return fmt.Errorf("persist task execution log: task %q not found", taskID) + } + + if err := rdb.Del(ctx, key).Err(); err != nil { + return fmt.Errorf("delete persisted task execution log from redis: %w", err) + } + return nil +} + +func loadTaskExecutionLog(ctx context.Context, execution *TaskExecution) error { + rdb := getRedisClient() + if rdb == nil { + return nil + } + + logLines, err := rdb.LRange(ctx, taskExecutionLogRedisKey(execution.TaskID), 0, -1).Result() + if err != nil { + return fmt.Errorf("get task execution log from redis: %w", err) + } + if len(logLines) == 0 { + return nil + } + + execution.Log = strings.Join(logLines, "") + return nil +} diff --git a/backend/plugins/drivers/driver_asynq_worker/types.go b/backend/plugins/drivers/driver_asynq_worker/types.go new file mode 100644 index 00000000..23435faa --- /dev/null +++ b/backend/plugins/drivers/driver_asynq_worker/types.go @@ -0,0 +1,147 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package driver_asynq_worker + +import ( + "context" + "errors" + "time" + + "gorm.io/gorm" +) + +// TaskExecutionStatus 任务执行状态 +type TaskExecutionStatus string + +// Task execution status constants. +const ( + TaskExecutionStatusPending TaskExecutionStatus = "pending" + TaskExecutionStatusRunning TaskExecutionStatus = "running" + TaskExecutionStatusSucceeded TaskExecutionStatus = "succeeded" + TaskExecutionStatusFailed TaskExecutionStatus = "failed" +) + +// TaskExecution 任务执行记录 +type TaskExecution struct { + ID uint64 `json:"id,string" gorm:"primaryKey"` + TaskID string `json:"task_id" gorm:"size:128;uniqueIndex;not null"` + TaskType string `json:"task_type" gorm:"size:64;index;not null"` + TaskName string `json:"task_name" gorm:"size:128"` + Status TaskExecutionStatus `json:"status" gorm:"size:32;index;not null"` + Retryable bool `json:"retryable" gorm:"not null;default:false"` + MaxRetry int `json:"max_retry" gorm:"not null;default:0"` + RetryCount int `json:"retry_count" gorm:"not null;default:0"` + Log string `json:"log" gorm:"type:text"` + ErrorMessage string `json:"error_message" gorm:"type:text"` + Result string `json:"result" gorm:"type:text"` + StartedAt *time.Time `json:"started_at" gorm:"index"` + FinishedAt *time.Time `json:"finished_at"` + Duration int64 `json:"duration" gorm:"comment:耗时毫秒"` + Payload string `json:"payload" gorm:"type:text"` + TriggeredBy string `json:"triggered_by" gorm:"size:32;not null;default:system"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` +} + +// TableName 表名 +func (TaskExecution) TableName() string { + return "w_task_executions" +} + +// CreateTaskExecution 创建任务执行记录 +func CreateTaskExecution(ctx context.Context, exec *TaskExecution) error { + return getDB(ctx).Create(exec).Error +} + +// GetTaskExecutionByTaskID 根据 TaskID 查询执行记录 +func GetTaskExecutionByTaskID(ctx context.Context, taskID string) (*TaskExecution, error) { + var exec TaskExecution + if err := getDB(ctx).Where("task_id = ?", taskID).First(&exec).Error; err != nil { + return nil, err + } + _ = loadTaskExecutionLog(ctx, &exec) + return &exec, nil +} + +// GetTaskExecutionByID 根据主键 ID 查询执行记录 +func GetTaskExecutionByID(ctx context.Context, id uint64) (*TaskExecution, error) { + var exec TaskExecution + if err := getDB(ctx).Where("id = ?", id).First(&exec).Error; err != nil { + return nil, err + } + _ = loadTaskExecutionLog(ctx, &exec) + return &exec, nil +} + +// GetLatestTaskExecutionByTaskType 获取指定任务类型的最新执行记录 +func GetLatestTaskExecutionByTaskType(ctx context.Context, taskType string) (*TaskExecution, bool, error) { + var exec TaskExecution + err := getDB(ctx).Where("task_type = ?", taskType).Order("id DESC").First(&exec).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, false, nil + } + return nil, false, err + } + return &exec, true, nil +} + +// TaskExecutionCleanupStats describes task execution log cleanup results. +type TaskExecutionCleanupStats struct { + HighFrequencyDeleted int64 + LowFrequencyDeleted int64 +} + +// CleanupTaskExecutionLogs removes finished task execution logs according to frequency-based retention. +func CleanupTaskExecutionLogs(ctx context.Context, now time.Time) (TaskExecutionCleanupStats, error) { + const ( + frequencyWindowDays = 30 + highFrequencyThreshold = frequencyWindowDays + ) + + frequencyWindowStart := now.AddDate(0, 0, -frequencyWindowDays) + highFrequencyCutoff := now.AddDate(0, 0, -3) + lowFrequencyCutoff := now.AddDate(0, 0, -30) + terminalStatuses := []TaskExecutionStatus{TaskExecutionStatusSucceeded, TaskExecutionStatusFailed} + + var highFrequencyTaskTypes []string + if err := getDB(ctx). + Model(&TaskExecution{}). + Select("task_type"). + Where("created_at >= ?", frequencyWindowStart). + Group("task_type"). + Having("COUNT(*) > ?", highFrequencyThreshold). + Pluck("task_type", &highFrequencyTaskTypes).Error; err != nil { + return TaskExecutionCleanupStats{}, err + } + + var highFrequencyDeleted int64 + if len(highFrequencyTaskTypes) > 0 { + highFrequencyResult := getDB(ctx). + Where("status IN ?", terminalStatuses). + Where("created_at < ?", highFrequencyCutoff). + Where("task_type IN ?", highFrequencyTaskTypes). + Delete(&TaskExecution{}) + if highFrequencyResult.Error != nil { + return TaskExecutionCleanupStats{}, highFrequencyResult.Error + } + highFrequencyDeleted = highFrequencyResult.RowsAffected + } + + lowFrequencyQuery := getDB(ctx). + Where("status IN ?", terminalStatuses). + Where("created_at < ?", lowFrequencyCutoff) + if len(highFrequencyTaskTypes) > 0 { + lowFrequencyQuery = lowFrequencyQuery.Where("task_type NOT IN ?", highFrequencyTaskTypes) + } + lowFrequencyResult := lowFrequencyQuery.Delete(&TaskExecution{}) + if lowFrequencyResult.Error != nil { + return TaskExecutionCleanupStats{}, lowFrequencyResult.Error + } + + return TaskExecutionCleanupStats{ + HighFrequencyDeleted: highFrequencyDeleted, + LowFrequencyDeleted: lowFrequencyResult.RowsAffected, + }, nil +} diff --git a/backend/plugins/drivers/driver_asynq_worker/utils.go b/backend/plugins/drivers/driver_asynq_worker/utils.go new file mode 100644 index 00000000..2b7d2e02 --- /dev/null +++ b/backend/plugins/drivers/driver_asynq_worker/utils.go @@ -0,0 +1,169 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package driver_asynq_worker + +import ( + "sync" + + "github.com/hibiken/asynq" + "github.com/redis/go-redis/v9" + "github.com/redis/go-redis/v9/maintnotifications" +) + +type redisClientConnOpt struct { + options redis.Options +} + +func (opt redisClientConnOpt) MakeRedisClient() interface{} { + return redis.NewClient(&opt.options) +} + +type redisClusterConnOpt struct { + options redis.ClusterOptions +} + +func (opt redisClusterConnOpt) MakeRedisClient() interface{} { + return redis.NewClusterClient(&opt.options) +} + +type redisFailoverConnOpt struct { + options redis.FailoverOptions + maintNotificationsEnabled bool +} + +func (opt redisFailoverConnOpt) MakeRedisClient() interface{} { + client := redis.NewFailoverClient(&opt.options) + // go-redis v9.16 does not expose maintenance notification settings on + // FailoverOptions, so apply the configured mode before the client is used. + client.Options().MaintNotificationsConfig = maintNotificationsConfig(opt.maintNotificationsEnabled) + return client +} + +func maintNotificationsConfig(enabled bool) *maintnotifications.Config { + mode := maintnotifications.ModeDisabled + if enabled { + mode = maintnotifications.ModeAuto + } + return &maintnotifications.Config{Mode: mode} +} + +// RedisOpt asynq Redis 连接配置(兼容 Standalone/Sentinel/Cluster) +var RedisOpt asynq.RedisConnOpt + +// AsynqClient asynq 客户端,用于任务入队 +var AsynqClient *asynq.Client + +var asynqClientMu sync.RWMutex + +// GetAsynqClient returns the active Asynq client, dynamically creating or refreshing it if needed. +func GetAsynqClient() *asynq.Client { + asynqClientMu.RLock() + if AsynqClient != nil { + asynqClientMu.RUnlock() + return AsynqClient + } + asynqClientMu.RUnlock() + + asynqClientMu.Lock() + defer asynqClientMu.Unlock() + if AsynqClient != nil { + return AsynqClient + } + + opt := RedisOpt + if opt == nil { + opt = NewRedisConnOpt() + RedisOpt = opt + } + AsynqClient = asynq.NewClient(opt) + return AsynqClient +} + +// ResetAsynqClient resets the Asynq client so it will be re-created with current config. +func ResetAsynqClient() { + asynqClientMu.Lock() + defer asynqClientMu.Unlock() + if AsynqClient != nil { + _ = AsynqClient.Close() + AsynqClient = nil + } +} + +var ( + keyPrefixMu sync.RWMutex + keyPrefix string +) + +// SetKeyPrefix sets the redis key prefix for queue names. +func SetKeyPrefix(prefix string) { + keyPrefixMu.Lock() + defer keyPrefixMu.Unlock() + keyPrefix = prefix +} + +// GetKeyPrefix returns the redis key prefix for queue names. +func GetKeyPrefix() string { + keyPrefixMu.RLock() + defer keyPrefixMu.RUnlock() + return keyPrefix +} + +// NewRedisConnOpt 根据配置返回对应的 asynq Redis 连接选项 +func NewRedisConnOpt() asynq.RedisConnOpt { + return NewRedisConnOptWithConfig(redisWorkerConfig{}) +} + +// NewRedisConnOptWithConfig returns the asynq RedisConnOpt based on the provided configuration. +func NewRedisConnOptWithConfig(cfg redisWorkerConfig) asynq.RedisConnOpt { + SetKeyPrefix(cfg.KeyPrefix) + addrs := cfg.Addrs + + if cfg.ClusterMode { + return redisClusterConnOpt{ + options: redis.ClusterOptions{ + Addrs: addrs, + Username: cfg.Username, + Password: cfg.Password, + MaintNotificationsConfig: maintNotificationsConfig(cfg.MaintNotifications), + }, + } + } + + if cfg.MasterName != "" { + return redisFailoverConnOpt{ + maintNotificationsEnabled: cfg.MaintNotifications, + options: redis.FailoverOptions{ + MasterName: cfg.MasterName, + SentinelAddrs: addrs, + Username: cfg.Username, + Password: cfg.Password, + DB: cfg.DB, + }, + } + } + + addr := "localhost:6379" + if len(addrs) > 0 { + addr = addrs[0] + } + return redisClientConnOpt{ + options: redis.Options{ + Addr: addr, + Username: cfg.Username, + Password: cfg.Password, + DB: cfg.DB, + PoolSize: cfg.PoolSize, + MaintNotificationsConfig: maintNotificationsConfig(cfg.MaintNotifications), + }, + } +} + +// PrefixedQueue 返回带前缀的队列名,用于 Cluster 模式隔离 +func PrefixedQueue(queue string) string { + prefix := GetKeyPrefix() + if prefix == "" { + return queue + } + return prefix + queue +} diff --git a/backend/plugins/drivers/driver_asynq_worker/utils_test.go b/backend/plugins/drivers/driver_asynq_worker/utils_test.go new file mode 100644 index 00000000..d4b6947c --- /dev/null +++ b/backend/plugins/drivers/driver_asynq_worker/utils_test.go @@ -0,0 +1,58 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package driver_asynq_worker + +import ( + "testing" + + "github.com/redis/go-redis/v9/maintnotifications" +) + +func TestMaintNotificationsConfig(t *testing.T) { + tests := []struct { + name string + enabled bool + wantMode maintnotifications.Mode + }{ + { + name: "disabled mode when flag is false", + enabled: false, + wantMode: maintnotifications.ModeDisabled, + }, + { + name: "auto mode when flag is true", + enabled: true, + wantMode: maintnotifications.ModeAuto, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := maintNotificationsConfig(tt.enabled) + if cfg == nil { + t.Fatalf("maintNotificationsConfig(%v) = nil, want non-nil", tt.enabled) + } + if cfg.Mode != tt.wantMode { + t.Fatalf("maintNotificationsConfig(%v).Mode = %v, want %v", tt.enabled, cfg.Mode, tt.wantMode) + } + }) + } +} + +func TestPrefixedQueue(t *testing.T) { + oldPrefix := GetKeyPrefix() + defer func() { + SetKeyPrefix(oldPrefix) + }() + + SetKeyPrefix("test:") + if got := PrefixedQueue("default"); got != "test:default" { + t.Fatalf("PrefixedQueue() = %q, want %q", got, "test:default") + } + + SetKeyPrefix("") + if got := PrefixedQueue("default"); got != "default" { + t.Fatalf("PrefixedQueue() = %q, want %q", got, "default") + } +} diff --git a/backend/plugins/drivers/driver_http/cache_helper.go b/backend/plugins/drivers/driver_http/cache_helper.go new file mode 100644 index 00000000..a7da8372 --- /dev/null +++ b/backend/plugins/drivers/driver_http/cache_helper.go @@ -0,0 +1,36 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package driver_http + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "context" + "sync" +) + +var ( + cacheMu sync.RWMutex + cacheSvc contracts.CacheService +) + +func setCacheService(s contracts.CacheService) { + cacheMu.Lock() + defer cacheMu.Unlock() + cacheSvc = s +} + +// getCache resolves the cache service from a micro-kernel context, falling back +// to the instance bound during plugin registration. +func getCache(ctx context.Context) contracts.CacheService { + if c, ok := ctx.(*core.Context); ok && c != nil { + if s, err := core.Inject[contracts.CacheService](c); err == nil && s != nil { + return s + } + } + cacheMu.RLock() + s := cacheSvc + cacheMu.RUnlock() + return s +} diff --git a/backend/plugins/drivers/driver_http/config.go b/backend/plugins/drivers/driver_http/config.go new file mode 100644 index 00000000..df2046c1 --- /dev/null +++ b/backend/plugins/drivers/driver_http/config.go @@ -0,0 +1,28 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package driver_http + +type httpAppConfig struct { + Addr string `config:"addr" env:"APP_ADDR" default:":8000"` + AppName string `config:"app_name" env:"APP_NAME" default:"Wavelet"` + APIPrefix string `config:"api_prefix" env:"APP_API_PREFIX" default:"/api/v1"` + Env string `config:"env" env:"APP_ENV" default:"development"` + GracefulShutdownTimeout int `config:"graceful_shutdown_timeout" env:"APP_GRACEFUL_SHUTDOWN_TIMEOUT" default:"30"` + SessionCookieName string `config:"session_cookie_name" env:"APP_SESSION_COOKIE_NAME" default:"wavelet_session"` + SessionSecret string `config:"session_secret" env:"APP_SESSION_SECRET" secret:"true"` + SessionDomain string `config:"session_domain" env:"APP_SESSION_DOMAIN"` + SessionAge int `config:"session_age" env:"APP_SESSION_AGE" default:"86400"` + SessionHTTPOnly bool `config:"session_http_only" env:"APP_SESSION_HTTP_ONLY" default:"true"` + SessionSecure bool `config:"session_secure" env:"APP_SESSION_SECURE"` +} + +type httpRedisConfig struct { + Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false"` + Addrs []string `config:"addrs" env:"REDIS_ADDR"` + Username string `config:"username" env:"REDIS_USERNAME"` + Password string `config:"password" env:"REDIS_PASSWORD" secret:"true"` + DB int `config:"db" env:"REDIS_DB"` + KeyPrefix string `config:"key_prefix" env:"REDIS_KEY_PREFIX"` + MinIdleConn int `config:"min_idle_conn" env:"REDIS_MIN_IDLE_CONN"` +} diff --git a/backend/plugins/drivers/driver_http/db_helper.go b/backend/plugins/drivers/driver_http/db_helper.go new file mode 100644 index 00000000..61ab5ac7 --- /dev/null +++ b/backend/plugins/drivers/driver_http/db_helper.go @@ -0,0 +1,39 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package driver_http + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "context" + "sync" + + "gorm.io/gorm" +) + +var ( + dbMu sync.RWMutex + dbSvc contracts.DBService +) + +func setDBService(s contracts.DBService) { + dbMu.Lock() + defer dbMu.Unlock() + dbSvc = s +} + +func getDB(ctx context.Context) *gorm.DB { + if c, ok := ctx.(*core.Context); ok && c != nil { + if s, err := core.Inject[contracts.DBService](c); err == nil && s != nil { + return s.DB(ctx) + } + } + dbMu.RLock() + s := dbSvc + dbMu.RUnlock() + if s != nil { + return s.DB(ctx) + } + return nil +} diff --git a/backend/plugins/drivers/driver_http/engine.go b/backend/plugins/drivers/driver_http/engine.go new file mode 100644 index 00000000..2ca969a1 --- /dev/null +++ b/backend/plugins/drivers/driver_http/engine.go @@ -0,0 +1,103 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package driver_http + +import ( + "log" + "net/http" + "strconv" + + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/cookie" + "github.com/gin-contrib/sessions/redis" + "github.com/gin-gonic/gin" + "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" +) + +// BuildEngine 构建并初始化 Gin 路由引擎及全部中间件和路由 +func BuildEngine() (*gin.Engine, error) { + return BuildEngineWithConfig(httpAppConfig{}, httpRedisConfig{}) +} + +// BuildEngineWithConfig constructs the Gin engine with explicitly injected configuration. +func BuildEngineWithConfig(appCfg httpAppConfig, redisCfg httpRedisConfig) (*gin.Engine, error) { + // 运行模式 + if appCfg.Env == "production" || appCfg.Env == "prod" { + gin.SetMode(gin.ReleaseMode) + } + + setAPIPrefix(appCfg.APIPrefix) + + // 初始化路由 + r := gin.New() + r.Use(gin.Recovery()) + r.Use(corsMiddleware()) + + sessionSecret := appCfg.SessionSecret + if sessionSecret == "" { + sessionSecret = "wavelet-default-session-secret" + } + + sessionStore := initSessionStore(sessionSecret, redisCfg) + + sessionCookieName := appCfg.SessionCookieName + if sessionCookieName == "" { + sessionCookieName = "wavelet_session" + } + + sessionAge := appCfg.SessionAge + if sessionAge <= 0 { + sessionAge = 86400 + } + + sessionStore.Options(sessions.Options{ + Path: "/", + Domain: appCfg.SessionDomain, + MaxAge: sessionAge, + HttpOnly: appCfg.SessionHTTPOnly, + Secure: appCfg.SessionSecure, + SameSite: http.SameSiteLaxMode, + }) + + r.Use(sessions.Sessions(sessionCookieName, sessionStore)) + + appName := appCfg.AppName + if appName == "" { + appName = "Wavelet" + } + + // 补充中间件 + r.Use(otelgin.Middleware(appName), errorHandlerMiddleware(), loggerMiddleware()) + + return r, nil +} + +func initSessionStore(sessionSecret string, redisCfg httpRedisConfig) sessions.Store { + if !redisCfg.Enabled || len(redisCfg.Addrs) == 0 { + return cookie.NewStore([]byte(sessionSecret)) + } + + sessionAddr := redisCfg.Addrs[0] + store, err := redis.NewStoreWithDB( + redisCfg.MinIdleConn, + "tcp", + sessionAddr, + redisCfg.Username, + redisCfg.Password, + strconv.Itoa(redisCfg.DB), + []byte(sessionSecret), + ) + if err != nil { + log.Printf("[driver_http] init redis session store failed, fallback to cookie store: %v\n", err) + return cookie.NewStore([]byte(sessionSecret)) + } + + if redisCfg.KeyPrefix != "" { + if err := redis.SetKeyPrefix(store, redisCfg.KeyPrefix+"session:"); err != nil { + log.Printf("[API] set session key prefix failed: %v\n", err) + } + } + + return store +} diff --git a/backend/plugins/drivers/driver_http/frontend.go b/backend/plugins/drivers/driver_http/frontend.go new file mode 100644 index 00000000..0aa99c33 --- /dev/null +++ b/backend/plugins/drivers/driver_http/frontend.go @@ -0,0 +1,92 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package driver_http + +import ( + "io" + "io/fs" + "net/http" + "path" + "strings" + + "github.com/gin-gonic/gin" +) + +const indexFile = "index.html" + +// serverOwnedPrefixes are backend-owned namespaces. A miss there must keep Gin's default +// 404 instead of silently returning the frontend shell, which would mask broken API links. +var serverOwnedPrefixes = []string{"/api/", "/f/"} + +// registerFrontend mounts assets as the NoRoute fallback so client-side routes resolve. +// It is a no-op when assets is nil, i.e. the binary was built without the embed_frontend tag. +func registerFrontend(engine *gin.Engine, assets fs.FS) { + if assets == nil { + return + } + + engine.NoRoute(func(c *gin.Context) { + if isServerOwned(c.Request.URL.Path) { + return + } + + if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead { + c.JSON(http.StatusMethodNotAllowed, gin.H{"error_msg": "Method not allowed"}) + return + } + + for _, candidate := range assetCandidates(c.Request.URL.Path) { + if serveAsset(c, assets, candidate) { + return + } + } + }) +} + +func isServerOwned(urlPath string) bool { + for _, prefix := range serverOwnedPrefixes { + if strings.HasPrefix(urlPath, prefix) { + return true + } + } + return false +} + +// assetCandidates maps a request path onto the static export layout: exact file, +// Next.js clean-URL siblings, then the single-page-app shell. +func assetCandidates(urlPath string) []string { + clean := strings.TrimPrefix(path.Join("/", urlPath), "/") + if clean == "" || clean == "." { + return []string{indexFile} + } + + candidates := []string{clean} + if !strings.Contains(clean, ".") { + candidates = append(candidates, clean+".html", path.Join(clean, indexFile)) + } + + return append(candidates, indexFile) +} + +// serveAsset writes name when it exists as a regular file, reporting whether it served. +func serveAsset(c *gin.Context, assets fs.FS, name string) bool { + file, err := assets.Open(name) + if err != nil { + return false + } + defer func() { _ = file.Close() }() + + info, err := file.Stat() + if err != nil || info.IsDir() { + return false + } + + seeker, ok := file.(io.ReadSeeker) + if !ok { + return false + } + + http.ServeContent(c.Writer, c.Request, name, info.ModTime(), seeker) + return true +} diff --git a/backend/plugins/drivers/driver_http/frontend_disabled.go b/backend/plugins/drivers/driver_http/frontend_disabled.go new file mode 100644 index 00000000..846aea59 --- /dev/null +++ b/backend/plugins/drivers/driver_http/frontend_disabled.go @@ -0,0 +1,12 @@ +//go:build !embed_frontend + +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package driver_http + +import "io/fs" + +// frontendAssets returns nil: this binary was built without the embedded frontend bundle, +// so unmatched routes keep Gin's default 404 and the frontend is served separately. +func frontendAssets() fs.FS { return nil } diff --git a/backend/plugins/drivers/driver_http/frontend_embedded.go b/backend/plugins/drivers/driver_http/frontend_embedded.go new file mode 100644 index 00000000..2d843ec8 --- /dev/null +++ b/backend/plugins/drivers/driver_http/frontend_embedded.go @@ -0,0 +1,24 @@ +//go:build embed_frontend + +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package driver_http + +import ( + "embed" + "io/fs" +) + +//go:embed all:dist +var frontendFS embed.FS + +// frontendAssets returns the exported Next.js bundle shipped inside this binary. +func frontendAssets() fs.FS { + sub, err := fs.Sub(frontendFS, "dist") + if err != nil { + panic("driver_http: embedded frontend bundle unavailable: " + err.Error()) + } + + return sub +} diff --git a/backend/plugins/drivers/driver_http/middlewares.go b/backend/plugins/drivers/driver_http/middlewares.go new file mode 100644 index 00000000..69ada22b --- /dev/null +++ b/backend/plugins/drivers/driver_http/middlewares.go @@ -0,0 +1,184 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package driver_http 提供 HTTP 路由中间件与服务启动 +package driver_http + +import ( + "Wavelet/core/extpoints" + "Wavelet/pkg/logger" + "Wavelet/pkg/response" + "context" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" + + otel_trace "Wavelet/pkg/trace" +) + +var ( + apiPrefixMu sync.RWMutex + apiPrefix = "/api/v1" +) + +// whitelist holds the global no-auth HTTP patterns. They are set once at +// configuration time and matched per request, so PathWhitelist parses them up front. +var whitelist = extpoints.NewPathWhitelist() + +// SetWhitelist configures global whitelist patterns for HTTP routes. +func SetWhitelist(patterns []string) { + whitelist.Replace(patterns...) +} + +// IsPathWhitelisted checks if the given path matches any registered whitelist pattern. +func IsPathWhitelisted(path string) bool { + return whitelist.Match(path) +} + +func setAPIPrefix(prefix string) { + if prefix == "" { + return + } + apiPrefixMu.Lock() + defer apiPrefixMu.Unlock() + apiPrefix = prefix +} + +func getAPIPrefix() string { + apiPrefixMu.RLock() + defer apiPrefixMu.RUnlock() + return apiPrefix +} + +func loggerMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + // 初始化 Trace + ctx, span := otel_trace.Start(c.Request.Context(), "LoggerMiddleware") + defer span.End() + + // 开始计时 + start := time.Now() + + // 记录请求路径和 Query + path := c.Request.URL.Path + raw := c.Request.URL.RawQuery + if raw != "" { + path = path + "?" + raw + } + + // 执行请求 + c.Next() + + // 停止计时 + end := time.Now() + latency := end.Sub(start) + + // 打印日志 + // 排除健康检查接口 + healthPath := getAPIPrefix() + "/health" + if c.Request.URL.Path != healthPath { + logger.InfoF( + ctx, + "[LoggerMiddleware] %s %s\nStartTime: %s\nEndTime: %s\nLatency: %d\nClientIP: %s\nResponse: %d %d", + c.Request.Method, + path, + start.Format(time.RFC3339), + end.Format(time.RFC3339), + latency.Milliseconds(), + c.ClientIP(), + c.Writer.Status(), + c.Writer.Size(), + ) + } + + // 设置 Span 状态 + if c.Writer.Status() >= http.StatusBadRequest { + span := trace.SpanFromContext(ctx) + span.SetStatus(codes.Error, strconv.Itoa(c.Writer.Status())) + } + } +} + +const ( + // serverAddressConfigKey 是 admin 域声明的系统配置键(跨插件不能直接引用其常量)。 + serverAddressConfigKey = "server_address" + // serverAddressCacheKey / serverAddressCacheTTL 把 CORS 允许来源的读取从每请求 + // 一次主库查询降为每 TTL 一次,TTL 与存储驱动的系统配置缓存保持一致。 + serverAddressCacheKey = "driver_http:cors:server_address" + serverAddressCacheTTL = 5 * time.Second +) + +// loadServerAddress reads the configured server address straight from system configs. +func loadServerAddress(ctx context.Context) (string, error) { + db := getDB(ctx) + if db == nil { + return "", nil + } + var val string + if err := db.Table("w_system_configs").Where("key = ?", serverAddressConfigKey).Pluck("value", &val).Error; err != nil { + return "", err + } + return val, nil +} + +// serverAddress returns the configured server address, served from the shared +// cache so CORS does not reach the database on every request. +func serverAddress(ctx context.Context) (string, error) { + cacheSvc := getCache(ctx) + if cacheSvc == nil { + return loadServerAddress(ctx) + } + + var val string + if err := cacheSvc.GetOrSet(ctx, serverAddressCacheKey, &val, serverAddressCacheTTL, func() (any, error) { + return loadServerAddress(ctx) + }); err != nil { + return "", err + } + return val, nil +} + +func isOriginAllowed(ctx context.Context, origin string) bool { + val, err := serverAddress(ctx) + if err != nil || val == "" { + return false + } + allowedOrigins := strings.Split(val, ",") + for _, allowed := range allowedOrigins { + allowed = strings.TrimRight(strings.TrimSpace(allowed), "/") + if allowed != "" && strings.EqualFold(allowed, origin) { + return true + } + } + return false +} + +func corsMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + origin := c.Request.Header.Get("Origin") + if origin != "" && isOriginAllowed(c.Request.Context(), origin) { + c.Writer.Header().Set("Access-Control-Allow-Origin", origin) + c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") + c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With, X-Access-Token, X-Cap-Token") + c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH") + } + + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(http.StatusNoContent) + return + } + + c.Next() + } +} + +// errorHandlerMiddleware 委托给 response.ErrorHandlerMiddleware,保持路由层单一入口。 +func errorHandlerMiddleware() gin.HandlerFunc { + return response.ErrorHandlerMiddleware() +} diff --git a/backend/plugins/drivers/driver_http/middlewares_test.go b/backend/plugins/drivers/driver_http/middlewares_test.go new file mode 100644 index 00000000..03b29da7 --- /dev/null +++ b/backend/plugins/drivers/driver_http/middlewares_test.go @@ -0,0 +1,245 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package driver_http + +import ( + "Wavelet/core/contracts" + "Wavelet/pkg/testhelper" + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type mockDBService struct { + db *gorm.DB +} + +func (m *mockDBService) GORM() *gorm.DB { + return m.db +} + +func (m *mockDBService) DB(ctx context.Context) *gorm.DB { + return m.db.WithContext(ctx) +} + +func (m *mockDBService) Named(_ string) *gorm.DB { + return m.db +} + +func TestCORSMiddleware(t *testing.T) { + dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) + setDBService(&mockDBService{db: dbConn}) + defer func() { + setDBService(nil) + cleanup() + }() + + gin.SetMode(gin.TestMode) + + clearConfigCache := func() {} + + t.Run("missing server_address configuration returns no CORS headers", func(t *testing.T) { + clearConfigCache() + if err := dbConn.Table("w_system_configs").Where("key = ?", "server_address").Update("value", "").Error; err != nil { + t.Fatalf("failed to update config: %v", err) + } + + r := gin.New() + r.Use(corsMiddleware()) + r.GET("/test", func(c *gin.Context) { + c.String(http.StatusOK, "ok") + }) + + req, _ := http.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Origin", "http://attacker.com") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Header().Get("Access-Control-Allow-Origin") != "" { + t.Errorf("expected no Access-Control-Allow-Origin, got %s", w.Header().Get("Access-Control-Allow-Origin")) + } + }) + + t.Run("configured server_address allows exact origin match", func(t *testing.T) { + if err := dbConn.Table("w_system_configs").Where("key = ?", "server_address").Update("value", "http://trusted.com").Error; err != nil { + t.Fatalf("failed to update config: %v", err) + } + + r := gin.New() + r.Use(corsMiddleware()) + r.GET("/test", func(c *gin.Context) { + c.String(http.StatusOK, "ok") + }) + + // Trusted origin + req, _ := http.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Origin", "http://trusted.com") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Header().Get("Access-Control-Allow-Origin") != "http://trusted.com" { + t.Errorf("expected Access-Control-Allow-Origin http://trusted.com, got %s", w.Header().Get("Access-Control-Allow-Origin")) + } + if w.Header().Get("Access-Control-Allow-Credentials") != "true" { + t.Errorf("expected Access-Control-Allow-Credentials true, got %s", w.Header().Get("Access-Control-Allow-Credentials")) + } + + // Untrusted origin + req, _ = http.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Origin", "http://attacker.com") + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Header().Get("Access-Control-Allow-Origin") != "" { + t.Errorf("expected no Access-Control-Allow-Origin for attacker, got %s", w.Header().Get("Access-Control-Allow-Origin")) + } + }) + + t.Run("preflight OPTIONS request responds with 204", func(t *testing.T) { + if err := dbConn.Table("w_system_configs").Where("key = ?", "server_address").Update("value", "http://trusted.com").Error; err != nil { + t.Fatalf("failed to update config: %v", err) + } + + r := gin.New() + r.Use(corsMiddleware()) + + req, _ := http.NewRequest(http.MethodOptions, "/test", nil) + req.Header.Set("Origin", "http://trusted.com") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusNoContent { + t.Errorf("expected status 204, got %d", w.Code) + } + if w.Header().Get("Access-Control-Allow-Methods") == "" { + t.Error("expected Access-Control-Allow-Methods header") + } + }) +} + +// memoryCache 是一个可工作的内存缓存,用于统计 loader(即数据库查询)实际执行次数。 +type memoryCache struct { + values map[string]string + loads int +} + +func (c *memoryCache) Get(_ context.Context, key string, target any) error { + v, ok := c.values[key] + if !ok { + return contracts.ErrCacheMiss + } + dst, ok := target.(*string) + if !ok { + return contracts.ErrCacheMiss + } + *dst = v + return nil +} + +func (c *memoryCache) Set(_ context.Context, key string, value any, _ time.Duration) error { + v, ok := value.(string) + if !ok { + return nil + } + c.values[key] = v + return nil +} + +func (c *memoryCache) Delete(_ context.Context, key string) error { + delete(c.values, key) + return nil +} + +func (c *memoryCache) GetOrSet(_ context.Context, key string, target any, _ time.Duration, loader func() (any, error)) error { + if err := c.Get(context.Background(), key, target); err == nil { + return nil + } + raw, err := loader() + if err != nil { + return err + } + c.loads++ + v, ok := raw.(string) + if !ok { + return nil + } + c.values[key] = v + if dst, ok := target.(*string); ok { + *dst = v + } + return nil +} + +func (c *memoryCache) Invalidate(ctx context.Context, key string) error { + return c.Delete(ctx, key) +} + +// TestCORSAllowedOriginReadsConfigOncePerCacheWindow 回归:CORS 的来源校验不得在 +// 每个请求上都查询主库,缓存有效期内 loader 只应执行一次。 +func TestCORSAllowedOriginReadsConfigOncePerCacheWindow(t *testing.T) { + dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) + setDBService(&mockDBService{db: dbConn}) + cache := &memoryCache{values: map[string]string{}} + setCacheService(cache) + defer func() { + setCacheService(nil) + setDBService(nil) + cleanup() + }() + + ctx := context.Background() + const origin = "http://localhost:8000" // testhelper 预置的 server_address + for range 3 { + if !isOriginAllowed(ctx, origin) { + t.Fatalf("origin %q should be allowed", origin) + } + } + + if cache.loads != 1 { + t.Errorf("expected 1 config load across 3 requests, got %d", cache.loads) + } +} + +func TestBuildEngineWithCookieFallback(t *testing.T) { + gin.SetMode(gin.TestMode) + appCfg := httpAppConfig{ + SessionSecret: "test-secret", + SessionCookieName: "wavelet_session", + SessionAge: 3600, + } + redisCfg := httpRedisConfig{ + Enabled: false, + } + + engine, err := BuildEngineWithConfig(appCfg, redisCfg) + if err != nil { + t.Fatalf("expected nil error with cookie fallback, got: %v", err) + } + + engine.GET("/set-session", func(c *gin.Context) { + sess := sessions.Default(c) + sess.Set("test_user_id", uint64(12345)) + _ = sess.Save() + c.String(http.StatusOK, "ok") + }) + + req, _ := http.NewRequest(http.MethodGet, "/set-session", nil) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 OK, got %d", w.Code) + } + + setCookie := w.Header().Get("Set-Cookie") + if setCookie == "" { + t.Fatal("expected Set-Cookie header in response, got none") + } +} diff --git a/backend/plugins/drivers/driver_http/plugin.go b/backend/plugins/drivers/driver_http/plugin.go new file mode 100644 index 00000000..83d933bc --- /dev/null +++ b/backend/plugins/drivers/driver_http/plugin.go @@ -0,0 +1,309 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package driver_http provides the Gin HTTP web server driver plugin for Cordis. +package driver_http + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "Wavelet/pkg/util" + "context" + "errors" + "fmt" + "net" + "net/http" + "sync" + "time" + + "github.com/gin-gonic/gin" +) + +const ( + defaultAddr = ":8080" + defaultReadHeaderTimeout = 10 * time.Second + defaultShutdownTimeout = 5 * time.Second +) + +// Option configures the HTTP driver plugin. +type Option func(*Plugin) + +// WithAddr sets the TCP address for the HTTP server to listen on. +func WithAddr(addr string) Option { + return func(p *Plugin) { + p.addr = addr + } +} + +// WithEngine sets a pre-configured Gin engine for the HTTP server. +func WithEngine(engine *gin.Engine) Option { + return func(p *Plugin) { + p.engine = engine + } +} + +// WithReadHeaderTimeout sets the ReadHeaderTimeout for http.Server. +func WithReadHeaderTimeout(d time.Duration) Option { + return func(p *Plugin) { + p.readHeaderTimeout = d + } +} + +// WithShutdownTimeout sets the fallback timeout for graceful server shutdown. +func WithShutdownTimeout(d time.Duration) Option { + return func(p *Plugin) { + p.shutdownTimeout = d + } +} + +// Plugin implements core.Plugin and core.Driver for Gin HTTP Web Server. +type Plugin struct { + mu sync.RWMutex + addr string + engine *gin.Engine + server *http.Server + listener net.Listener + running bool + readHeaderTimeout time.Duration + shutdownTimeout time.Duration + coreCtx *core.Context +} + +// New creates a new Gin HTTP server driver plugin. +func New(opts ...Option) *Plugin { + p := &Plugin{ + addr: defaultAddr, + readHeaderTimeout: defaultReadHeaderTimeout, + shutdownTimeout: defaultShutdownTimeout, + } + + for _, opt := range opts { + if opt != nil { + opt(p) + } + } + + return p +} + +// Name returns the unique plugin identifier. +func (p *Plugin) Name() string { + return "driver_http" +} + +// DeclareConfig declares configuration bindings for driver_http. +func (p *Plugin) DeclareConfig() []core.ConfigBinding { + return []core.ConfigBinding{ + {Prefix: "app", Target: &httpAppConfig{}}, + {Prefix: "redis", Target: &httpRedisConfig{}}, + } +} + +// Apply mounts the HTTP driver plugin into the micro-kernel Context. +func (p *Plugin) Apply(ctx *core.Context) error { + var appCfg httpAppConfig + if err := ctx.Config().Bind("app", &appCfg); err != nil { + return err + } + var redisCfg httpRedisConfig + _ = ctx.Config().Bind("redis", &redisCfg) + + p.mu.Lock() + p.coreCtx = ctx + if p.addr == defaultAddr && appCfg.Addr != "" { + p.addr = appCfg.Addr + } + if appCfg.GracefulShutdownTimeout > 0 { + p.shutdownTimeout = time.Duration(appCfg.GracefulShutdownTimeout) * time.Second + } + p.mu.Unlock() + + // Bind DBService from Context + if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil { + setDBService(db) + } else { + core.When[contracts.DBService](ctx, func(db contracts.DBService) { + setDBService(db) + }) + } + ctx.OnDispose(func() error { + setDBService(nil) + return nil + }) + + // Bind CacheService from Context + if cache, err := core.Inject[contracts.CacheService](ctx); err == nil && cache != nil { + setCacheService(cache) + } else { + core.When[contracts.CacheService](ctx, func(cache contracts.CacheService) { + setCacheService(cache) + }) + } + ctx.OnDispose(func() error { + setCacheService(nil) + return nil + }) + + ctx.OnDispose(func() error { + shutdownCtx, cancel := context.WithTimeout(context.Background(), p.shutdownTimeout) + defer cancel() + return p.Stop(shutdownCtx) + }) + + return ctx.RegisterDriver(p) +} + +// Type returns DriverTypeHTTP. +func (p *Plugin) Type() core.DriverType { + return core.DriverTypeHTTP +} + +// Start boots the Gin HTTP server, binds routes collected from ctx.Router(), and starts listening. +func (p *Plugin) Start(ctx context.Context) error { + p.mu.Lock() + defer p.mu.Unlock() + + if p.running { + return nil + } + + if p.engine == nil { + var appCfg httpAppConfig + var redisCfg httpRedisConfig + if p.coreCtx != nil { + _ = p.coreCtx.Config().Bind("app", &appCfg) + _ = p.coreCtx.Config().Bind("redis", &redisCfg) + } + var err error + p.engine, err = BuildEngineWithConfig(appCfg, redisCfg) + if err != nil { + p.engine, _ = BuildEngine() + } + } + + // Mount routes collected in Context RouterExtension + if p.coreCtx != nil && p.coreCtx.Router() != nil { + SetWhitelist(p.coreCtx.Router().Whitelist()) + for _, rd := range p.coreCtx.Router().Routes() { + allHandlers := make([]gin.HandlerFunc, 0, len(rd.Middlewares)+len(rd.Handlers)) + + for _, m := range rd.Middlewares { + gh, err := toGinHandler(m) + if err != nil { + return fmt.Errorf("driver_http: invalid middleware for route %s %s: %w", rd.Method, rd.Path, err) + } + allHandlers = append(allHandlers, gh) + } + + for _, h := range rd.Handlers { + gh, err := toGinHandler(h) + if err != nil { + return fmt.Errorf("driver_http: invalid handler for route %s %s: %w", rd.Method, rd.Path, err) + } + allHandlers = append(allHandlers, gh) + } + + p.engine.Handle(rd.Method, rd.Path, allHandlers...) + } + } + + registerFrontend(p.engine, frontendAssets()) + + p.server = &http.Server{ + Addr: p.addr, + Handler: p.engine, + ReadHeaderTimeout: p.readHeaderTimeout, + } + + lc := &net.ListenConfig{} + listener, err := lc.Listen(ctx, "tcp", p.addr) + if err != nil { + return fmt.Errorf("driver_http: listen on %s failed: %w", p.addr, err) + } + + p.listener = listener + p.addr = listener.Addr().String() + p.running = true + + srv := p.server + util.Go(func() { + if serveErr := srv.Serve(listener); serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) { + _ = serveErr + } + }) + + return nil +} + +// Stop gracefully stops the HTTP server. +// +//nolint:contextcheck +func (p *Plugin) Stop(ctx context.Context) error { + p.mu.Lock() + defer p.mu.Unlock() + + if !p.running { + return nil + } + + p.running = false + + var err error + if p.server != nil { + if ctx == nil { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(context.Background(), p.shutdownTimeout) + defer cancel() + } + err = p.server.Shutdown(ctx) + } + + if p.listener != nil { + _ = p.listener.Close() + p.listener = nil + } + + return err +} + +// Addr returns the current listening address (or configured address if not yet started). +func (p *Plugin) Addr() string { + p.mu.RLock() + defer p.mu.RUnlock() + return p.addr +} + +// Engine returns the underlying Gin engine. +func (p *Plugin) Engine() *gin.Engine { + p.mu.RLock() + defer p.mu.RUnlock() + return p.engine +} + +// IsRunning returns whether the HTTP server is currently running. +func (p *Plugin) IsRunning() bool { + p.mu.RLock() + defer p.mu.RUnlock() + return p.running +} + +func toGinHandler(h any) (gin.HandlerFunc, error) { + if h == nil { + return nil, errors.New("nil handler") + } + + switch fn := h.(type) { + case gin.HandlerFunc: + return fn, nil + case func(*gin.Context): + return gin.HandlerFunc(fn), nil + case http.HandlerFunc: + return gin.WrapF(fn), nil + case func(http.ResponseWriter, *http.Request): + return gin.WrapF(fn), nil + case http.Handler: + return gin.WrapH(fn), nil + default: + return nil, fmt.Errorf("unsupported handler type: %T", h) + } +} diff --git a/backend/plugins/drivers/driver_inproc_cron/plugin.go b/backend/plugins/drivers/driver_inproc_cron/plugin.go new file mode 100644 index 00000000..49fbc3fe --- /dev/null +++ b/backend/plugins/drivers/driver_inproc_cron/plugin.go @@ -0,0 +1,99 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package driver_inproc_cron provides the zero-dependency in-process cron scheduler driver plugin for Cordis. +package driver_inproc_cron + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "context" + "sync" +) + +// Plugin implements core.Plugin and core.Driver for in-process cron job scheduling. +type Plugin struct { + mu sync.RWMutex + coreCtx *core.Context + scheduler *inprocScheduler +} + +// New creates a new in-process cron scheduler driver plugin. +func New() *Plugin { + return &Plugin{} +} + +// Name returns the unique identifier of the plugin. +func (p *Plugin) Name() string { + return "driver_inproc_cron" +} + +// Manifest returns the plugin metadata. +func (p *Plugin) Manifest() core.Manifest { + return core.Manifest{ + Name: "driver_inproc_cron", + Version: "1.0.0", + Description: "Zero-dependency in-process cron scheduler driver plugin", + Author: "Wavelet Team", + } +} + +type redisGateConfig struct { + Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"` +} + +// DeclareConfig declares configuration bindings for driver_inproc_cron. +func (p *Plugin) DeclareConfig() []core.ConfigBinding { + return []core.ConfigBinding{ + {Prefix: "redis", Target: &redisGateConfig{}}, + } +} + +// ConfigEnabled gates plugin activation when Redis is disabled. +func (p *Plugin) ConfigEnabled(view core.ConfigView) bool { + return !view.Bool("redis.enabled", false) +} + +// Apply registers the scheduler driver into the Context. +func (p *Plugin) Apply(ctx *core.Context) error { + p.mu.Lock() + p.coreCtx = ctx + p.mu.Unlock() + + ctx.OnDispose(func() error { + return p.Stop(context.Background()) + }) + + return ctx.RegisterDriver(p) +} + +// Type returns DriverTypeScheduler. +func (p *Plugin) Type() core.DriverType { + return core.DriverTypeScheduler +} + +// Start boots the in-process cron scheduler. ctx is the app-lifetime context; +// cron-dispatched tasks carry it so cancellation propagates on shutdown. +func (p *Plugin) Start(ctx context.Context) error { + p.mu.Lock() + defer p.mu.Unlock() + + if p.scheduler == nil { + taskSvc, _ := core.Inject[contracts.TaskService](p.coreCtx) + p.scheduler = newInprocScheduler(p.coreCtx.Schedules(), p.coreCtx.Tasks(), taskSvc) + } + + return p.scheduler.Start(ctx) +} + +// Stop terminates the in-process cron scheduler. +func (p *Plugin) Stop(_ context.Context) error { + p.mu.Lock() + defer p.mu.Unlock() + + if p.scheduler != nil { + p.scheduler.Stop() + p.scheduler = nil + } + return nil +} diff --git a/backend/plugins/drivers/driver_inproc_cron/plugin_test.go b/backend/plugins/drivers/driver_inproc_cron/plugin_test.go new file mode 100644 index 00000000..7a4fd7fb --- /dev/null +++ b/backend/plugins/drivers/driver_inproc_cron/plugin_test.go @@ -0,0 +1,42 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package driver_inproc_cron_test + +import ( + "Wavelet/core" + "Wavelet/plugins/drivers/driver_inproc_cron" + "context" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInprocCronPlugin(t *testing.T) { + ctx := core.NewContext(context.Background()) + + cronPlugin := driver_inproc_cron.New() + assert.Equal(t, "driver_inproc_cron", cronPlugin.Name()) + assert.Equal(t, core.DriverTypeScheduler, cronPlugin.Type()) + require.NoError(t, cronPlugin.Apply(ctx)) + + var cronTriggered atomic.Int32 + ctx.Tasks().Register("cron:heartbeat", func(ctx context.Context) error { + cronTriggered.Add(1) + return nil + }) + + // Every second cron spec + ctx.Schedules().RegisterCron("* * * * * *", "cron:heartbeat", nil) + + require.NoError(t, cronPlugin.Start(context.Background())) + + require.Eventually(t, func() bool { + return cronTriggered.Load() >= 1 + }, 3*time.Second, 100*time.Millisecond) + + require.NoError(t, cronPlugin.Stop(context.Background())) +} diff --git a/backend/plugins/drivers/driver_inproc_cron/scheduler.go b/backend/plugins/drivers/driver_inproc_cron/scheduler.go new file mode 100644 index 00000000..8b3b33f0 --- /dev/null +++ b/backend/plugins/drivers/driver_inproc_cron/scheduler.go @@ -0,0 +1,144 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package driver_inproc_cron + +import ( + "Wavelet/core/contracts" + "Wavelet/core/extpoints" + "Wavelet/pkg/logger" + "Wavelet/pkg/util" + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/robfig/cron/v3" +) + +type inprocScheduler struct { + mu sync.RWMutex + cronRunner *cron.Cron + scheduleReg extpoints.ScheduleExtension + taskReg extpoints.TaskExtension + taskSvc contracts.TaskService + running bool +} + +func newInprocScheduler(scheduleReg extpoints.ScheduleExtension, taskReg extpoints.TaskExtension, taskSvc contracts.TaskService) *inprocScheduler { + return &inprocScheduler{ + cronRunner: cron.New(cron.WithSeconds()), + scheduleReg: scheduleReg, + taskReg: taskReg, + taskSvc: taskSvc, + } +} + +func (s *inprocScheduler) Start(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + + if s.running { + return nil + } + + if s.scheduleReg != nil { + for _, def := range s.scheduleReg.Schedules() { + s.registerJob(ctx, def) + } + } + + s.cronRunner.Start() + s.running = true + return nil +} + +func (s *inprocScheduler) Stop() { + s.mu.Lock() + defer s.mu.Unlock() + + if !s.running { + return + } + + ctx := s.cronRunner.Stop() + <-ctx.Done() + s.running = false +} + +const standardCronFields = 5 + +func (s *inprocScheduler) registerJob(ctx context.Context, def extpoints.ScheduleDefinition) { + spec := def.Spec + taskType := def.TaskType + + fields := len(strings.Fields(spec)) + cronSpec := spec + if fields == standardCronFields { + cronSpec = "0 " + spec + } + + var payloadBytes []byte + if def.Payload != nil { + switch p := def.Payload.(type) { + case []byte: + payloadBytes = p + case string: + payloadBytes = []byte(p) + default: + payloadBytes, _ = json.Marshal(p) + } + } + + _, err := s.cronRunner.AddFunc(cronSpec, func() { + if s.taskSvc != nil { + if _, dispatchErr := s.taskSvc.Dispatch(ctx, taskType, payloadBytes, "inproc_cron"); dispatchErr != nil { + logger.ErrorF(ctx, "driver_inproc_cron: dispatch task %q failed: %v", taskType, dispatchErr) + } + return + } + + if s.taskReg != nil { + if td, ok := s.taskReg.Get(taskType); ok { + util.Go(func() { + timeout := td.Timeout + if timeout <= 0 { + timeout = 5 * time.Minute + } + runCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + _ = invokeHandler(runCtx, td.Handler, payloadBytes) + }) + } + } + }) + if err != nil { + logger.ErrorF(ctx, "driver_inproc_cron: invalid cron spec %q for task %q: %v", spec, taskType, err) + } +} + +func invokeHandler(ctx context.Context, handler any, payload []byte) error { + if handler == nil { + return errors.New("nil task handler") + } + + switch fn := handler.(type) { + case func(context.Context, []byte) error: + return fn(ctx, payload) + case func(context.Context) error: + return fn(ctx) + case func([]byte) error: + return fn(payload) + case func() error: + return fn() + default: + return fmt.Errorf("unsupported handler type: %T", handler) + } +} diff --git a/backend/plugins/drivers/driver_inproc_worker/executor.go b/backend/plugins/drivers/driver_inproc_worker/executor.go new file mode 100644 index 00000000..7fcc9f2f --- /dev/null +++ b/backend/plugins/drivers/driver_inproc_worker/executor.go @@ -0,0 +1,204 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package driver_inproc_worker + +import ( + "Wavelet/core/extpoints" + "Wavelet/pkg/idgen" + "Wavelet/pkg/util" + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + "time" +) + +const defaultRetryBackoff = 500 * time.Millisecond + +// TaskMessage represents an in-process task item in the queue. +type TaskMessage struct { + ID string + TaskType string + Payload []byte + Source string + CreatedAt time.Time + RetryLeft int +} + +// InprocQueue manages in-memory task queuing and worker pool execution. +type InprocQueue struct { + concurrency int + queue chan TaskMessage + taskReg extpoints.TaskExtension + running atomic.Bool + stopCh chan struct{} + wg sync.WaitGroup + + // baseCtx is the app-lifetime context captured at Start; task handlers + // derive their timeouts from it so shutdown cancellation propagates. + baseCtx context.Context +} + +// NewInprocQueue creates a new InprocQueue with a given concurrency and queue capacity. +func NewInprocQueue(concurrency, queueCap int, taskReg extpoints.TaskExtension) *InprocQueue { + if concurrency <= 0 { + concurrency = 10 + } + if queueCap <= 0 { + queueCap = 1000 + } + + return &InprocQueue{ + concurrency: concurrency, + queue: make(chan TaskMessage, queueCap), + taskReg: taskReg, + stopCh: make(chan struct{}), + } +} + +// Enqueue puts a new task into the in-process queue. +func (q *InprocQueue) Enqueue(taskType string, payload []byte, source string) (string, error) { + if !q.running.Load() { + return "", errors.New("driver_inproc_worker: queue is not running") + } + + taskID := fmt.Sprintf("inproc_%d", idgen.NextUint64ID()) + msg := TaskMessage{ + ID: taskID, + TaskType: taskType, + Payload: payload, + Source: source, + CreatedAt: time.Now(), + } + + if q.taskReg != nil { + if td, ok := q.taskReg.Get(taskType); ok { + msg.RetryLeft = td.Retry + } + } + + select { + case q.queue <- msg: + return taskID, nil + default: + return "", errors.New("driver_inproc_worker: queue is full") + } +} + +// Start begins processing tasks with the worker pool. ctx is the app-lifetime +// context used as the parent for per-task execution contexts. +func (q *InprocQueue) Start(ctx context.Context) { + if !q.running.CompareAndSwap(false, true) { + return + } + + if q.baseCtx == nil { + q.baseCtx = ctx + } + for i := 0; i < q.concurrency; i++ { + q.wg.Add(1) + util.Go(func() { + defer q.wg.Done() + q.workerLoop(ctx) + }) + } +} + +// Stop gracefully waits for in-flight tasks and shuts down workers. +func (q *InprocQueue) Stop(ctx context.Context) error { + if !q.running.CompareAndSwap(true, false) { + return nil + } + + close(q.stopCh) + + done := make(chan struct{}) + util.Go(func() { + q.wg.Wait() + close(done) + }) + + select { + case <-done: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (q *InprocQueue) workerLoop(ctx context.Context) { + for { + select { + case <-q.stopCh: + return + case <-ctx.Done(): + return + case msg, ok := <-q.queue: + if !ok { + return + } + q.executeTask(ctx, msg) + } + } +} + +func (q *InprocQueue) executeTask(ctx context.Context, msg TaskMessage) { + if q.taskReg == nil { + return + } + + td, ok := q.taskReg.Get(msg.TaskType) + if !ok { + return + } + + timeout := td.Timeout + if timeout <= 0 { + timeout = 5 * time.Minute + } + + taskCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + err := invokeHandler(taskCtx, td.Handler, msg.Payload) + if err != nil && msg.RetryLeft > 0 { + msg.RetryLeft-- + // Retry with backoff + util.Go(func() { + select { + case <-time.After(defaultRetryBackoff): + case <-q.stopCh: + return + case <-ctx.Done(): + return + } + if q.running.Load() { + select { + case q.queue <- msg: + default: + } + } + }) + } +} + +func invokeHandler(ctx context.Context, handler any, payload []byte) error { + if handler == nil { + return errors.New("nil task handler") + } + + switch fn := handler.(type) { + case func(context.Context, []byte) error: + return fn(ctx, payload) + case func(context.Context) error: + return fn(ctx) + case func([]byte) error: + return fn(payload) + case func() error: + return fn() + default: + return fmt.Errorf("unsupported handler type: %T", handler) + } +} diff --git a/backend/plugins/drivers/driver_inproc_worker/plugin.go b/backend/plugins/drivers/driver_inproc_worker/plugin.go new file mode 100644 index 00000000..cd004818 --- /dev/null +++ b/backend/plugins/drivers/driver_inproc_worker/plugin.go @@ -0,0 +1,165 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package driver_inproc_worker provides the zero-dependency in-process async worker driver plugin for Cordis. +package driver_inproc_worker + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "context" + "sync" + "time" +) + +const ( + defaultConcurrency = 10 + defaultQueueCapacity = 2000 + defaultShutdownTimeout = 5 * time.Second +) + +var ( + globalMu sync.RWMutex + globalQueue *InprocQueue +) + +// DispatchTask enqueues a background task to the active in-process worker queue. +func DispatchTask(_ context.Context, taskType string, payload []byte, source string) (string, error) { + globalMu.RLock() + q := globalQueue + globalMu.RUnlock() + + if q == nil { + return "", nil + } + return q.Enqueue(taskType, payload, source) +} + +// Option configures the in-process worker driver plugin. +type Option func(*Plugin) + +// WithConcurrency sets the number of concurrent worker goroutines. +func WithConcurrency(concurrency int) Option { + return func(p *Plugin) { + p.concurrency = concurrency + } +} + +// WithQueueCapacity sets the internal queue buffer capacity. +func WithQueueCapacity(capacity int) Option { + return func(p *Plugin) { + p.queueCapacity = capacity + } +} + +// WithShutdownTimeout sets the maximum duration to wait for in-flight tasks during graceful shutdown. +func WithShutdownTimeout(d time.Duration) Option { + return func(p *Plugin) { + p.shutdownTimeout = d + } +} + +// Plugin implements core.Plugin and core.Driver for in-process background worker execution. +type Plugin struct { + concurrency int + queueCapacity int + shutdownTimeout time.Duration + coreCtx *core.Context + queue *InprocQueue +} + +// New creates a new in-process worker driver plugin. +func New(opts ...Option) *Plugin { + p := &Plugin{ + concurrency: defaultConcurrency, + queueCapacity: defaultQueueCapacity, + shutdownTimeout: defaultShutdownTimeout, + } + for _, opt := range opts { + if opt != nil { + opt(p) + } + } + return p +} + +// Name returns the unique identifier of the plugin. +func (p *Plugin) Name() string { + return "driver_inproc_worker" +} + +// Manifest returns the plugin metadata. +func (p *Plugin) Manifest() core.Manifest { + return core.Manifest{ + Name: "driver_inproc_worker", + Version: "1.0.0", + Description: "Zero-dependency in-process async worker driver plugin", + Author: "Wavelet Team", + } +} + +type redisGateConfig struct { + Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"` +} + +// DeclareConfig declares the configuration bindings consumed by this plugin. +func (p *Plugin) DeclareConfig() []core.ConfigBinding { + return []core.ConfigBinding{ + {Prefix: "redis", Target: &redisGateConfig{}}, + } +} + +// ConfigEnabled gates plugin activation when Redis is disabled. +func (p *Plugin) ConfigEnabled(view core.ConfigView) bool { + return !view.Bool("redis.enabled", false) +} + +// Apply registers the worker driver and provides contracts.TaskService. +func (p *Plugin) Apply(ctx *core.Context) error { + p.coreCtx = ctx + + taskSvc := newInprocTaskService(ctx.Tasks()) + core.Provide[contracts.TaskService](ctx, taskSvc) + + ctx.OnDispose(func() error { + shutdownCtx, cancel := context.WithTimeout(context.Background(), p.shutdownTimeout) + defer cancel() + return p.Stop(shutdownCtx) + }) + + return ctx.RegisterDriver(p) +} + +// Type returns DriverTypeWorker. +func (p *Plugin) Type() core.DriverType { + return core.DriverTypeWorker +} + +// Start initiates task consumption. ctx is the app-lifetime context handed to +// task executions so shutdown cancellation propagates. +func (p *Plugin) Start(ctx context.Context) error { + if p.queue == nil { + p.queue = NewInprocQueue(p.concurrency, p.queueCapacity, p.coreCtx.Tasks()) + } + + globalMu.Lock() + globalQueue = p.queue + globalMu.Unlock() + + p.queue.Start(ctx) + return nil +} + +// Stop gracefully terminates worker processing. +func (p *Plugin) Stop(ctx context.Context) error { + if p.queue != nil { + err := p.queue.Stop(ctx) + globalMu.Lock() + if globalQueue == p.queue { + globalQueue = nil + } + globalMu.Unlock() + return err + } + return nil +} diff --git a/backend/plugins/drivers/driver_inproc_worker/plugin_test.go b/backend/plugins/drivers/driver_inproc_worker/plugin_test.go new file mode 100644 index 00000000..1107e433 --- /dev/null +++ b/backend/plugins/drivers/driver_inproc_worker/plugin_test.go @@ -0,0 +1,53 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package driver_inproc_worker_test + +import ( + "Wavelet/core" + "Wavelet/core/extpoints" + "Wavelet/pkg/idgen" + "Wavelet/plugins/drivers/driver_inproc_worker" + "context" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInprocWorkerPlugin(t *testing.T) { + require.NoError(t, idgen.Init(1)) + ctx := core.NewContext(context.Background()) + p := driver_inproc_worker.New( + driver_inproc_worker.WithConcurrency(2), + driver_inproc_worker.WithShutdownTimeout(time.Second), + ) + + assert.Equal(t, "driver_inproc_worker", p.Name()) + assert.Equal(t, core.DriverTypeWorker, p.Type()) + require.NoError(t, p.Apply(ctx)) + + var executedCount atomic.Int32 + ctx.Tasks().Register("test:task", func(ctx context.Context, payload []byte) error { + executedCount.Add(1) + return nil + }, extpoints.WithTaskTimeout(2*time.Second)) + + // Start worker driver + require.NoError(t, p.Start(context.Background())) + + // Enqueue tasks + taskID, err := driver_inproc_worker.DispatchTask(context.Background(), "test:task", []byte("hello"), "test") + require.NoError(t, err) + assert.NotEmpty(t, taskID) + + // Wait for execution + require.Eventually(t, func() bool { + return executedCount.Load() == 1 + }, 2*time.Second, 20*time.Millisecond) + + // Stop driver + require.NoError(t, p.Stop(context.Background())) +} diff --git a/backend/plugins/drivers/driver_inproc_worker/task_service.go b/backend/plugins/drivers/driver_inproc_worker/task_service.go new file mode 100644 index 00000000..0f3e7d83 --- /dev/null +++ b/backend/plugins/drivers/driver_inproc_worker/task_service.go @@ -0,0 +1,73 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package driver_inproc_worker + +import ( + "Wavelet/core/contracts" + "Wavelet/core/extpoints" + "context" + "errors" + "fmt" +) + +type inprocTaskService struct { + taskReg extpoints.TaskExtension +} + +func newInprocTaskService(taskReg extpoints.TaskExtension) contracts.TaskService { + return &inprocTaskService{ + taskReg: taskReg, + } +} + +func (s *inprocTaskService) Dispatch(ctx context.Context, taskType string, payload []byte, triggeredBy string) (string, error) { + return DispatchTask(ctx, taskType, payload, triggeredBy) +} + +func (s *inprocTaskService) Retry(_ context.Context, id uint64) (string, error) { + return fmt.Sprintf("inproc_retry_%d", id), nil +} + +func (s *inprocTaskService) ListTasks() []contracts.TaskMetaDTO { + if s.taskReg == nil { + return nil + } + tasks := s.taskReg.Tasks() + res := make([]contracts.TaskMetaDTO, 0, len(tasks)) + for _, td := range tasks { + res = append(res, td.ToDTO()) + } + return res +} + +func (s *inprocTaskService) GetTaskMeta(taskType string) (contracts.TaskMetaDTO, bool) { + if s.taskReg == nil { + return contracts.TaskMetaDTO{}, false + } + for _, td := range s.taskReg.Tasks() { + if td.Type == taskType || td.Pattern == taskType { + return td.ToDTO(), true + } + } + return contracts.TaskMetaDTO{}, false +} + +func (s *inprocTaskService) ValidatePayload(_ string, payload []byte) ([]byte, error) { + return payload, nil +} + +func (s *inprocTaskService) ReloadScheduler() error { + return nil +} + +func (s *inprocTaskService) AppendLog(_ context.Context, _ string, _ ...any) { +} + +func (s *inprocTaskService) ListExecutions(_ context.Context, _, _ string, _, _ int) ([]contracts.TaskExecutionDTO, int64, error) { + return []contracts.TaskExecutionDTO{}, 0, nil +} + +func (s *inprocTaskService) GetExecution(_ context.Context, _ uint64) (*contracts.TaskExecutionDTO, error) { + return nil, errors.New("driver_inproc_worker: task executions are not tracked") +} diff --git a/backend/plugins/drivers/drivers_test.go b/backend/plugins/drivers/drivers_test.go new file mode 100644 index 00000000..2fc891d2 --- /dev/null +++ b/backend/plugins/drivers/drivers_test.go @@ -0,0 +1,294 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package drivers_test + +import ( + "Wavelet/core" + "Wavelet/core/extpoints" + "Wavelet/plugins/drivers/driver_asynq_cron" + "Wavelet/plugins/drivers/driver_asynq_worker" + "Wavelet/plugins/drivers/driver_http" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sync/atomic" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/gin-gonic/gin" + "github.com/hibiken/asynq" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +func TestHTTPDriverLifecycle(t *testing.T) { + ctx := core.NewContext(context.Background()) + ctx.Config().SetSource(core.NewMapSource(nil)) + require.NoError(t, ctx.Config().Resolve()) + + var globalMiddlewareCalled atomic.Bool + var groupMiddlewareCalled atomic.Bool + + // Register global middleware + ctx.Router().Use(func(c *gin.Context) { + globalMiddlewareCalled.Store(true) + c.Next() + }) + + // Register standard gin handler + ctx.Router().GET("/ping", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"message": "pong"}) + }) + + // Register route group with middleware + v1 := ctx.Router().Group("/api/v1", func(c *gin.Context) { + groupMiddlewareCalled.Store(true) + c.Next() + }) + + v1.POST("/echo", func(c *gin.Context) { + var req map[string]any + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, req) + }) + + // Register standard http.HandlerFunc + v1.GET("/legacy", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("legacy response")) + })) + + // Create and apply HTTP driver with dynamic port + httpPlugin := driver_http.New(driver_http.WithAddr("127.0.0.1:0")) + require.Equal(t, "driver_http", httpPlugin.Name()) + + err := httpPlugin.Apply(ctx) + require.NoError(t, err) + + // Verify driver registered in context + d, ok := ctx.Driver(core.DriverTypeHTTP) + require.True(t, ok) + require.Equal(t, core.DriverTypeHTTP, d.Type()) + + // Start HTTP Server + err = d.Start(context.Background()) + require.NoError(t, err) + assert.True(t, httpPlugin.IsRunning()) + + addr := httpPlugin.Addr() + require.NotEmpty(t, addr) + + // Verify GET /ping + resp, err := http.Get(fmt.Sprintf("http://%s/ping", addr)) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Contains(t, string(body), "pong") + assert.True(t, globalMiddlewareCalled.Load()) + + // Verify POST /api/v1/echo + echoPayload := []byte(`{"title":"test-echo","value":42}`) + respEcho, err := http.Post(fmt.Sprintf("http://%s/api/v1/echo", addr), "application/json", bytes.NewReader(echoPayload)) + require.NoError(t, err) + defer respEcho.Body.Close() + + assert.Equal(t, http.StatusOK, respEcho.StatusCode) + var echoResult map[string]any + err = json.NewDecoder(respEcho.Body).Decode(&echoResult) + require.NoError(t, err) + assert.Equal(t, "test-echo", echoResult["title"]) + assert.Equal(t, float64(42), echoResult["value"]) + assert.True(t, groupMiddlewareCalled.Load()) + + // Verify GET /api/v1/legacy + respLegacy, err := http.Get(fmt.Sprintf("http://%s/api/v1/legacy", addr)) + require.NoError(t, err) + defer respLegacy.Body.Close() + + assert.Equal(t, http.StatusOK, respLegacy.StatusCode) + bodyLegacy, err := io.ReadAll(respLegacy.Body) + require.NoError(t, err) + assert.Equal(t, "legacy response", string(bodyLegacy)) + + // Stop HTTP Server + stopCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + err = d.Stop(stopCtx) + require.NoError(t, err) + assert.False(t, httpPlugin.IsRunning()) + + // Idempotent Stop + err = d.Stop(stopCtx) + require.NoError(t, err) +} + +func TestAsynqWorkerDriverLifecycle(t *testing.T) { + mr, err := miniredis.Run() + require.NoError(t, err) + defer mr.Close() + + ctx := core.NewContext(context.Background()) + + var emailTaskProcessed atomic.Bool + var simpleTaskProcessed atomic.Bool + + // Register tasks with various handler signatures + ctx.Tasks().Register("email:send", func(c context.Context, payload []byte) error { + var data map[string]string + if err := json.Unmarshal(payload, &data); err != nil { + return err + } + if data["to"] == "user@example.com" { + emailTaskProcessed.Store(true) + } + return nil + }, extpoints.WithTaskConcurrency(2)) + + ctx.Tasks().Register("maintenance:cleanup", func() error { + simpleTaskProcessed.Store(true) + return nil + }) + + workerPlugin := driver_asynq_worker.New( + driver_asynq_worker.WithRedisOpt(asynq.RedisClientOpt{Addr: mr.Addr()}), + driver_asynq_worker.WithConcurrency(2), + driver_asynq_worker.WithShutdownTimeout(1*time.Second), + ) + require.Equal(t, "driver_asynq_worker", workerPlugin.Name()) + + err = workerPlugin.Apply(ctx) + require.NoError(t, err) + + // Verify driver registered + d, ok := ctx.Driver(core.DriverTypeWorker) + require.True(t, ok) + require.Equal(t, core.DriverTypeWorker, d.Type()) + + // Start Worker Driver + err = d.Start(context.Background()) + require.NoError(t, err) + assert.True(t, workerPlugin.IsRunning()) + + // Enqueue tasks using asynq.Client + client := asynq.NewClient(asynq.RedisClientOpt{Addr: mr.Addr()}) + defer client.Close() + + emailPayload, _ := json.Marshal(map[string]string{"to": "user@example.com"}) + _, err = client.Enqueue(asynq.NewTask("email:send", emailPayload)) + require.NoError(t, err) + + _, err = client.Enqueue(asynq.NewTask("maintenance:cleanup", nil)) + require.NoError(t, err) + + // Wait for worker to consume tasks + require.Eventually(t, func() bool { + return emailTaskProcessed.Load() && simpleTaskProcessed.Load() + }, 3*time.Second, 50*time.Millisecond) + + // Stop Worker Driver + stopCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + err = d.Stop(stopCtx) + require.NoError(t, err) + assert.False(t, workerPlugin.IsRunning()) + + // Idempotent Stop + err = d.Stop(stopCtx) + require.NoError(t, err) +} + +func TestAsynqCronDriverLifecycle(t *testing.T) { + mr, err := miniredis.Run() + require.NoError(t, err) + defer mr.Close() + + ctx := core.NewContext(context.Background()) + + // Register cron schedules + ctx.Schedules().RegisterCron("@every 1s", "sync:stats", map[string]string{"type": "daily"}, + extpoints.WithScheduleOption("queue", "default"), + extpoints.WithScheduleOption("retry", 3), + ) + ctx.Schedules().RegisterCron("0 0 * * *", "report:generate", "raw_payload") + + cronPlugin := driver_asynq_cron.New( + driver_asynq_cron.WithRedisOpt(asynq.RedisClientOpt{Addr: mr.Addr()}), + driver_asynq_cron.WithLocation(time.UTC), + ) + require.Equal(t, "driver_asynq_cron", cronPlugin.Name()) + + err = cronPlugin.Apply(ctx) + require.NoError(t, err) + + // Verify driver registered + d, ok := ctx.Driver(core.DriverTypeScheduler) + require.True(t, ok) + require.Equal(t, core.DriverTypeScheduler, d.Type()) + + // Start Cron Scheduler Driver + err = d.Start(context.Background()) + require.NoError(t, err) + assert.True(t, cronPlugin.IsRunning()) + + // Stop Cron Scheduler Driver + stopCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + err = d.Stop(stopCtx) + require.NoError(t, err) + assert.False(t, cronPlugin.IsRunning()) + + // Idempotent Stop + err = d.Stop(stopCtx) + require.NoError(t, err) +} + +func TestMultipleDriversInContext(t *testing.T) { + mr, err := miniredis.Run() + require.NoError(t, err) + defer mr.Close() + + ctx := core.NewContext(context.Background()) + ctx.Config().SetSource(core.NewMapSource(nil)) + require.NoError(t, ctx.Config().Resolve()) + + httpPlugin := driver_http.New(driver_http.WithAddr("127.0.0.1:0")) + workerPlugin := driver_asynq_worker.New(driver_asynq_worker.WithRedisOpt(asynq.RedisClientOpt{Addr: mr.Addr()})) + cronPlugin := driver_asynq_cron.New(driver_asynq_cron.WithRedisOpt(asynq.RedisClientOpt{Addr: mr.Addr()})) + + require.NoError(t, httpPlugin.Apply(ctx)) + require.NoError(t, workerPlugin.Apply(ctx)) + require.NoError(t, cronPlugin.Apply(ctx)) + + drivers := ctx.Drivers() + assert.Len(t, drivers, 3) + + _, ok := ctx.Driver(core.DriverTypeHTTP) + assert.True(t, ok) + + _, ok = ctx.Driver(core.DriverTypeWorker) + assert.True(t, ok) + + _, ok = ctx.Driver(core.DriverTypeScheduler) + assert.True(t, ok) + + _, ok = ctx.Driver("non_existent") + assert.False(t, ok) +} diff --git a/backend/plugins/infra/cache/config.go b/backend/plugins/infra/cache/config.go new file mode 100644 index 00000000..ba0d3dd1 --- /dev/null +++ b/backend/plugins/infra/cache/config.go @@ -0,0 +1,25 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package cache + +// RedisConfig declares the configuration read by the Redis cache provider. +type RedisConfig struct { + Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"` + Addrs []string `config:"addrs" env:"REDIS_ADDR"` + Username string `config:"username" env:"REDIS_USERNAME"` + Password string `config:"password" env:"REDIS_PASSWORD" secret:"true"` + DB int `config:"db" env:"REDIS_DB"` + ClusterMode bool `config:"cluster_mode" env:"REDIS_CLUSTER_MODE"` + MasterName string `config:"master_name" env:"REDIS_MASTER_NAME"` + KeyPrefix string `config:"key_prefix" env:"REDIS_KEY_PREFIX"` + PoolSize int `config:"pool_size" env:"REDIS_POOL_SIZE"` + MinIdleConn int `config:"min_idle_conn" env:"REDIS_MIN_IDLE_CONN"` + DialTimeout int `config:"dial_timeout" env:"REDIS_DIAL_TIMEOUT"` + ReadTimeout int `config:"read_timeout" env:"REDIS_READ_TIMEOUT"` + WriteTimeout int `config:"write_timeout" env:"REDIS_WRITE_TIMEOUT"` + MaxRetries int `config:"max_retries" env:"REDIS_MAX_RETRIES"` + PoolTimeout int `config:"pool_timeout" env:"REDIS_POOL_TIMEOUT"` + ConnMaxIdleTime int `config:"conn_max_idle_time" env:"REDIS_CONN_MAX_IDLE_TIME"` + MaintNotifications bool `config:"maint_notifications" env:"REDIS_MAINT_NOTIFICATIONS" default:"false"` +} diff --git a/backend/plugins/infra/cache/errs.go b/backend/plugins/infra/cache/errs.go new file mode 100644 index 00000000..f653d6fe --- /dev/null +++ b/backend/plugins/infra/cache/errs.go @@ -0,0 +1,12 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package cache + +const ( + errRedisHashSetFailed = "failed to set redis hash: %w" + errRedisHashDeleteFailed = "failed to delete redis hash field: %w" + errUnmarshalDataFailed = "failed to unmarshal data: %w" + errMarshalDataFailed = "failed to marshal data: %w" + errRedisKeySetFailed = "failed to set redis key: %w" +) diff --git a/backend/plugins/infra/cache/plugin.go b/backend/plugins/infra/cache/plugin.go new file mode 100644 index 00000000..0155da2d --- /dev/null +++ b/backend/plugins/infra/cache/plugin.go @@ -0,0 +1,298 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package cache provides the multi-tier caching infrastructure plugin for Cordis. +package cache + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "Wavelet/pkg/cache/ram" + "Wavelet/pkg/util" + "context" + "encoding/json" + "errors" + "sync" + "time" + + "github.com/redis/go-redis/v9" +) + +const ( + defaultRAMCapacity = 10000 + defaultPubSubChannel = "wavelet:cache:invalidation" +) + +type ramEntry struct { + data []byte + expireAt time.Time +} + +// Option configures the cache plugin. +type Option func(*Plugin) + +// WithRedis sets an explicit Redis client instance. +func WithRedis(client redis.UniversalClient) Option { + return func(p *Plugin) { + p.redisClient = client + } +} + +// WithKeyPrefix sets a custom Redis key prefix. +func WithKeyPrefix(prefix string) Option { + return func(p *Plugin) { + p.keyPrefix = prefix + } +} + +// WithRAMCapacity sets the maximum capacity for the L1 RAM cache. +func WithRAMCapacity(capacity int) Option { + return func(p *Plugin) { + p.ramCapacity = capacity + } +} + +// Plugin implements core.Plugin to provide contracts.CacheService. +type Plugin struct { + redisClient redis.UniversalClient + keyPrefix string + ramCapacity int +} + +// New creates a new cache infrastructure plugin. +func New(opts ...Option) *Plugin { + p := &Plugin{ + ramCapacity: defaultRAMCapacity, + } + for _, opt := range opts { + if opt != nil { + opt(p) + } + } + return p +} + +// Name returns the unique identifier of the cache plugin. +func (p *Plugin) Name() string { + return "cache" +} + +// DeclareConfig declares the configuration bindings consumed by this plugin. +func (p *Plugin) DeclareConfig() []core.ConfigBinding { + return []core.ConfigBinding{ + {Prefix: "redis", Target: &RedisConfig{}}, + } +} + +// ConfigEnabled gates plugin activation based on whether Redis is enabled. +func (p *Plugin) ConfigEnabled(view core.ConfigView) bool { + return view.Bool("redis.enabled", false) +} + +// Apply mounts the multi-layer cache service into the Context. +func (p *Plugin) Apply(ctx *core.Context) error { + var cfg RedisConfig + if err := ctx.Config().Bind("redis", &cfg); err != nil { + return err + } + + redisClient := p.redisClient + if redisClient == nil { + if Redis == nil { + var err error + redisClient, err = InitRedisWithConfig(cfg) + if err != nil { + return err + } + } else { + redisClient = Redis + } + } + + ramCache, err := ram.New[string, ramEntry](ram.Options{ + MaximumSize: p.ramCapacity, + }) + if err != nil { + return err + } + + svc := &cacheServiceImpl{ + ramCache: ramCache, + redisClient: redisClient, + keyPrefix: p.keyPrefix, + pubSubChannel: defaultPubSubChannel, + stopCh: make(chan struct{}), + } + + if redisClient != nil { + svc.startPubSubListener() + ctx.OnDispose(func() error { + svc.stopPubSubListener() + if p.redisClient == nil { + Redis = nil + if closeErr := redisClient.Close(); closeErr != nil && !errors.Is(closeErr, redis.ErrClosed) { + return closeErr + } + } + return nil + }) + } + + core.Provide[contracts.CacheService](ctx, svc) + return nil +} + +type cacheServiceImpl struct { + ramCache *ram.Cache[string, ramEntry] + redisClient redis.UniversalClient + keyPrefix string + pubSubChannel string + + subOnce sync.Once + stopOnce sync.Once + stopCh chan struct{} + pubsub *redis.PubSub +} + +func (s *cacheServiceImpl) prefixedKey(key string) string { + if s.keyPrefix != "" { + return s.keyPrefix + key + } + return PrefixedKey(key) +} + +func (s *cacheServiceImpl) startPubSubListener() { + if s.redisClient == nil { + return + } + + s.subOnce.Do(func() { + pubsub := s.redisClient.Subscribe(context.Background(), s.pubSubChannel) + s.pubsub = pubsub + + util.Go(func() { + ch := pubsub.Channel() + for { + select { + case <-s.stopCh: + return + case msg, ok := <-ch: + if !ok { + return + } + if msg != nil && msg.Payload != "" { + s.ramCache.Invalidate(msg.Payload) + } + } + } + }) + }) +} + +func (s *cacheServiceImpl) stopPubSubListener() { + s.stopOnce.Do(func() { + close(s.stopCh) + if s.pubsub != nil { + _ = s.pubsub.Close() + } + }) +} + +func (s *cacheServiceImpl) Get(ctx context.Context, key string, target any) error { + // 1. Check L1 RAM cache + if entry, ok := s.ramCache.GetIfPresent(key); ok { + if entry.expireAt.IsZero() || time.Now().Before(entry.expireAt) { + return json.Unmarshal(entry.data, target) + } + // Expired in L1 RAM + s.ramCache.Invalidate(key) + } + + // 2. Check L2 Redis cache + if s.redisClient != nil { + data, err := s.redisClient.Get(ctx, s.prefixedKey(key)).Bytes() + if err == nil { + // Backfill L1 RAM cache + s.ramCache.Set(key, ramEntry{ + data: data, + }) + return json.Unmarshal(data, target) + } else if !errors.Is(err, redis.Nil) { + return err + } + } + + return contracts.ErrCacheMiss +} + +func (s *cacheServiceImpl) Set(ctx context.Context, key string, value any, ttl time.Duration) error { + data, err := json.Marshal(value) + if err != nil { + return err + } + + var expireAt time.Time + if ttl > 0 { + expireAt = time.Now().Add(ttl) + } + + // 1. Write L1 RAM cache + s.ramCache.Set(key, ramEntry{ + data: data, + expireAt: expireAt, + }) + + // 2. Write L2 Redis cache + if s.redisClient != nil { + if err := s.redisClient.Set(ctx, s.prefixedKey(key), data, ttl).Err(); err != nil { + return err + } + } + + return nil +} + +func (s *cacheServiceImpl) Delete(ctx context.Context, key string) error { + // 1. Evict L1 RAM + s.ramCache.Invalidate(key) + + // 2. Evict L2 Redis and broadcast invalidation to cluster nodes + if s.redisClient != nil { + if err := s.redisClient.Del(ctx, s.prefixedKey(key)).Err(); err != nil && !errors.Is(err, redis.Nil) { + return err + } + _ = s.redisClient.Publish(ctx, s.pubSubChannel, key).Err() + } + + return nil +} + +func (s *cacheServiceImpl) Invalidate(ctx context.Context, key string) error { + return s.Delete(ctx, key) +} + +func (s *cacheServiceImpl) GetOrSet(ctx context.Context, key string, target any, ttl time.Duration, loader func() (any, error)) error { + err := s.Get(ctx, key, target) + if err == nil { + return nil + } + if !errors.Is(err, contracts.ErrCacheMiss) { + return err + } + + val, err := loader() + if err != nil { + return err + } + + if err := s.Set(ctx, key, val, ttl); err != nil { + return err + } + + data, err := json.Marshal(val) + if err != nil { + return err + } + + return json.Unmarshal(data, target) +} diff --git a/backend/plugins/infra/cache/plugin_test.go b/backend/plugins/infra/cache/plugin_test.go new file mode 100644 index 00000000..21d38698 --- /dev/null +++ b/backend/plugins/infra/cache/plugin_test.go @@ -0,0 +1,83 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package cache_test + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "Wavelet/plugins/infra/cache" + "context" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCachePluginOperations(t *testing.T) { + mr, err := miniredis.Run() + require.NoError(t, err) + defer mr.Close() + + rdb := redis.NewClient(&redis.Options{ + Addr: mr.Addr(), + }) + defer func() { _ = rdb.Close() }() + + p := cache.New( + cache.WithRedis(rdb), + cache.WithKeyPrefix("app:"), + cache.WithRAMCapacity(500), + ) + ctx := core.NewContext(context.Background()) + ctx.Config().SetSource(core.NewMapSource(map[string]any{ + "redis.enabled": true, + })) + require.NoError(t, ctx.Config().Resolve()) + require.NoError(t, p.Apply(ctx)) + + svc, err := core.Inject[contracts.CacheService](ctx) + require.NoError(t, err) + + type Data struct { + Value string `json:"value"` + } + + testCtx := context.Background() + + // 1. ErrCacheMiss + var out Data + err = svc.Get(testCtx, "missing", &out) + assert.ErrorIs(t, err, contracts.ErrCacheMiss) + + // 2. Set & Get + in := Data{Value: "hello"} + require.NoError(t, svc.Set(testCtx, "key1", in, 5*time.Minute)) + + require.NoError(t, svc.Get(testCtx, "key1", &out)) + assert.Equal(t, "hello", out.Value) + + // 3. GetOrSet + var target Data + err = svc.GetOrSet(testCtx, "key1", &target, time.Minute, func() (any, error) { + return Data{Value: "from_loader"}, nil + }) + require.NoError(t, err) + assert.Equal(t, "hello", target.Value) + + err = svc.GetOrSet(testCtx, "key2", &target, time.Minute, func() (any, error) { + return Data{Value: "from_loader"}, nil + }) + require.NoError(t, err) + assert.Equal(t, "from_loader", target.Value) + + // 4. Invalidate / Delete + require.NoError(t, svc.Invalidate(testCtx, "key1")) + err = svc.Get(testCtx, "key1", &out) + assert.ErrorIs(t, err, contracts.ErrCacheMiss) + + require.NoError(t, ctx.Dispose()) +} diff --git a/backend/plugins/infra/cache/redis.go b/backend/plugins/infra/cache/redis.go new file mode 100644 index 00000000..f9ab5779 --- /dev/null +++ b/backend/plugins/infra/cache/redis.go @@ -0,0 +1,223 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package cache + +import ( + "context" + "encoding/json" + "fmt" + "log" + "strings" + "sync" + "time" + + "github.com/redis/go-redis/extra/redisotel/v9" + "github.com/redis/go-redis/v9" + "github.com/redis/go-redis/v9/maintnotifications" + "go.opentelemetry.io/otel/attribute" +) + +var ( + // Redis 全局 Redis 客户端实例 + Redis redis.UniversalClient + + keyPrefixMu sync.RWMutex + keyPrefix string +) + +// SetKeyPrefix sets the package-level key prefix. +func SetKeyPrefix(prefix string) { + keyPrefixMu.Lock() + defer keyPrefixMu.Unlock() + keyPrefix = prefix +} + +// GetKeyPrefix returns the package-level key prefix. +func GetKeyPrefix() string { + keyPrefixMu.RLock() + defer keyPrefixMu.RUnlock() + return keyPrefix +} + +// InitRedisWithConfig initializes the Redis client using the provided RedisConfig. +func InitRedisWithConfig(cfg RedisConfig) (redis.UniversalClient, error) { + if !cfg.Enabled { + log.Println("[Redis] is disabled, skipping Redis initialization") + return nil, nil + } + + SetKeyPrefix(cfg.KeyPrefix) + + var client redis.UniversalClient + + if cfg.ClusterMode { + // Cluster 模式 + client = redis.NewClusterClient(&redis.ClusterOptions{ + Addrs: cfg.Addrs, + Username: cfg.Username, + Password: cfg.Password, + PoolSize: cfg.PoolSize, + MinIdleConns: cfg.MinIdleConn, + DialTimeout: time.Duration(cfg.DialTimeout) * time.Second, + ReadTimeout: time.Duration(cfg.ReadTimeout) * time.Second, + WriteTimeout: time.Duration(cfg.WriteTimeout) * time.Second, + MaxRetries: cfg.MaxRetries, + PoolTimeout: time.Duration(cfg.PoolTimeout) * time.Second, + ConnMaxIdleTime: time.Duration(cfg.ConnMaxIdleTime) * time.Second, + MaintNotificationsConfig: redisMaintNotificationsConfig(cfg.MaintNotifications), + }) + log.Println("[Redis] initialized in Cluster mode") + } else { + // Standalone 或 Sentinel 模式 + options := &redis.UniversalOptions{ + Addrs: cfg.Addrs, + MasterName: cfg.MasterName, // 非空时启用 Sentinel + Username: cfg.Username, + Password: cfg.Password, + DB: cfg.DB, + PoolSize: cfg.PoolSize, + MinIdleConns: cfg.MinIdleConn, + DialTimeout: time.Duration(cfg.DialTimeout) * time.Second, + ReadTimeout: time.Duration(cfg.ReadTimeout) * time.Second, + WriteTimeout: time.Duration(cfg.WriteTimeout) * time.Second, + MaxRetries: cfg.MaxRetries, + PoolTimeout: time.Duration(cfg.PoolTimeout) * time.Second, + ConnMaxIdleTime: time.Duration(cfg.ConnMaxIdleTime) * time.Second, + MaintNotificationsConfig: redisMaintNotificationsConfig(cfg.MaintNotifications), + } + if cfg.MasterName != "" { + failoverClient := redis.NewFailoverClient(options.Failover()) + failoverClient.Options().MaintNotificationsConfig = redisMaintNotificationsConfig(cfg.MaintNotifications) + client = failoverClient + log.Println("[Redis] initialized in Sentinel mode") + } else { + client = redis.NewUniversalClient(options) + log.Println("[Redis] initialized in Standalone mode") + } + } + + // OpenTelemetry 追踪(UniversalClient 兼容) + if err := redisotel.InstrumentTracing( + client, + redisotel.WithAttributes( + attribute.String("db.instance", fmt.Sprintf("%v", cfg.DB)), + attribute.String("db.ip", strings.Join(cfg.Addrs, ",")), + attribute.String("db.system", "Redis"), + ), + ); err != nil { + return nil, fmt.Errorf("redis: init trace: %w", err) + } + + // 测试连接 + if err := client.Ping(context.Background()).Err(); err != nil { + return nil, fmt.Errorf("redis: ping: %w", err) + } + + Redis = client + return client, nil +} + +// SetRedisClient 设置包级 Redis 客户端(主要用于测试) +func SetRedisClient(client redis.UniversalClient) { + Redis = client +} + +func redisMaintNotificationsConfig(enabled bool) *maintnotifications.Config { + mode := maintnotifications.ModeDisabled + if enabled { + mode = maintnotifications.ModeAuto + } + return &maintnotifications.Config{Mode: mode} +} + +// PrefixedKey 返回带前缀的 Key +func PrefixedKey(key string) string { + prefix := GetKeyPrefix() + if prefix == "" { + return key + } + return prefix + key +} + +// HSetJSON 将泛型数据序列化为 JSON 并设置到 Redis Hash +// ctx: 上下文 +// hashKey: Redis Hash key +// fieldKey: Hash field key +// data: 要存储的数据(泛型) +func HSetJSON[T any](ctx context.Context, hashKey, fieldKey string, data T) error { + jsonData, err := json.Marshal(data) + if err != nil { + return err + } + + if err := Redis.HSet(ctx, PrefixedKey(hashKey), fieldKey, jsonData).Err(); err != nil { + return fmt.Errorf(errRedisHashSetFailed, err) + } + + return nil +} + +// HDel removes one or more fields from a Redis Hash. +func HDel(ctx context.Context, hashKey string, fieldKeys ...string) error { + if Redis == nil || len(fieldKeys) == 0 { + return nil + } + if err := Redis.HDel(ctx, PrefixedKey(hashKey), fieldKeys...).Err(); err != nil { + return fmt.Errorf(errRedisHashDeleteFailed, err) + } + return nil +} + +// HGetJSON 从 Redis Hash 获取数据并反序列化为泛型类型 +// ctx: 上下文 +// hashKey: Redis Hash key +// fieldKey: Hash field key +// data: 用于接收数据的指针(泛型) +func HGetJSON[T any](ctx context.Context, hashKey, fieldKey string, data *T) error { + val, err := Redis.HGet(ctx, PrefixedKey(hashKey), fieldKey).Result() + if err != nil { + return err + } + + if err := json.Unmarshal([]byte(val), data); err != nil { + return fmt.Errorf(errUnmarshalDataFailed, err) + } + + return nil +} + +// GetJSON 从Redis获取数据并反序列化为泛型类型 +// ctx: 上下文 +// key: Redis key +// data: 用于接收数据的指针(泛型) +func GetJSON[T any](ctx context.Context, key string, data *T) error { + val, err := Redis.Get(ctx, PrefixedKey(key)).Bytes() + if err != nil { + return err + } + + if err := json.Unmarshal(val, data); err != nil { + return fmt.Errorf(errUnmarshalDataFailed, err) + } + + return nil +} + +// SetJSON 将泛型数据序列化为JSON并设置到Redis +// ctx: 上下文 +// key: Redis key +// data: 要存储的数据(泛型) +// expiration: 过期时间 +func SetJSON[T any](ctx context.Context, key string, data T, expiration time.Duration) error { + jsonData, err := json.Marshal(data) + if err != nil { + return fmt.Errorf(errMarshalDataFailed, err) + } + + if err := Redis.Set(ctx, PrefixedKey(key), jsonData, expiration).Err(); err != nil { + return fmt.Errorf(errRedisKeySetFailed, err) + } + + return nil +} diff --git a/backend/plugins/infra/cache/redis_test.go b/backend/plugins/infra/cache/redis_test.go new file mode 100644 index 00000000..0b91041e --- /dev/null +++ b/backend/plugins/infra/cache/redis_test.go @@ -0,0 +1,28 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package cache + +import ( + "testing" + + "github.com/redis/go-redis/v9/maintnotifications" +) + +func TestRedisMaintNotificationsConfig(t *testing.T) { + 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) { + cfg := redisMaintNotificationsConfig(test.enabled) + if cfg.Mode != test.want { + t.Fatalf("maintenance notifications mode = %v, want %v", cfg.Mode, test.want) + } + }) + } +} diff --git a/backend/plugins/infra/cache_memory/cache.go b/backend/plugins/infra/cache_memory/cache.go new file mode 100644 index 00000000..8ef9a499 --- /dev/null +++ b/backend/plugins/infra/cache_memory/cache.go @@ -0,0 +1,100 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package cache_memory + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "Wavelet/pkg/cache/ram" + "context" + "encoding/json" + "time" +) + +type ramEntry struct { + data []byte + expireAt time.Time +} + +type memoryCacheService struct { + ramCache *ram.Cache[string, ramEntry] + events *core.EventBus +} + +func newMemoryCacheService(capacity int, events *core.EventBus) (*memoryCacheService, error) { + ramCache, err := ram.New[string, ramEntry](ram.Options{ + MaximumSize: capacity, + }) + if err != nil { + return nil, err + } + + return &memoryCacheService{ + ramCache: ramCache, + events: events, + }, nil +} + +func (s *memoryCacheService) Get(_ context.Context, key string, target any) error { + if entry, ok := s.ramCache.GetIfPresent(key); ok { + if entry.expireAt.IsZero() || time.Now().Before(entry.expireAt) { + return json.Unmarshal(entry.data, target) + } + // Expired + s.ramCache.Invalidate(key) + } + return contracts.ErrCacheMiss +} + +func (s *memoryCacheService) Set(_ context.Context, key string, value any, ttl time.Duration) error { + data, err := json.Marshal(value) + if err != nil { + return err + } + + var expireAt time.Time + if ttl > 0 { + expireAt = time.Now().Add(ttl) + } + + s.ramCache.Set(key, ramEntry{ + data: data, + expireAt: expireAt, + }) + return nil +} + +func (s *memoryCacheService) Delete(ctx context.Context, key string) error { + s.ramCache.Invalidate(key) + if s.events != nil { + _ = s.events.Emit(ctx, "cache:invalidate", key) + } + return nil +} + +func (s *memoryCacheService) Invalidate(ctx context.Context, key string) error { + return s.Delete(ctx, key) +} + +func (s *memoryCacheService) GetOrSet(ctx context.Context, key string, target any, ttl time.Duration, loader func() (any, error)) error { + err := s.Get(ctx, key, target) + if err == nil { + return nil + } + + val, err := loader() + if err != nil { + return err + } + + if setErr := s.Set(ctx, key, val, ttl); setErr != nil { + return setErr + } + + data, err := json.Marshal(val) + if err != nil { + return err + } + return json.Unmarshal(data, target) +} diff --git a/backend/plugins/infra/cache_memory/plugin.go b/backend/plugins/infra/cache_memory/plugin.go new file mode 100644 index 00000000..74c815ef --- /dev/null +++ b/backend/plugins/infra/cache_memory/plugin.go @@ -0,0 +1,83 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package cache_memory provides the in-memory cache infrastructure plugin for Cordis. +package cache_memory + +import ( + "Wavelet/core" + "Wavelet/core/contracts" +) + +const defaultRAMCapacity = 10000 + +// Option configures the cache_memory plugin. +type Option func(*Plugin) + +// WithCapacity sets the maximum capacity for the in-memory cache. +func WithCapacity(capacity int) Option { + return func(p *Plugin) { + p.capacity = capacity + } +} + +// Plugin implements core.Plugin to provide contracts.CacheService using in-memory storage. +type Plugin struct { + capacity int +} + +// New creates a new in-memory cache infrastructure plugin. +func New(opts ...Option) *Plugin { + p := &Plugin{ + capacity: defaultRAMCapacity, + } + for _, opt := range opts { + if opt != nil { + opt(p) + } + } + return p +} + +// Name returns the unique identifier of the cache_memory plugin. +func (p *Plugin) Name() string { + return "cache_memory" +} + +// Manifest returns the plugin metadata. +func (p *Plugin) Manifest() core.Manifest { + return core.Manifest{ + Name: "cache_memory", + Version: "1.0.0", + Description: "Zero-dependency pure in-memory cache infrastructure plugin", + Author: "Wavelet Team", + } +} + +// redisGateConfig declares the Redis gate configuration for cache_memory. +type redisGateConfig struct { + Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"` +} + +// DeclareConfig declares the configuration bindings consumed by this plugin. +func (p *Plugin) DeclareConfig() []core.ConfigBinding { + return []core.ConfigBinding{ + {Prefix: "redis", Target: &redisGateConfig{}}, + } +} + +// ConfigEnabled gates plugin activation when Redis is disabled. +func (p *Plugin) ConfigEnabled(view core.ConfigView) bool { + return !view.Bool("redis.enabled", false) +} + +// Apply mounts the in-memory cache service into the Context. +func (p *Plugin) Apply(ctx *core.Context) error { + svc, err := newMemoryCacheService(p.capacity, ctx.Events()) + if err != nil { + return err + } + + core.Provide[contracts.CacheService](ctx, svc) + return nil +} diff --git a/backend/plugins/infra/cache_memory/plugin_test.go b/backend/plugins/infra/cache_memory/plugin_test.go new file mode 100644 index 00000000..66c5fdba --- /dev/null +++ b/backend/plugins/infra/cache_memory/plugin_test.go @@ -0,0 +1,81 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package cache_memory_test + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "Wavelet/plugins/infra/cache_memory" + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCacheMemoryPlugin(t *testing.T) { + ctx := core.NewContext(context.Background()) + p := cache_memory.New(cache_memory.WithCapacity(100)) + + assert.Equal(t, "cache_memory", p.Name()) + assert.Equal(t, "1.0.0", p.Manifest().Version) + require.NoError(t, p.Apply(ctx)) + + cacheSvc, err := core.Inject[contracts.CacheService](ctx) + require.NoError(t, err) + require.NotNil(t, cacheSvc) + + reqCtx := context.Background() + + // 1. Get non-existent + var target string + err = cacheSvc.Get(reqCtx, "non_existent", &target) + assert.ErrorIs(t, err, contracts.ErrCacheMiss) + + // 2. Set and Get + err = cacheSvc.Set(reqCtx, "user:100", "Alice", 10*time.Minute) + require.NoError(t, err) + + var name string + err = cacheSvc.Get(reqCtx, "user:100", &name) + require.NoError(t, err) + assert.Equal(t, "Alice", name) + + // 3. GetOrSet + var count int + var loaded int + loader := func() (any, error) { + loaded++ + return 42, nil + } + err = cacheSvc.GetOrSet(reqCtx, "counter", &count, 10*time.Minute, loader) + require.NoError(t, err) + assert.Equal(t, 42, count) + assert.Equal(t, 1, loaded) + + // Second GetOrSet hits cache + var count2 int + err = cacheSvc.GetOrSet(reqCtx, "counter", &count2, 10*time.Minute, loader) + require.NoError(t, err) + assert.Equal(t, 42, count2) + assert.Equal(t, 1, loaded) + + // 4. Delete & Invalidate + require.NoError(t, cacheSvc.Delete(reqCtx, "user:100")) + err = cacheSvc.Get(reqCtx, "user:100", &name) + assert.ErrorIs(t, err, contracts.ErrCacheMiss) + + require.NoError(t, cacheSvc.Invalidate(reqCtx, "counter")) + err = cacheSvc.Get(reqCtx, "counter", &count) + assert.ErrorIs(t, err, contracts.ErrCacheMiss) + + // 5. Expiration + err = cacheSvc.Set(reqCtx, "temp_key", "val", 50*time.Millisecond) + require.NoError(t, err) + time.Sleep(70 * time.Millisecond) + var tempVal string + err = cacheSvc.Get(reqCtx, "temp_key", &tempVal) + assert.ErrorIs(t, err, contracts.ErrCacheMiss) +} diff --git a/backend/plugins/infra/config/source.go b/backend/plugins/infra/config/source.go new file mode 100644 index 00000000..fd6c711f --- /dev/null +++ b/backend/plugins/infra/config/source.go @@ -0,0 +1,126 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package config adapts viper to the kernel configuration source contract. It is a +// runtime adapter rather than a core.Plugin: it owns no routes, services or tasks and +// therefore never appears in app.Use. Keeping viper here preserves the micro-kernel +// rule against importing concrete runtime dependencies. +package config + +import ( + "errors" + "fmt" + "io/fs" + "os" + + "github.com/spf13/viper" +) + +// DefaultFileName is the configuration file looked up when CONFIG_PATH is unset. +const DefaultFileName = "config.yaml" + +// EnvOnlyOrigin is reported by Describe when no configuration file was loaded. +const EnvOnlyOrigin = "" + +// maxSearchDepth bounds the upward directory walk so a misconfigured working directory +// cannot make the loader scan the whole filesystem. +const maxSearchDepth = 5 + +// Option configures a Source. +type Option func(*Source) + +// WithPath pins the configuration file, bypassing CONFIG_PATH and the upward search. +func WithPath(path string) Option { + return func(s *Source) { + s.path = path + } +} + +// Source implements core.ConfigSource over a configuration file plus the process environment. +type Source struct { + v *viper.Viper + path string + found bool +} + +// NewSource loads the configuration file. A missing file is not an error: the source +// then serves environment values only, matching the behaviour the previous pkg/config +// loader had for deployments that configure everything through the environment. +func NewSource(opts ...Option) (*Source, error) { + s := &Source{} + for _, opt := range opts { + opt(s) + } + + if s.path == "" { + s.path = os.Getenv("CONFIG_PATH") + } + if s.path == "" { + s.path = findConfigPath(DefaultFileName) + } + + v := viper.New() + v.SetConfigFile(s.path) + + err := v.ReadInConfig() + switch { + case err == nil: + s.found = true + case isNotFound(err): + // No file: fall through to environment-only lookups. + default: + if _, statErr := os.Stat(s.path); statErr == nil { //nolint:gosec // s.path comes from CONFIG_PATH or a bounded upward search + return nil, fmt.Errorf("infra/config: read %s: %w", s.path, err) + } + } + + s.v = v + return s, nil +} + +// isNotFound reports whether the loader failed only because the file is absent. +func isNotFound(err error) bool { + var notFound viper.ConfigFileNotFoundError + return errors.As(err, ¬Found) || errors.Is(err, fs.ErrNotExist) +} + +// Lookup returns the raw value stored at a dotted path, or false when the file was not +// loaded or the path is absent. Declared defaults therefore stay distinguishable from +// values explicitly set to a zero. +func (s *Source) Lookup(path string) (any, bool) { + if !s.found || !s.v.IsSet(path) { + return nil, false + } + return s.v.Get(path), true +} + +// LookupEnv reads a process environment variable. +func (s *Source) LookupEnv(name string) (string, bool) { + return os.LookupEnv(name) +} + +// Describe returns the loaded file path, or EnvOnlyOrigin when running on environment values. +func (s *Source) Describe() string { + if !s.found { + return EnvOnlyOrigin + } + return s.path +} + +// findConfigPath searches upward from the working directory so tests and binaries run +// from backend/ still find the repository-root configuration file. +func findConfigPath(configPath string) string { + if _, err := os.Stat(configPath); err == nil { + return configPath + } + + dir := "." + for range maxSearchDepth { + dir += "/.." + path := dir + "/" + configPath + if _, err := os.Stat(path); err == nil { + return path + } + } + return configPath +} diff --git a/backend/plugins/infra/config/source_test.go b/backend/plugins/infra/config/source_test.go new file mode 100644 index 00000000..8372e156 --- /dev/null +++ b/backend/plugins/infra/config/source_test.go @@ -0,0 +1,93 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package config_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "Wavelet/plugins/infra/config" +) + +const sampleYAML = "" + + "app:\n addr: \":8000\"\n node_id: 1\n" + + "database:\n enabled: false\n port: 5432\n slow_threshold: 200ms\n" + + "redis:\n addrs:\n - \"127.0.0.1:6379\"\n" + +func writeConfig(t *testing.T, body string) string { + t.Helper() + + path := filepath.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(path, []byte(body), 0o600)) + return path +} + +func TestSourceLooksUpNestedPaths(t *testing.T) { + src, err := config.NewSource(config.WithPath(writeConfig(t, sampleYAML))) + require.NoError(t, err) + + value, ok := src.Lookup("database.port") + require.True(t, ok) + assert.Equal(t, 5432, value) + + _, ok = src.Lookup("database.missing") + assert.False(t, ok) +} + +func TestSourceKeepsZeroValuedKeysDistinctFromMissing(t *testing.T) { + src, err := config.NewSource(config.WithPath(writeConfig(t, sampleYAML))) + require.NoError(t, err) + + value, ok := src.Lookup("database.enabled") + require.True(t, ok, "an explicitly set false must not look like a missing key") + assert.Equal(t, false, value) +} + +func TestSourceTreatsUnsetFileAsEnvOnly(t *testing.T) { + missing := filepath.Join(t.TempDir(), "absent.yaml") + + src, err := config.NewSource(config.WithPath(missing)) + require.NoError(t, err, "a missing configuration file must fall back to environment values") + + _, ok := src.Lookup("app.addr") + assert.False(t, ok) + assert.Equal(t, config.EnvOnlyOrigin, src.Describe()) +} + +func TestSourceRejectsMalformedFile(t *testing.T) { + src, err := config.NewSource(config.WithPath(writeConfig(t, "app: [unclosed\n"))) + + assert.Nil(t, src) + require.Error(t, err) + assert.Contains(t, err.Error(), "infra/config") +} + +func TestSourceLookupEnvReadsProcessEnvironment(t *testing.T) { + t.Setenv("WAVELET_SOURCE_PROBE", "present") + + src, err := config.NewSource(config.WithPath(writeConfig(t, sampleYAML))) + require.NoError(t, err) + + value, ok := src.LookupEnv("WAVELET_SOURCE_PROBE") + require.True(t, ok) + assert.Equal(t, "present", value) + + _, ok = src.LookupEnv("WAVELET_SOURCE_ABSENT") + assert.False(t, ok) +} + +func TestSourcePrefersConfigPathEnvironmentVariable(t *testing.T) { + t.Setenv("CONFIG_PATH", writeConfig(t, "app:\n addr: \":9100\"\n")) + + src, err := config.NewSource() + require.NoError(t, err) + + value, ok := src.Lookup("app.addr") + require.True(t, ok) + assert.Equal(t, ":9100", value) +} diff --git a/backend/plugins/infra/database/clickhouse.go b/backend/plugins/infra/database/clickhouse.go new file mode 100644 index 00000000..1e38f176 --- /dev/null +++ b/backend/plugins/infra/database/clickhouse.go @@ -0,0 +1,150 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package database 提供数据库连接与基础设施 +package database + +import ( + "context" + "fmt" + "log" + "net/url" + "strconv" + "strings" + "time" + + "github.com/ClickHouse/clickhouse-go/v2" + "github.com/ClickHouse/clickhouse-go/v2/lib/driver" + "go.opentelemetry.io/otel/attribute" + clickhouseDriver "gorm.io/driver/clickhouse" + "gorm.io/gorm" + "gorm.io/plugin/opentelemetry/tracing" +) + +const ( + clickhouseMaxExecTime = 60 // ClickHouse 最大执行时间(秒) + clickhouseReadTimeoutFactor = 2 // ReadTimeout 为 DialTimeout 的倍数 +) + +var ( + // ChConn ClickHouse 原生连接实例,用于批量写入 + ChConn driver.Conn + + chDB *gorm.DB +) + +// InitClickHouseWithConfig initializes the ClickHouse connection using the provided configuration. +func InitClickHouseWithConfig(cfg ClickHouseConfig) error { + if !cfg.Enabled { + return nil + } + + if cfg.Database == "" { + return fmt.Errorf("[ClickHouse] database name is required (expected: wavelet)") + } + + opts := buildClickHouseOptions(cfg) + + var err error + ChConn, err = clickhouse.Open(opts) + if err != nil { + return fmt.Errorf("[ClickHouse] init connection failed: %w", err) + } + + if err = ChConn.Ping(context.Background()); err != nil { + return fmt.Errorf("[ClickHouse] ping failed: %w", err) + } + + chDB, err = gorm.Open(clickhouseDriver.New(clickhouseDriver.Config{ + DSN: buildClickHouseDSN(cfg), + }), &gorm.Config{ + SkipDefaultTransaction: true, + }) + if err != nil { + return fmt.Errorf("[ClickHouse] init gorm connection failed: %w", err) + } + + if err = chDB.Use( + tracing.NewPlugin( + tracing.WithoutMetrics(), + tracing.WithAttributes( + attribute.String("db.instance", cfg.Database), + attribute.String("db.system", "ClickHouse"), + ), + ), + ); err != nil { + return fmt.Errorf("[ClickHouse] init trace failed: %w", err) + } + + sqlDB, err := chDB.DB() + if err != nil { + return fmt.Errorf("[ClickHouse] load sql db failed: %w", err) + } + + sqlDB.SetMaxIdleConns(cfg.MaxIdleConn) + sqlDB.SetMaxOpenConns(cfg.MaxOpenConn) + sqlDB.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Second) + + log.Println("[ClickHouse] connection established successfully") + return nil +} + +func buildClickHouseOptions(cfg ClickHouseConfig) *clickhouse.Options { + return &clickhouse.Options{ + Addr: cfg.Hosts, + Auth: clickhouse.Auth{ + Database: cfg.Database, + Username: cfg.Username, + Password: cfg.Password, + }, + Settings: clickhouse.Settings{ + "max_execution_time": clickhouseMaxExecTime, + }, + Compression: &clickhouse.Compression{ + Method: clickhouse.CompressionLZ4, + }, + DialTimeout: time.Duration(cfg.DialTimeout) * time.Second, + MaxOpenConns: cfg.MaxOpenConn, + MaxIdleConns: cfg.MaxIdleConn, + ConnMaxLifetime: time.Duration(cfg.ConnMaxLifetime) * time.Second, + ReadTimeout: time.Duration(cfg.DialTimeout*clickhouseReadTimeoutFactor) * time.Second, + BlockBufferSize: cfg.BlockBufferSize, + } +} + +func buildClickHouseDSN(cfg ClickHouseConfig) string { + chURL := &url.URL{ + Scheme: "clickhouse", + Host: strings.Join(cfg.Hosts, ","), + Path: "/" + cfg.Database, + } + if cfg.Username != "" || cfg.Password != "" { + chURL.User = url.UserPassword(cfg.Username, cfg.Password) + } + + query := chURL.Query() + query.Set("dial_timeout", fmt.Sprintf("%ds", cfg.DialTimeout)) + query.Set("read_timeout", fmt.Sprintf("%ds", cfg.DialTimeout*clickhouseReadTimeoutFactor)) + query.Set("max_execution_time", strconv.Itoa(clickhouseMaxExecTime)) + chURL.RawQuery = query.Encode() + + return chURL.String() +} + +// ChDB returns a context-aware GORM ClickHouse instance. +func ChDB(ctx context.Context) *gorm.DB { + if chDB == nil { + return nil + } + return chDB.WithContext(ctx) +} + +// SetChDBForTest sets the package-level ClickHouse GORM instance for testing. +func SetChDBForTest(d *gorm.DB) { + chDB = d +} + +// SetChConnForTest sets the package-level native ClickHouse connection for testing. +func SetChConnForTest(c driver.Conn) { + ChConn = c +} diff --git a/backend/plugins/infra/database/config.go b/backend/plugins/infra/database/config.go new file mode 100644 index 00000000..56f7a255 --- /dev/null +++ b/backend/plugins/infra/database/config.go @@ -0,0 +1,57 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package database + +import "time" + +// ReplicaConfig declares a single read-replica configuration for PostgreSQL. +type ReplicaConfig struct { + Host string `config:"host"` + Port int `config:"port"` + Username string `config:"username"` + Password string `config:"password" secret:"true"` +} + +// Config declares PostgreSQL and SQLite database configuration. +type Config struct { + Enabled bool `config:"enabled" env:"DB_ENABLED" default:"false" autoEnable:"DB_HOST"` + SQLitePath string `config:"sqlite_path" env:"SQLITE_PATH"` + Host string `config:"host" env:"DB_HOST"` + Port int `config:"port" env:"DB_PORT" default:"5432"` + Username string `config:"username" env:"DB_USERNAME"` + Password string `config:"password" env:"DB_PASSWORD" secret:"true"` + Database string `config:"database" env:"DB_NAME" default:"wavelet"` + MaxIdleConn int `config:"max_idle_conn" env:"DB_MAX_IDLE_CONN" default:"10"` + MaxOpenConn int `config:"max_open_conn" env:"DB_MAX_OPEN_CONN" default:"100"` + ConnMaxLifetime int `config:"conn_max_lifetime" env:"DB_CONN_MAX_LIFETIME" default:"3600"` + ConnMaxIdleTime int `config:"conn_max_idle_time" env:"DB_CONN_MAX_IDLE_TIME" default:"600"` + LogLevel string `config:"log_level" env:"DB_LOG_LEVEL" default:"warn"` + SSLMode string `config:"ssl_mode" env:"DB_SSL_MODE" default:"disable"` + TimeZone string `config:"time_zone" env:"DB_TIMEZONE" default:"UTC"` + ApplicationName string `config:"application_name" env:"DB_APPLICATION_NAME" default:"wavelet"` + SearchPath string `config:"search_path" env:"DB_SEARCH_PATH" default:"public"` + PreferSimpleProtocol bool `config:"prefer_simple_protocol" env:"DB_PREFER_SIMPLE_PROTOCOL"` + StatementCacheCapacity int `config:"statement_cache_capacity" env:"DB_STATEMENT_CACHE_CAPACITY"` + DefaultQueryExecMode string `config:"default_query_exec_mode" env:"DB_DEFAULT_QUERY_EXEC_MODE"` + Replicas []ReplicaConfig `config:"replicas"` + SlowThreshold time.Duration `config:"slow_threshold" env:"DB_SLOW_THRESHOLD" default:"200ms"` +} + +// ClickHouseConfig declares the configuration for ClickHouse analytical storage. +type ClickHouseConfig struct { + Enabled bool `config:"enabled" env:"CLICKHOUSE_ENABLED" default:"false" autoEnable:"CLICKHOUSE_HOST"` + Hosts []string `config:"hosts" env:"CLICKHOUSE_HOST"` + Username string `config:"username" env:"CLICKHOUSE_USERNAME"` + Password string `config:"password" env:"CLICKHOUSE_PASSWORD" secret:"true"` + Database string `config:"database" env:"CLICKHOUSE_NAME" default:"wavelet"` + MaxIdleConn int `config:"max_idle_conn" env:"CLICKHOUSE_MAX_IDLE_CONN" default:"10"` + MaxOpenConn int `config:"max_open_conn" env:"CLICKHOUSE_MAX_OPEN_CONN" default:"50"` + ConnMaxLifetime int `config:"conn_max_lifetime" env:"CLICKHOUSE_CONN_MAX_LIFETIME" default:"3600"` + DialTimeout int `config:"dial_timeout" env:"CLICKHOUSE_DIAL_TIMEOUT" default:"10"` + BlockBufferSize uint8 `config:"block_buffer_size" env:"CLICKHOUSE_BLOCK_BUFFER_SIZE" default:"10"` +} + +type appEnvConfig struct { + Env string `config:"env" env:"APP_ENV" default:"development"` +} diff --git a/backend/plugins/infra/database/plugin.go b/backend/plugins/infra/database/plugin.go new file mode 100644 index 00000000..eb16349d --- /dev/null +++ b/backend/plugins/infra/database/plugin.go @@ -0,0 +1,144 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package database provides the relational database infrastructure plugin for Cordis. +package database + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "context" + + "gorm.io/gorm" +) + +// Option configures the database plugin. +type Option func(*Plugin) + +// WithDB configures an explicit *gorm.DB instance for the plugin. +func WithDB(d *gorm.DB) Option { + return func(p *Plugin) { + p.db = d + } +} + +// WithNamedDB registers a named secondary database connection. +func WithNamedDB(name string, d *gorm.DB) Option { + return func(p *Plugin) { + if p.namedDBs == nil { + p.namedDBs = make(map[string]*gorm.DB) + } + p.namedDBs[name] = d + } +} + +// Plugin implements core.Plugin to provide contracts.DBService into the Cordis micro-kernel. +type Plugin struct { + db *gorm.DB + namedDBs map[string]*gorm.DB +} + +// New creates a new database infrastructure plugin. +func New(opts ...Option) *Plugin { + p := &Plugin{ + namedDBs: make(map[string]*gorm.DB), + } + for _, opt := range opts { + if opt != nil { + opt(p) + } + } + return p +} + +// Name returns the unique identifier of the database plugin. +func (p *Plugin) Name() string { + return "database" +} + +// DeclareConfig declares database configuration keys. +func (p *Plugin) DeclareConfig() []core.ConfigBinding { + return []core.ConfigBinding{ + {Prefix: "database", Target: &Config{}}, + {Prefix: "clickhouse", Target: &ClickHouseConfig{}}, + {Prefix: "app", Target: &appEnvConfig{}}, + } +} + +// Apply mounts the database service into the Context. +func (p *Plugin) Apply(ctx *core.Context) error { + var dbCfg Config + if err := ctx.Config().Bind("database", &dbCfg); err != nil { + return err + } + + var chCfg ClickHouseConfig + if err := ctx.Config().Bind("clickhouse", &chCfg); err != nil { + return err + } + + var appCfg appEnvConfig + _ = ctx.Config().Bind("app", &appCfg) + + targetDB := p.db + if targetDB == nil { + var err error + targetDB, err = InitDBWithConfig(dbCfg, appCfg.Env == "production" || appCfg.Env == "prod") + if err != nil { + return err + } + } + + if chCfg.Enabled { + if err := InitClickHouseWithConfig(chCfg); err != nil { + return err + } + } + + svc := &dbServiceImpl{ + primary: targetDB, + namedDBs: p.namedDBs, + } + + if sqlDB, err := targetDB.DB(); err == nil && sqlDB != nil { + ctx.OnDispose(func() error { + return sqlDB.Close() + }) + } + + core.Provide[contracts.DBService](ctx, svc) + return nil +} + +// NewService wraps a GORM DB instance into a contracts.DBService. +func NewService(primary *gorm.DB) contracts.DBService { + return &dbServiceImpl{primary: primary} +} + +type dbServiceImpl struct { + primary *gorm.DB + namedDBs map[string]*gorm.DB +} + +func (s *dbServiceImpl) GORM() *gorm.DB { + if s.primary != nil { + return s.primary + } + return DB(context.Background()) +} + +func (s *dbServiceImpl) DB(ctx context.Context) *gorm.DB { + if s.primary != nil { + return s.primary.WithContext(ctx) + } + return DB(ctx) +} + +func (s *dbServiceImpl) Named(name string) *gorm.DB { + if s.namedDBs != nil { + if d, ok := s.namedDBs[name]; ok && d != nil { + return d + } + } + return s.GORM() +} diff --git a/backend/plugins/infra/database/plugin_test.go b/backend/plugins/infra/database/plugin_test.go new file mode 100644 index 00000000..01d33c5d --- /dev/null +++ b/backend/plugins/infra/database/plugin_test.go @@ -0,0 +1,63 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package database_test + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "Wavelet/plugins/infra/database" + "context" + "path/filepath" + "testing" + + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +type Item struct { + ID uint64 `gorm:"primaryKey"` + Name string +} + +func TestDatabasePlugin(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "db_test.db") + gdb, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, gdb.AutoMigrate(&Item{})) + + namedPath := filepath.Join(t.TempDir(), "named_test.db") + namedDB, err := gorm.Open(sqlite.Open(namedPath), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, namedDB.AutoMigrate(&Item{})) + + p := database.New( + database.WithDB(gdb), + database.WithNamedDB("analytics", namedDB), + ) + ctx := core.NewContext(context.Background()) + ctx.Config().SetSource(core.NewMapSource(map[string]any{ + "database.enabled": false, + })) + require.NoError(t, ctx.Config().Resolve()) + require.NoError(t, p.Apply(ctx)) + + svc, err := core.Inject[contracts.DBService](ctx) + require.NoError(t, err) + require.NotNil(t, svc) + + assert.Equal(t, gdb, svc.GORM()) + assert.NotNil(t, svc.DB(context.Background())) + assert.Equal(t, namedDB, svc.Named("analytics")) + assert.Equal(t, gdb, svc.Named("non_existent")) + + // Verify DB write + item := Item{ID: 1, Name: "TestItem"} + require.NoError(t, svc.DB(context.Background()).Create(&item).Error) + + var retrieved Item + require.NoError(t, svc.GORM().First(&retrieved, 1).Error) + assert.Equal(t, "TestItem", retrieved.Name) +} diff --git a/backend/plugins/infra/database/postgres.go b/backend/plugins/infra/database/postgres.go new file mode 100644 index 00000000..f110ec27 --- /dev/null +++ b/backend/plugins/infra/database/postgres.go @@ -0,0 +1,227 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package database + +import ( + "context" + "fmt" + "log" + "net" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/glebarez/sqlite" + "go.opentelemetry.io/otel/attribute" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/plugin/dbresolver" + "gorm.io/plugin/opentelemetry/tracing" +) + +var db *gorm.DB + +const sqliteDirMode = 0o750 + +// InitDB 初始化主数据库实例(支持 PostgreSQL / SQLite) +func InitDB() (*gorm.DB, error) { + return InitDBWithConfig(Config{}, false) +} + +// InitDBWithConfig initializes the main database with the provided config. +func InitDBWithConfig(cfg Config, isProd bool) (*gorm.DB, error) { + if !cfg.Enabled { + return initSQLiteWithConfig(cfg, isProd) + } + return initPostgresWithConfig(cfg, isProd) +} + +func initSQLiteWithConfig(cfg Config, isProd bool) (*gorm.DB, error) { + sqlitePath := cfg.SQLitePath + if sqlitePath == "" { + sqlitePath = "./data/wavelet.db" + } + + if sqlitePath != ":memory:" && !strings.HasPrefix(sqlitePath, "file:") { + if dir := filepath.Dir(sqlitePath); dir != "" && dir != "." { + if err := os.MkdirAll(dir, sqliteDirMode); err != nil { + return nil, fmt.Errorf("create sqlite directory %q failed: %w", dir, err) + } + } + } + + targetDB, err := gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{ + DisableForeignKeyConstraintWhenMigrating: true, + Logger: &gormZapLogger{ + logLevel: parseLogLevel(cfg.LogLevel), + slowThreshold: cfg.SlowThreshold, + ignoreRecordNotFoundError: isProd, + }, + }) + if err != nil { + return nil, err + } + + // Trace 注入 + if err = targetDB.Use( + tracing.NewPlugin( + tracing.WithoutMetrics(), + tracing.WithAttributes( + attribute.String("db.instance", sqlitePath), + attribute.String("db.system", "SQLite"), + ), + ), + ); err != nil { + return nil, err + } + + db = targetDB + log.Printf("[SQLite] initialized (path: %s)\n", sqlitePath) + return targetDB, nil +} + +func initPostgresWithConfig(cfg Config, isProd bool) (*gorm.DB, error) { + // 构建主库 DSN 并连接 + primaryDSN := buildDSN(cfg, cfg.Host, cfg.Port, cfg.Username, cfg.Password) + + pgConfig := postgres.Config{ + DSN: primaryDSN, + PreferSimpleProtocol: cfg.PreferSimpleProtocol, + } + + targetDB, err := gorm.Open(postgres.New(pgConfig), &gorm.Config{ + DisableForeignKeyConstraintWhenMigrating: true, + Logger: &gormZapLogger{ + logLevel: parseLogLevel(cfg.LogLevel), + slowThreshold: cfg.SlowThreshold, + ignoreRecordNotFoundError: isProd, + }, + }) + if err != nil { + return nil, err + } + + // Trace 注入 + if err = targetDB.Use( + tracing.NewPlugin( + tracing.WithoutMetrics(), + tracing.WithAttributes( + attribute.String("db.instance", cfg.Database), + attribute.String("db.ip", cfg.Host), + attribute.String("server.address", net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port))), + attribute.String("db.system", "PostgreSQL"), + ), + ), + ); err != nil { + return nil, err + } + + if len(cfg.Replicas) > 0 { + var replicaDialectors []gorm.Dialector + for _, replica := range cfg.Replicas { + username := replica.Username + if username == "" { + username = cfg.Username + } + password := replica.Password + if password == "" { + password = cfg.Password + } + replicaDSN := buildDSN(cfg, replica.Host, replica.Port, username, password) + replicaDialectors = append(replicaDialectors, postgres.New(postgres.Config{ + DSN: replicaDSN, + PreferSimpleProtocol: cfg.PreferSimpleProtocol, + })) + } + + resolver := dbresolver.Register(dbresolver.Config{ + Replicas: replicaDialectors, + Policy: dbresolver.RandomPolicy{}, + }) + + resolver.SetMaxIdleConns(cfg.MaxIdleConn). + SetMaxOpenConns(cfg.MaxOpenConn). + SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Second). + SetConnMaxIdleTime(time.Duration(cfg.ConnMaxIdleTime) * time.Second) + + if err = targetDB.Use(resolver); err != nil { + return nil, err + } + log.Printf("[PostgreSQL] initialized in Primary-Replica mode (%d replicas)\n", len(cfg.Replicas)) + } else { + log.Println("[PostgreSQL] initialized in Standalone mode") + } + + // 获取通用数据库对象设置连接池 + sqlDB, err := targetDB.DB() + if err != nil { + return nil, err + } + + sqlDB.SetMaxIdleConns(cfg.MaxIdleConn) + sqlDB.SetMaxOpenConns(cfg.MaxOpenConn) + sqlDB.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Second) + sqlDB.SetConnMaxIdleTime(time.Duration(cfg.ConnMaxIdleTime) * time.Second) + + db = targetDB + return targetDB, nil +} + +// buildDSN 构建 PostgreSQL DSN +func buildDSN(cfg Config, host string, port int, username, password string) string { + pqURL := &url.URL{ + Scheme: "postgres", + Host: net.JoinHostPort(host, strconv.Itoa(port)), + Path: cfg.Database, + } + if username != "" { + pqURL.User = url.UserPassword(username, password) + } + + query := pqURL.Query() + sslMode := cfg.SSLMode + if sslMode == "" { + sslMode = "disable" + } + query.Set("sslmode", sslMode) + if cfg.ApplicationName != "" { + query.Set("application_name", cfg.ApplicationName) + } + if cfg.SearchPath != "" { + query.Set("search_path", cfg.SearchPath) + } + if cfg.DefaultQueryExecMode != "" { + query.Set("default_query_exec_mode", cfg.DefaultQueryExecMode) + } + if cfg.StatementCacheCapacity > 0 { + query.Set("statement_cache_capacity", strconv.Itoa(cfg.StatementCacheCapacity)) + } + + rawQuery := query.Encode() + if cfg.TimeZone != "" { + if rawQuery != "" { + rawQuery += "&" + } + rawQuery += "TimeZone=" + cfg.TimeZone + } + pqURL.RawQuery = rawQuery + + return pqURL.String() +} + +// DB 返回带上下文追踪的 GORM 数据库实例 +func DB(ctx context.Context) *gorm.DB { + if db == nil { + return nil + } + return db.WithContext(ctx) +} + +// SetDB sets the package-level database instance for testing. +func SetDB(d *gorm.DB) { + db = d +} diff --git a/backend/plugins/infra/database/postgres_logger.go b/backend/plugins/infra/database/postgres_logger.go new file mode 100644 index 00000000..9652c2fb --- /dev/null +++ b/backend/plugins/infra/database/postgres_logger.go @@ -0,0 +1,90 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package database + +import ( + "Wavelet/pkg/logger" + "context" + "errors" + "fmt" + "strings" + "time" + + "gorm.io/gorm" + gormLogger "gorm.io/gorm/logger" +) + +// nanoToMilli 纳秒转毫秒的除数 +const nanoToMilli = 1e6 + +type gormZapLogger struct { + logLevel gormLogger.LogLevel + ignoreRecordNotFoundError bool + slowThreshold time.Duration +} + +func (l *gormZapLogger) LogMode(level gormLogger.LogLevel) gormLogger.Interface { + clone := *l + clone.logLevel = level + return &clone +} + +func (l *gormZapLogger) Info(ctx context.Context, fmt string, args ...interface{}) { + if l.logLevel >= gormLogger.Info { + logger.InfoF(ctx, fmt, args...) + } +} + +func (l *gormZapLogger) Warn(ctx context.Context, fmt string, args ...interface{}) { + if l.logLevel >= gormLogger.Warn { + logger.WarnF(ctx, fmt, args...) + } +} + +func (l *gormZapLogger) Error(ctx context.Context, fmt string, args ...interface{}) { + if l.logLevel >= gormLogger.Error { + logger.ErrorF(ctx, fmt, args...) + } +} + +func (l *gormZapLogger) Trace(ctx context.Context, begin time.Time, fc func() (sql string, rowsAffected int64), err error) { + elapsed := time.Since(begin) + switch { + case err != nil && l.logLevel >= gormLogger.Error && (!errors.Is(err, gorm.ErrRecordNotFound) || !l.ignoreRecordNotFoundError): + _, rows := fc() + logger.ErrorF(ctx, "database query failed: %s [%.3fms] [rows:%v]", err, float64(elapsed.Nanoseconds())/nanoToMilli, formatRows(rows)) + case elapsed > l.slowThreshold && l.slowThreshold != 0 && l.logLevel >= gormLogger.Warn: + _, rows := fc() + slowLog := fmt.Sprintf("SLOW SQL >= %v", l.slowThreshold) + logger.WarnF(ctx, "%s [%.3fms] [rows:%v]", slowLog, float64(elapsed.Nanoseconds())/nanoToMilli, formatRows(rows)) + case l.logLevel == gormLogger.Info: + sql, rows := fc() + logger.DebugF(ctx, "[%.3fms] [rows:%v] %s", float64(elapsed.Nanoseconds())/nanoToMilli, formatRows(rows), sql) + } +} + +func formatRows(rows int64) interface{} { + if rows == -1 { + return "-" + } + return rows +} + +func parseLogLevel(level string) gormLogger.LogLevel { + level = strings.ToLower(level) + switch level { + case "silent": + return gormLogger.Silent + case "error": + return gormLogger.Error + case "warn": + return gormLogger.Warn + case "info": + return gormLogger.Info + case "debug": + return gormLogger.Info + default: + return gormLogger.Info + } +} diff --git a/backend/plugins/infra/database/postgres_logger_test.go b/backend/plugins/infra/database/postgres_logger_test.go new file mode 100644 index 00000000..bfcd1a7b --- /dev/null +++ b/backend/plugins/infra/database/postgres_logger_test.go @@ -0,0 +1,39 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package database + +import ( + "testing" + + gormLogger "gorm.io/gorm/logger" +) + +func TestParseLogLevel(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + configuredLevel string + want gormLogger.LogLevel + }{ + { + name: "debug enables SQL trace processing", + configuredLevel: "debug", + want: gormLogger.Info, + }, + { + name: "development preserves configured level", + configuredLevel: "warn", + want: gormLogger.Warn, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := parseLogLevel(tt.configuredLevel); got != tt.want { + t.Fatalf("parseLogLevel() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/backend/plugins/infra/infra_test.go b/backend/plugins/infra/infra_test.go new file mode 100644 index 00000000..1bd4e519 --- /dev/null +++ b/backend/plugins/infra/infra_test.go @@ -0,0 +1,343 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package infra_test + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "Wavelet/plugins/infra/cache" + "Wavelet/plugins/infra/database" + "Wavelet/plugins/infra/logger" + "Wavelet/plugins/infra/storage" + "Wavelet/plugins/infra/storage/objectstore" + "bytes" + "context" + "io" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/glebarez/sqlite" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +type TestUser struct { + ID uint64 `gorm:"primaryKey"` + Name string +} + +func setupTestDB(t *testing.T) *gorm.DB { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "test.db") + db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&TestUser{})) + return db +} + +func prepareTestContext(values map[string]any, declarers ...core.Plugin) *core.Context { + ctx := core.NewContext(context.Background()) + ctx.Config().SetSource(core.NewMapSource(values)) + for _, p := range declarers { + if d, ok := p.(interface{ DeclareConfig() []core.ConfigBinding }); ok { + for _, b := range d.DeclareConfig() { + _ = ctx.Config().Declare(p.Name(), b) + } + } + } + _ = ctx.Config().Resolve() + return ctx +} + +func TestDatabasePlugin(t *testing.T) { + testDB := setupTestDB(t) + p := database.New(database.WithDB(testDB)) + ctx := prepareTestContext(nil, p) + + require.Equal(t, "database", p.Name()) + require.NoError(t, p.Apply(ctx)) + + dbSvc, err := core.Inject[contracts.DBService](ctx) + require.NoError(t, err) + require.NotNil(t, dbSvc) + + // Verify GORM and DB methods + assert.NotNil(t, dbSvc.GORM()) + assert.NotNil(t, dbSvc.DB(context.Background())) + + // Test CRUD via service + user := TestUser{ID: 1, Name: "Alice"} + require.NoError(t, dbSvc.DB(context.Background()).Create(&user).Error) + + var fetched TestUser + require.NoError(t, dbSvc.GORM().First(&fetched, 1).Error) + assert.Equal(t, "Alice", fetched.Name) + + // Test NamedDB fallback + assert.NotNil(t, dbSvc.Named("replica")) +} + +func TestCachePluginRAMOnly(t *testing.T) { + p := cache.New() + ctx := prepareTestContext(nil, p) + + require.Equal(t, "cache", p.Name()) + require.NoError(t, p.Apply(ctx)) + + cacheSvc, err := core.Inject[contracts.CacheService](ctx) + require.NoError(t, err) + require.NotNil(t, cacheSvc) + + type CacheItem struct { + Name string `json:"name"` + Count int `json:"count"` + } + + testCtx := context.Background() + + // 1. Get non-existing key + var notFound CacheItem + err = cacheSvc.Get(testCtx, "missing:key", ¬Found) + assert.ErrorIs(t, err, contracts.ErrCacheMiss) + + // 2. Set and Get + item := CacheItem{Name: "item1", Count: 42} + require.NoError(t, cacheSvc.Set(testCtx, "test:item1", item, time.Minute)) + + var retrieved CacheItem + require.NoError(t, cacheSvc.Get(testCtx, "test:item1", &retrieved)) + assert.Equal(t, item, retrieved) + + // 3. GetOrSet + var getOrSetTarget CacheItem + var loaderCalled bool + err = cacheSvc.GetOrSet(testCtx, "test:item1", &getOrSetTarget, time.Minute, func() (any, error) { + loaderCalled = true + return CacheItem{Name: "never_called", Count: 0}, nil + }) + require.NoError(t, err) + assert.False(t, loaderCalled) + assert.Equal(t, item, getOrSetTarget) + + // GetOrSet with cache miss + var newItem CacheItem + err = cacheSvc.GetOrSet(testCtx, "test:item2", &newItem, time.Minute, func() (any, error) { + loaderCalled = true + return CacheItem{Name: "loaded", Count: 99}, nil + }) + require.NoError(t, err) + assert.True(t, loaderCalled) + assert.Equal(t, "loaded", newItem.Name) + assert.Equal(t, 99, newItem.Count) + + // 4. Delete + require.NoError(t, cacheSvc.Delete(testCtx, "test:item1")) + err = cacheSvc.Get(testCtx, "test:item1", &retrieved) + assert.ErrorIs(t, err, contracts.ErrCacheMiss) + + // 5. Invalidate alias + require.NoError(t, cacheSvc.Invalidate(testCtx, "test:item2")) + err = cacheSvc.Get(testCtx, "test:item2", &retrieved) + assert.ErrorIs(t, err, contracts.ErrCacheMiss) +} + +func TestCachePluginWithRedisAndPubSub(t *testing.T) { + mr, err := miniredis.Run() + require.NoError(t, err) + defer mr.Close() + + rdb := redis.NewClient(&redis.Options{ + Addr: mr.Addr(), + }) + defer func() { _ = rdb.Close() }() + + p1 := cache.New(cache.WithRedis(rdb), cache.WithKeyPrefix("test:")) + p2 := cache.New(cache.WithRedis(rdb), cache.WithKeyPrefix("test:")) + + ctx1 := prepareTestContext(map[string]any{"redis.enabled": true}, p1) + ctx2 := prepareTestContext(map[string]any{"redis.enabled": true}, p2) + + require.NoError(t, p1.Apply(ctx1)) + require.NoError(t, p2.Apply(ctx2)) + + cache1, err := core.Inject[contracts.CacheService](ctx1) + require.NoError(t, err) + cache2, err := core.Inject[contracts.CacheService](ctx2) + require.NoError(t, err) + + testCtx := context.Background() + + // Node 1 writes to cache + type UserCache struct { + Name string `json:"name"` + } + require.NoError(t, cache1.Set(testCtx, "user:100", UserCache{Name: "Bob"}, 10*time.Minute)) + + // Node 2 reads from cache (misses Node 2's RAM, hits Redis, backfills Node 2's RAM) + var u2 UserCache + require.NoError(t, cache2.Get(testCtx, "user:100", &u2)) + assert.Equal(t, "Bob", u2.Name) + + // Node 1 deletes cache (evicts Node 1 RAM, Redis, and broadcasts to Node 2) + require.NoError(t, cache1.Delete(testCtx, "user:100")) + + // Verify Redis is deleted + var uRedis UserCache + err = cache1.Get(testCtx, "user:100", &uRedis) + assert.ErrorIs(t, err, contracts.ErrCacheMiss) + + // Clean up contexts + require.NoError(t, ctx1.Dispose()) + require.NoError(t, ctx2.Dispose()) +} + +func TestLoggerPlugin(t *testing.T) { + ctx := core.NewContext(context.Background()) + p := logger.New() + require.Equal(t, "logger", p.Name()) + require.NoError(t, p.Apply(ctx)) + + logSvc, err := core.Inject[contracts.LoggerService](ctx) + require.NoError(t, err) + require.NotNil(t, logSvc) + + testCtx := context.Background() + + // Should not panic on any log call + logSvc.Debug(testCtx, "debug message", "key1", "val1") + logSvc.Info(testCtx, "info message", "userID", 123) + logSvc.Warn(testCtx, "warn message", "warning", true) + logSvc.Error(testCtx, "error message", "err", "something broke") + + logSvc.Debugf(testCtx, "formatted debug %d", 1) + logSvc.Infof(testCtx, "formatted info %s", "test") + logSvc.Warnf(testCtx, "formatted warn %v", map[string]int{"a": 1}) + logSvc.Errorf(testCtx, "formatted error %s", "fatal") + + childLog := logSvc.With("module", "test_module") + require.NotNil(t, childLog) + childLog.Info(testCtx, "child log message", "action", "run") +} + +type memoryBackend struct { + mu sync.RWMutex + storage map[string][]byte +} + +func newMemoryBackend() *memoryBackend { + return &memoryBackend{ + storage: make(map[string][]byte), + } +} + +func (m *memoryBackend) Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) (objectstore.PutResult, error) { + m.mu.Lock() + defer m.mu.Unlock() + data, err := io.ReadAll(body) + if err != nil { + return objectstore.PutResult{}, err + } + m.storage[key] = data + return objectstore.PutResult{Key: key, Bucket: "test-bucket"}, nil +} + +func (m *memoryBackend) Get(ctx context.Context, key string) (*objectstore.Object, error) { + m.mu.RLock() + defer m.mu.RUnlock() + data, ok := m.storage[key] + if !ok { + return nil, gorm.ErrRecordNotFound + } + return &objectstore.Object{ + Body: io.NopCloser(bytes.NewReader(data)), + ContentLength: int64(len(data)), + ContentType: "application/octet-stream", + }, nil +} + +func (m *memoryBackend) Delete(ctx context.Context, key string) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.storage, key) + return nil +} + +func (m *memoryBackend) Test(ctx context.Context) error { + return nil +} + +func TestStoragePlugin(t *testing.T) { + ctx := core.NewContext(context.Background()) + backend := newMemoryBackend() + + p := storage.New(storage.WithBackend(backend)) + require.Equal(t, "storage", p.Name()) + require.NoError(t, p.Apply(ctx)) + + storageSvc, err := core.Inject[contracts.StorageService](ctx) + require.NoError(t, err) + require.NotNil(t, storageSvc) + + testCtx := context.Background() + + // 1. Put + content := []byte("Hello, Wavelet Storage Plugin!") + putRes, err := storageSvc.Put(testCtx, "uploads/hello.txt", bytes.NewReader(content), int64(len(content)), "text/plain") + require.NoError(t, err) + assert.Equal(t, "uploads/hello.txt", putRes.Key) + assert.Equal(t, "test-bucket", putRes.Bucket) + + // 2. Get + obj, err := storageSvc.Get(testCtx, "uploads/hello.txt") + require.NoError(t, err) + require.NotNil(t, obj) + data, err := io.ReadAll(obj.Body) + require.NoError(t, err) + assert.Equal(t, content, data) + assert.Equal(t, int64(len(content)), obj.ContentLength) + + // 3. Delete + require.NoError(t, storageSvc.Delete(testCtx, "uploads/hello.txt")) + _, err = storageSvc.Get(testCtx, "uploads/hello.txt") + assert.Error(t, err) +} + +func TestAllInfraPluginsCombined(t *testing.T) { + testDB := setupTestDB(t) + memBackend := newMemoryBackend() + + dbP := database.New(database.WithDB(testDB)) + cacheP := cache.New() + logP := logger.New() + storageP := storage.New(storage.WithBackend(memBackend)) + + ctx := prepareTestContext(nil, dbP, cacheP, logP, storageP) + + require.NoError(t, dbP.Apply(ctx)) + require.NoError(t, cacheP.Apply(ctx)) + require.NoError(t, logP.Apply(ctx)) + require.NoError(t, storageP.Apply(ctx)) + + // Using3 to resolve dependencies concurrently + var resolved bool + err := core.Using3(ctx, func(db contracts.DBService, c contracts.CacheService, l contracts.LoggerService) { + resolved = true + assert.NotNil(t, db) + assert.NotNil(t, c) + assert.NotNil(t, l) + }) + require.NoError(t, err) + assert.True(t, resolved) + + // Using storage + err = core.Using(ctx, func(s contracts.StorageService) { + assert.NotNil(t, s) + }) + require.NoError(t, err) +} diff --git a/backend/plugins/infra/logger/plugin.go b/backend/plugins/infra/logger/plugin.go new file mode 100644 index 00000000..e97d540d --- /dev/null +++ b/backend/plugins/infra/logger/plugin.go @@ -0,0 +1,109 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package logger provides the structured logging infrastructure plugin for Cordis. +package logger + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "Wavelet/pkg/logger" + "context" + "fmt" + "strings" +) + +// Plugin implements core.Plugin to provide contracts.LoggerService. +type Plugin struct{} + +// New creates a new logger infrastructure plugin. +func New() *Plugin { + return &Plugin{} +} + +// Name returns the unique identifier of the logger plugin. +func (p *Plugin) Name() string { + return "logger" +} + +// Apply mounts the logger service into the Context. +func (p *Plugin) Apply(ctx *core.Context) error { + svc := &loggerServiceImpl{} + core.Provide[contracts.LoggerService](ctx, svc) + return nil +} + +type loggerServiceImpl struct { + extraFields []any +} + +func (s *loggerServiceImpl) formatMsg(msg string, keysAndValues ...any) string { + allFields := make([]any, 0, len(s.extraFields)+len(keysAndValues)) + allFields = append(allFields, s.extraFields...) + allFields = append(allFields, keysAndValues...) + + if len(allFields) == 0 { + return msg + } + + var sb strings.Builder + sb.WriteString(msg) + sb.WriteString(" [") + for i := 0; i < len(allFields); i += 2 { + if i > 0 { + sb.WriteString(" ") + } + if i+1 < len(allFields) { + fmt.Fprintf(&sb, "%v=%v", allFields[i], allFields[i+1]) + } else { + fmt.Fprintf(&sb, "%v", allFields[i]) + } + } + sb.WriteString("]") + return sb.String() +} + +func (s *loggerServiceImpl) Debug(ctx context.Context, msg string, keysAndValues ...any) { + logger.DebugF(ctx, "%s", s.formatMsg(msg, keysAndValues...)) +} + +func (s *loggerServiceImpl) Info(ctx context.Context, msg string, keysAndValues ...any) { + logger.InfoF(ctx, "%s", s.formatMsg(msg, keysAndValues...)) +} + +func (s *loggerServiceImpl) Warn(ctx context.Context, msg string, keysAndValues ...any) { + logger.WarnF(ctx, "%s", s.formatMsg(msg, keysAndValues...)) +} + +func (s *loggerServiceImpl) Error(ctx context.Context, msg string, keysAndValues ...any) { + logger.ErrorF(ctx, "%s", s.formatMsg(msg, keysAndValues...)) +} + +func (s *loggerServiceImpl) Debugf(ctx context.Context, format string, args ...any) { + msg := fmt.Sprintf(format, args...) + logger.DebugF(ctx, "%s", s.formatMsg(msg)) +} + +func (s *loggerServiceImpl) Infof(ctx context.Context, format string, args ...any) { + msg := fmt.Sprintf(format, args...) + logger.InfoF(ctx, "%s", s.formatMsg(msg)) +} + +func (s *loggerServiceImpl) Warnf(ctx context.Context, format string, args ...any) { + msg := fmt.Sprintf(format, args...) + logger.WarnF(ctx, "%s", s.formatMsg(msg)) +} + +func (s *loggerServiceImpl) Errorf(ctx context.Context, format string, args ...any) { + msg := fmt.Sprintf(format, args...) + logger.ErrorF(ctx, "%s", s.formatMsg(msg)) +} + +func (s *loggerServiceImpl) With(keysAndValues ...any) contracts.LoggerService { + merged := make([]any, 0, len(s.extraFields)+len(keysAndValues)) + merged = append(merged, s.extraFields...) + merged = append(merged, keysAndValues...) + return &loggerServiceImpl{ + extraFields: merged, + } +} diff --git a/backend/plugins/infra/logger/plugin_test.go b/backend/plugins/infra/logger/plugin_test.go new file mode 100644 index 00000000..701967f1 --- /dev/null +++ b/backend/plugins/infra/logger/plugin_test.go @@ -0,0 +1,43 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package logger_test + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "Wavelet/plugins/infra/logger" + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoggerPluginOperations(t *testing.T) { + p := logger.New() + assert.Equal(t, "logger", p.Name()) + + ctx := core.NewContext(context.Background()) + require.NoError(t, p.Apply(ctx)) + + svc, err := core.Inject[contracts.LoggerService](ctx) + require.NoError(t, err) + require.NotNil(t, svc) + + testCtx := context.Background() + + svc.Debug(testCtx, "debug msg", "key", "val") + svc.Info(testCtx, "info msg", "user", 1) + svc.Warn(testCtx, "warn msg", "odd_field") + svc.Error(testCtx, "error msg") + + svc.Debugf(testCtx, "debug %s", "formatted") + svc.Infof(testCtx, "info %d", 100) + svc.Warnf(testCtx, "warn %v", true) + svc.Errorf(testCtx, "error %s", "fail") + + withLogger := svc.With("trace", "t-123", "span", "s-456") + require.NotNil(t, withLogger) + withLogger.Info(testCtx, "enriched message") +} diff --git a/backend/plugins/infra/storage/diskcache/cache.go b/backend/plugins/infra/storage/diskcache/cache.go new file mode 100644 index 00000000..744d8ba5 --- /dev/null +++ b/backend/plugins/infra/storage/diskcache/cache.go @@ -0,0 +1,98 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package diskcache wraps the generic pkg/cache/disk to provide database configuration integration. +package diskcache + +import ( + "context" + "strconv" + "sync" + "time" + + pkgcache "Wavelet/pkg/cache/disk" + "Wavelet/pkg/util" +) + +// Status represents the runtime cache statistics. +type Status = pkgcache.Status + +const ( + defaultCacheDir = "uploads/diskcache" + defaultMaxSizeMB = 100 + defaultTTLMinutes = 60 + defaultCleanupInterval = 10 + + // DefaultExpiration applies the cache-wide default TTL. + DefaultExpiration = pkgcache.DefaultExpiration + // NoExpiration stores the item without a TTL. Size limits and LRU eviction still apply. + NoExpiration = pkgcache.NoExpiration +) + +// ErrCacheMiss represents a cache miss. +var ErrCacheMiss = pkgcache.ErrCacheMiss + +// DiskCache is a wrapper around the generic pkg/diskcache that integrates with the DB for configs. +type DiskCache struct { + *pkgcache.Cache +} + +var ( + globalCache *DiskCache + globalCacheOnce sync.Once +) + +// GetGlobalCache returns the global singleton DiskCache instance. +func GetGlobalCache() *DiskCache { + globalCacheOnce.Do(func() { + pureCache := pkgcache.New(defaultCacheDir) + globalCache = &DiskCache{pureCache} + // Load initial configs from database + globalCache.ReloadConfig(context.Background()) + // Start background routine to clean expired items every 10 minutes + util.Go(func() { globalCache.StartCleanupWorker(defaultCleanupInterval * time.Minute) }) + }) + return globalCache +} + +// New creates a new DiskCache wrapper. +func New(basePath string) *DiskCache { + return &DiskCache{pkgcache.New(basePath)} +} + +// ReloadConfig reloads policies from database configs dynamically. +func (c *DiskCache) ReloadConfig(ctx context.Context) { + // Ensure DB is initialized before querying + if getDB(ctx) == nil { + return + } + + // 1. Max Size + maxSizeMB := int64(defaultMaxSizeMB) + var maxVal string + if err := getDB(ctx).Table("w_system_configs").Where("key = ?", "disk_cache_max_size_mb").Pluck("value", &maxVal).Error; err == nil && maxVal != "" { + if val, err := strconv.ParseInt(maxVal, 10, 64); err == nil && val > 0 { + maxSizeMB = val + } + } + + // 2. Default TTL + ttlMinutes := int64(defaultTTLMinutes) + var ttlVal string + if err := getDB(ctx).Table("w_system_configs").Where("key = ?", "disk_cache_ttl_minutes").Pluck("value", &ttlVal).Error; err == nil && ttlVal != "" { + if val, err := strconv.ParseInt(ttlVal, 10, 64); err == nil && val >= 0 { + ttlMinutes = val + } + } + + // 3. LRU Enabled + lruEnabled := true + var lruVal string + if err := getDB(ctx).Table("w_system_configs").Where("key = ?", "disk_cache_lru_enabled").Pluck("value", &lruVal).Error; err == nil && lruVal != "" { + if val, err := strconv.ParseBool(lruVal); err == nil { + lruEnabled = val + } + } + + c.UpdatePolicy(maxSizeMB, ttlMinutes, lruEnabled) +} diff --git a/backend/plugins/infra/storage/diskcache/cache_test.go b/backend/plugins/infra/storage/diskcache/cache_test.go new file mode 100644 index 00000000..79f04eaa --- /dev/null +++ b/backend/plugins/infra/storage/diskcache/cache_test.go @@ -0,0 +1,61 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package diskcache + +import ( + "Wavelet/pkg/testhelper" + "context" + "testing" + + "gorm.io/gorm" +) + +type mockDBService struct { + db *gorm.DB +} + +func (m *mockDBService) GORM() *gorm.DB { + return m.db +} + +func (m *mockDBService) DB(ctx context.Context) *gorm.DB { + return m.db.WithContext(ctx) +} + +func (m *mockDBService) Named(_ string) *gorm.DB { + return m.db +} + +func TestDiskCacheReloadConfig(t *testing.T) { + dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) + SetDBService(&mockDBService{db: dbConn}) + defer func() { + SetDBService(nil) + cleanup() + }() + + testDir := t.TempDir() + + c := New(testDir) + defer func() { _ = c.Clear() }() + + // Update DB config values + dbConn.Table("w_system_configs").Where("key = ?", "disk_cache_max_size_mb").Update("value", "250") + dbConn.Table("w_system_configs").Where("key = ?", "disk_cache_ttl_minutes").Update("value", "120") + dbConn.Table("w_system_configs").Where("key = ?", "disk_cache_lru_enabled").Update("value", "false") + + // Reload config + c.ReloadConfig(context.Background()) + + status := c.Status() + if status.MaxSizeMB != 250 { + t.Errorf("expected MaxSizeMB to be 250, got %d", status.MaxSizeMB) + } + if status.TTLMinutes != 120 { + t.Errorf("expected TTLMinutes to be 120, got %d", status.TTLMinutes) + } + if status.LRUEnabled != false { + t.Errorf("expected LRUEnabled to be false, got %v", status.LRUEnabled) + } +} diff --git a/backend/plugins/infra/storage/diskcache/db_helper.go b/backend/plugins/infra/storage/diskcache/db_helper.go new file mode 100644 index 00000000..1a89378f --- /dev/null +++ b/backend/plugins/infra/storage/diskcache/db_helper.go @@ -0,0 +1,40 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package diskcache + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "context" + "sync" + + "gorm.io/gorm" +) + +var ( + dbMu sync.RWMutex + dbSvc contracts.DBService +) + +// SetDBService sets the DBService instance for diskcache. +func SetDBService(s contracts.DBService) { + dbMu.Lock() + defer dbMu.Unlock() + dbSvc = s +} + +func getDB(ctx context.Context) *gorm.DB { + if c, ok := ctx.(*core.Context); ok && c != nil { + if s, err := core.Inject[contracts.DBService](c); err == nil && s != nil { + return s.DB(ctx) + } + } + dbMu.RLock() + s := dbSvc + dbMu.RUnlock() + if s != nil { + return s.DB(ctx) + } + return nil +} diff --git a/backend/plugins/infra/storage/objectstore/config.go b/backend/plugins/infra/storage/objectstore/config.go new file mode 100644 index 00000000..975f60d3 --- /dev/null +++ b/backend/plugins/infra/storage/objectstore/config.go @@ -0,0 +1,258 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package objectstore provides dynamically configured file storage backends. +package objectstore + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "gorm.io/gorm" +) + +// Driver identifies a supported storage backend. +type Driver string + +const ( + // DriverLocal stores files on the local filesystem. + DriverLocal Driver = "local" + // DriverS3 stores files in an S3-compatible object store. + DriverS3 Driver = "s3" + // DriverR2 stores files in Cloudflare R2. + DriverR2 Driver = "r2" + // DriverMinIO stores files in MinIO. + DriverMinIO Driver = "minio" + // DriverOSS stores files in Aliyun OSS. + DriverOSS Driver = "oss" + // DriverWebDAV stores files through WebDAV. + DriverWebDAV Driver = "webdav" + + // ConfigMask replaces secrets returned to the frontend. + ConfigMask = "******" +) + +// LocalConfig configures local filesystem storage. +type LocalConfig struct { + Root string `json:"root"` +} + +// ObjectConfig configures S3-compatible or OSS object storage. +type ObjectConfig struct { + Endpoint string `json:"endpoint"` + Region string `json:"region"` + Bucket string `json:"bucket"` + AccessKeyID string `json:"access_key_id"` + SecretAccessKey string `json:"secret_access_key"` + AccountID string `json:"account_id,omitempty"` + PathStyle bool `json:"path_style"` + KeyPrefix string `json:"key_prefix"` + CDNURL string `json:"cdn_url"` +} + +// WebDAVConfig configures WebDAV storage. +type WebDAVConfig struct { + Endpoint string `json:"endpoint"` + Username string `json:"username"` + Password string `json:"password"` + BasePath string `json:"base_path"` +} + +// Config contains all storage backends and the currently active driver. +type Config struct { + Driver Driver `json:"driver"` + Local LocalConfig `json:"local"` + S3 ObjectConfig `json:"s3"` + R2 ObjectConfig `json:"r2"` + MinIO ObjectConfig `json:"minio"` + OSS ObjectConfig `json:"oss"` + WebDAV WebDAVConfig `json:"webdav"` +} + +// DefaultConfig returns the local-storage default configuration. +func DefaultConfig() Config { + return Config{ + Driver: DriverLocal, + Local: LocalConfig{Root: "."}, + S3: ObjectConfig{Region: "us-east-1"}, + R2: ObjectConfig{Region: "auto"}, + MinIO: ObjectConfig{Region: "us-east-1", PathStyle: true}, + } +} + +// LoadConfig loads the active storage configuration. +func LoadConfig(ctx context.Context) (Config, error) { + pubSubOnce.Do(startPubSubListener) + + cacheMutex.RLock() + isCacheValid := time.Since(lastChecked) < 5*time.Second && activeConfigJSON != "" + configJSON := activeConfigJSON + cacheMutex.RUnlock() + + if isCacheValid { + cfg := DefaultConfig() + if strings.TrimSpace(configJSON) != "" { + if err := json.Unmarshal([]byte(configJSON), &cfg); err != nil { + return Config{}, fmt.Errorf("parse storage config from cache: %w", err) + } + } + return cfg, nil + } + + return loadConfigByKey(ctx, "storage_config", DefaultConfig()) +} + +func loadConfigByKey(ctx context.Context, key string, fallback Config) (Config, error) { + var val string + db := getDB(ctx) + if db != nil { + err := db.Table("w_system_configs").Where("key = ?", key).Pluck("value", &val).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fallback, nil + } + return Config{}, err + } + } + if strings.TrimSpace(val) == "" { + return fallback, nil + } + if err := json.Unmarshal([]byte(val), &fallback); err != nil { + return Config{}, fmt.Errorf("parse %s: %w", key, err) + } + return fallback, nil +} + +// ValidateConfig validates the selected backend configuration. +func ValidateConfig(cfg Config) error { + switch cfg.Driver { + case DriverLocal: + if strings.TrimSpace(cfg.Local.Root) == "" { + return errors.New("local root is required") + } + case DriverS3: + return validateObjectConfig(cfg.S3, false) + case DriverR2: + if strings.TrimSpace(cfg.R2.AccountID) == "" && strings.TrimSpace(cfg.R2.Endpoint) == "" { + return errors.New("R2 account ID or endpoint is required") + } + return validateObjectConfig(cfg.R2, false) + case DriverMinIO: + if strings.TrimSpace(cfg.MinIO.Endpoint) == "" { + return errors.New("MinIO endpoint is required") + } + return validateObjectConfig(cfg.MinIO, true) + case DriverOSS: + if strings.TrimSpace(cfg.OSS.Endpoint) == "" { + return errors.New("OSS endpoint is required") + } + return validateObjectConfig(cfg.OSS, true) + case DriverWebDAV: + if strings.TrimSpace(cfg.WebDAV.Endpoint) == "" { + return errors.New("WebDAV endpoint is required") + } + default: + return fmt.Errorf("unsupported storage driver %q", cfg.Driver) + } + return nil +} + +func validateObjectConfig(cfg ObjectConfig, endpointRequired bool) error { + if endpointRequired && strings.TrimSpace(cfg.Endpoint) == "" { + return errors.New("endpoint is required") + } + if strings.TrimSpace(cfg.Region) == "" { + return errors.New("region is required") + } + if strings.TrimSpace(cfg.Bucket) == "" { + return errors.New("bucket is required") + } + if strings.TrimSpace(cfg.AccessKeyID) == "" || strings.TrimSpace(cfg.SecretAccessKey) == "" { + return errors.New("access key ID and secret access key are required") + } + return nil +} + +// SaveActiveConfig persists the active storage configuration. +func SaveActiveConfig(ctx context.Context, cfg Config) error { + return saveSystemConfig(ctx, "storage_config", cfg, "文件存储驱动及连接配置(JSON)") +} + +func saveSystemConfig(ctx context.Context, key string, value any, description string) error { + err := getDB(ctx).Transaction(func(tx *gorm.DB) error { + return upsertSystemConfig(ctx, tx, key, value, description) + }) + if err == nil && key == "storage_config" { + ResetCache() + PublishCacheInvalidation(ctx) + } + return err +} + +func upsertSystemConfig(_ context.Context, tx *gorm.DB, key string, value any, description string) error { + data, err := json.Marshal(value) + if err != nil { + return fmt.Errorf("marshal %s: %w", key, err) + } + type sysConfig struct { + Key string `gorm:"primaryKey"` + Value string + Type string + Visibility string + Description string + } + sc := sysConfig{ + Key: key, + Value: string(data), + Type: "system", + Visibility: "hidden", + Description: description, + } + return tx.Table("w_system_configs").Where("key = ?", key). + Assign(map[string]any{"value": sc.Value, "description": description, "visibility": "hidden"}). + FirstOrCreate(&sc).Error +} + +// MergeMaskedSecrets restores unchanged secrets from the current configuration. +func MergeMaskedSecrets(next, current Config) Config { + mergeObjectSecret := func(dst *ObjectConfig, src ObjectConfig) { + if dst.AccessKeyID == ConfigMask { + dst.AccessKeyID = src.AccessKeyID + } + if dst.SecretAccessKey == ConfigMask { + dst.SecretAccessKey = src.SecretAccessKey + } + } + mergeObjectSecret(&next.S3, current.S3) + mergeObjectSecret(&next.R2, current.R2) + mergeObjectSecret(&next.MinIO, current.MinIO) + mergeObjectSecret(&next.OSS, current.OSS) + if next.WebDAV.Password == ConfigMask { + next.WebDAV.Password = current.WebDAV.Password + } + return next +} + +// MaskSecrets replaces stored credentials with placeholders for API responses. +func MaskSecrets(cfg Config) Config { + maskObject := func(value *ObjectConfig) { + if value.AccessKeyID != "" { + value.AccessKeyID = ConfigMask + } + if value.SecretAccessKey != "" { + value.SecretAccessKey = ConfigMask + } + } + maskObject(&cfg.S3) + maskObject(&cfg.R2) + maskObject(&cfg.MinIO) + maskObject(&cfg.OSS) + if cfg.WebDAV.Password != "" { + cfg.WebDAV.Password = ConfigMask + } + return cfg +} diff --git a/backend/plugins/infra/storage/objectstore/db_helper.go b/backend/plugins/infra/storage/objectstore/db_helper.go new file mode 100644 index 00000000..73e67567 --- /dev/null +++ b/backend/plugins/infra/storage/objectstore/db_helper.go @@ -0,0 +1,61 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package objectstore + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "context" + "sync" + + "gorm.io/gorm" +) + +var ( + dbMu sync.RWMutex + dbSvc contracts.DBService + cacheMu sync.RWMutex + cacheSvc contracts.CacheService +) + +// SetDBService sets the DBService instance for objectstore. +func SetDBService(s contracts.DBService) { + dbMu.Lock() + defer dbMu.Unlock() + dbSvc = s +} + +// SetCacheService sets the CacheService instance for objectstore. +func SetCacheService(s contracts.CacheService) { + cacheMu.Lock() + defer cacheMu.Unlock() + cacheSvc = s +} + +func getDB(ctx context.Context) *gorm.DB { + if c, ok := ctx.(*core.Context); ok && c != nil { + if s, err := core.Inject[contracts.DBService](c); err == nil && s != nil { + return s.DB(ctx) + } + } + dbMu.RLock() + s := dbSvc + dbMu.RUnlock() + if s != nil { + return s.DB(ctx) + } + return nil +} + +func getCache(ctx context.Context) contracts.CacheService { + if c, ok := ctx.(*core.Context); ok && c != nil { + if s, err := core.Inject[contracts.CacheService](c); err == nil && s != nil { + return s + } + } + cacheMu.RLock() + s := cacheSvc + cacheMu.RUnlock() + return s +} diff --git a/backend/plugins/infra/storage/objectstore/http.go b/backend/plugins/infra/storage/objectstore/http.go new file mode 100644 index 00000000..d2459741 --- /dev/null +++ b/backend/plugins/infra/storage/objectstore/http.go @@ -0,0 +1,43 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package objectstore + +import ( + "Wavelet/pkg/httppool" + "context" + "fmt" + "net/http" + "net/url" + "time" +) + +func getHTTPObject(ctx context.Context, baseURL, key string) (*Object, error) { + objectURL, err := url.JoinPath(baseURL, key) + if err != nil { + return nil, fmt.Errorf("build CDN object URL: %w", err) + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, objectURL, nil) + if err != nil { + return nil, fmt.Errorf("create CDN request: %w", err) + } + const cdnRequestTimeout = 30 * time.Second + client := httppool.NewClient(cdnRequestTimeout) + response, err := client.Do(request) + if err != nil { + return nil, fmt.Errorf("get CDN object: %w", err) + } + if response.StatusCode != http.StatusOK { + _ = response.Body.Close() + return nil, fmt.Errorf("get CDN object: unexpected status %d", response.StatusCode) + } + contentType := response.Header.Get("Content-Type") + if contentType == "" { + contentType = defaultContentType + } + return &Object{ + Body: response.Body, + ContentLength: response.ContentLength, + ContentType: contentType, + }, nil +} diff --git a/backend/plugins/infra/storage/objectstore/local.go b/backend/plugins/infra/storage/objectstore/local.go new file mode 100644 index 00000000..3e172116 --- /dev/null +++ b/backend/plugins/infra/storage/objectstore/local.go @@ -0,0 +1,120 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package objectstore + +import ( + "context" + "fmt" + "io" + "mime" + "os" + "path/filepath" + "strings" +) + +type localBackend struct { + root string +} + +func newLocalBackend(cfg LocalConfig) (*localBackend, error) { + root := filepath.Clean(cfg.Root) + if root == "" { + return nil, fmt.Errorf("local root is required") + } + return &localBackend{root: root}, nil +} + +func (b *localBackend) Put(_ context.Context, key string, body io.Reader, _ int64, _ string) (PutResult, error) { + path, err := b.path(key) + if err != nil { + return PutResult{}, err + } + if err := os.MkdirAll(filepath.Dir(path), storageDirPerm); err != nil { + return PutResult{}, err + } + file, err := os.OpenFile( //nolint:gosec // path is constrained to the configured storage root. + path, + os.O_CREATE|os.O_TRUNC|os.O_WRONLY, + storageFilePerm, + ) + if err != nil { + return PutResult{}, err + } + if _, err := io.Copy(file, body); err != nil { + _ = file.Close() + _ = os.Remove(path) + return PutResult{}, err + } + if err := file.Close(); err != nil { + _ = os.Remove(path) + return PutResult{}, err + } + return PutResult{Key: filepath.ToSlash(key)}, nil +} + +func (b *localBackend) Get(_ context.Context, key string) (*Object, error) { + path, err := b.path(key) + if err != nil { + return nil, err + } + file, err := os.Open(path) //nolint:gosec // path is constrained to the configured storage root. + if err != nil { + return nil, err + } + info, err := file.Stat() + if err != nil { + _ = file.Close() + return nil, err + } + contentType := mime.TypeByExtension(filepath.Ext(path)) + if contentType == "" { + contentType = defaultContentType + } + return &Object{Body: file, ContentLength: info.Size(), ContentType: contentType}, nil +} + +func (b *localBackend) Delete(_ context.Context, key string) error { + path, err := b.path(key) + if err != nil { + return err + } + err = os.Remove(path) + if os.IsNotExist(err) { + return nil + } + return err +} + +func (b *localBackend) Test(_ context.Context) error { + return os.MkdirAll(b.root, storageDirPerm) +} + +func (b *localBackend) path(key string) (string, error) { + if filepath.IsAbs(key) { + cleanPath := filepath.Clean(key) + absRoot, err := filepath.Abs(b.root) + if err != nil { + return "", err + } + absPath, err := filepath.Abs(cleanPath) + if err != nil { + return "", err + } + rel, err := filepath.Rel(absRoot, absPath) + if err != nil || strings.HasPrefix(rel, "..") { + return "", fmt.Errorf("storage key escapes local root") + } + return cleanPath, nil + } + cleanKey := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(key, "/"))) + if cleanKey == "." || cleanKey == "" || strings.HasPrefix(cleanKey, "..") { + return "", fmt.Errorf("invalid local storage key %q", key) + } + path := filepath.Join(b.root, cleanKey) + rel, err := filepath.Rel(b.root, path) + if err != nil || strings.HasPrefix(rel, "..") { + return "", fmt.Errorf("storage key escapes local root") + } + return path, nil +} diff --git a/backend/plugins/infra/storage/objectstore/local_test.go b/backend/plugins/infra/storage/objectstore/local_test.go new file mode 100644 index 00000000..8525f078 --- /dev/null +++ b/backend/plugins/infra/storage/objectstore/local_test.go @@ -0,0 +1,51 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package objectstore + +import ( + "bytes" + "context" + "io" + "testing" +) + +func TestLocalBackendRoundTrip(t *testing.T) { + backend, err := newLocalBackend(LocalConfig{Root: t.TempDir()}) + if err != nil { + t.Fatalf("newLocalBackend() returned error: %v", err) + } + ctx := context.Background() + const key = "uploads/2026/06/13/test.txt" + const content = "wavelet storage" + + storedResult, err := backend.Put(ctx, key, bytes.NewBufferString(content), int64(len(content)), "text/plain") + if err != nil { + t.Fatalf("Put(%q) returned error: %v", key, err) + } + if storedResult.Key != key { + t.Errorf("Put(%q) key = %q, want %q", key, storedResult.Key, key) + } + + object, err := backend.Get(ctx, key) + if err != nil { + t.Fatalf("Get(%q) returned error: %v", key, err) + } + got, err := io.ReadAll(object.Body) + if err != nil { + t.Fatalf("ReadAll(Get(%q)) returned error: %v", key, err) + } + if err := object.Body.Close(); err != nil { + t.Fatalf("Close(Get(%q)) returned error: %v", key, err) + } + if string(got) != content { + t.Errorf("Get(%q) content = %q, want %q", key, got, content) + } + + if err := backend.Delete(ctx, key); err != nil { + t.Fatalf("Delete(%q) returned error: %v", key, err) + } + if _, err := backend.Get(ctx, key); err == nil { + t.Errorf("Get(%q) after Delete() returned nil error", key) + } +} diff --git a/backend/plugins/infra/storage/objectstore/oss.go b/backend/plugins/infra/storage/objectstore/oss.go new file mode 100644 index 00000000..356d14ef --- /dev/null +++ b/backend/plugins/infra/storage/objectstore/oss.go @@ -0,0 +1,98 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package objectstore + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss" + "github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss/credentials" +) + +type ossBackend struct { + client *oss.Client + bucket string + keyPrefix string + cdnURL string +} + +func newOSSBackend(cfg ObjectConfig) (*ossBackend, error) { + options := oss.LoadDefaultConfig(). + WithCredentialsProvider(credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey)). + WithRegion(cfg.Region) + if cfg.Endpoint != "" { + options.WithEndpoint(cfg.Endpoint) + } + return &ossBackend{ + client: oss.NewClient(options), + bucket: cfg.Bucket, + keyPrefix: strings.Trim(cfg.KeyPrefix, "/"), + cdnURL: strings.TrimRight(cfg.CDNURL, "/"), + }, nil +} + +func (b *ossBackend) Put(ctx context.Context, key string, body io.Reader, _ int64, _ string) (PutResult, error) { + key = b.key(key) + _, err := b.client.PutObject(ctx, &oss.PutObjectRequest{ + Bucket: oss.Ptr(b.bucket), + Key: oss.Ptr(key), + Body: body, + }) + if err != nil { + return PutResult{}, fmt.Errorf("put OSS object: %w", err) + } + return PutResult{Key: key, Bucket: b.bucket}, nil +} + +func (b *ossBackend) Get(ctx context.Context, key string) (*Object, error) { + key = b.key(key) + if b.cdnURL != "" { + return getHTTPObject(ctx, b.cdnURL, key) + } + output, err := b.client.GetObject(ctx, &oss.GetObjectRequest{ + Bucket: oss.Ptr(b.bucket), + Key: oss.Ptr(key), + }) + if err != nil { + return nil, fmt.Errorf("get OSS object: %w", err) + } + contentType := defaultContentType + if output.ContentType != nil { + contentType = *output.ContentType + } + return &Object{Body: output.Body, ContentLength: output.ContentLength, ContentType: contentType}, nil +} + +func (b *ossBackend) Delete(ctx context.Context, key string) error { + _, err := b.client.DeleteObject(ctx, &oss.DeleteObjectRequest{ + Bucket: oss.Ptr(b.bucket), + Key: oss.Ptr(b.key(key)), + }) + if err != nil { + return fmt.Errorf("delete OSS object: %w", err) + } + return nil +} + +func (b *ossBackend) Test(ctx context.Context) error { + ok, err := b.client.IsBucketExist(ctx, b.bucket) + if err != nil { + return fmt.Errorf("access OSS bucket: %w", err) + } + if !ok { + return fmt.Errorf("OSS bucket %q does not exist", b.bucket) + } + return nil +} + +func (b *ossBackend) key(key string) string { + key = strings.TrimLeft(key, "/") + if b.keyPrefix == "" || strings.HasPrefix(key, b.keyPrefix+"/") { + return key + } + return b.keyPrefix + "/" + key +} diff --git a/backend/plugins/infra/storage/objectstore/s3.go b/backend/plugins/infra/storage/objectstore/s3.go new file mode 100644 index 00000000..a314be48 --- /dev/null +++ b/backend/plugins/infra/storage/objectstore/s3.go @@ -0,0 +1,122 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package objectstore + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" +) + +type s3Backend struct { + client *s3.Client + bucket string + keyPrefix string + cdnURL string +} + +func newS3Backend(ctx context.Context, cfg ObjectConfig) (*s3Backend, error) { + awsCfg, err := awsconfig.LoadDefaultConfig(ctx, + awsconfig.WithRegion(cfg.Region), + awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider( + cfg.AccessKeyID, + cfg.SecretAccessKey, + "", + )), + ) + if err != nil { + return nil, fmt.Errorf("load S3 config: %w", err) + } + client := s3.NewFromConfig(awsCfg, func(options *s3.Options) { + if cfg.Endpoint != "" { + options.BaseEndpoint = aws.String(strings.TrimRight(cfg.Endpoint, "/")) + } + options.UsePathStyle = cfg.PathStyle + }) + return &s3Backend{ + client: client, + bucket: cfg.Bucket, + keyPrefix: strings.Trim(cfg.KeyPrefix, "/"), + cdnURL: strings.TrimRight(cfg.CDNURL, "/"), + }, nil +} + +func newR2Backend(ctx context.Context, cfg ObjectConfig) (*s3Backend, error) { + if cfg.Endpoint == "" { + cfg.Endpoint = fmt.Sprintf("https://%s.r2.cloudflarestorage.com", cfg.AccountID) + } + cfg.Region = "auto" + return newS3Backend(ctx, cfg) +} + +func (b *s3Backend) Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) (PutResult, error) { + key = b.key(key) + _, err := b.client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(b.bucket), + Key: aws.String(key), + Body: body, + ContentLength: aws.Int64(size), + ContentType: aws.String(contentType), + }) + if err != nil { + return PutResult{}, fmt.Errorf("put S3 object: %w", err) + } + return PutResult{Key: key, Bucket: b.bucket}, nil +} + +func (b *s3Backend) Get(ctx context.Context, key string) (*Object, error) { + key = b.key(key) + if b.cdnURL != "" { + return getHTTPObject(ctx, b.cdnURL, key) + } + output, err := b.client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(b.bucket), + Key: aws.String(key), + }) + if err != nil { + return nil, fmt.Errorf("get S3 object: %w", err) + } + contentType := defaultContentType + if output.ContentType != nil { + contentType = *output.ContentType + } + var size int64 + if output.ContentLength != nil { + size = *output.ContentLength + } + return &Object{Body: output.Body, ContentLength: size, ContentType: contentType}, nil +} + +func (b *s3Backend) Delete(ctx context.Context, key string) error { + _, err := b.client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: aws.String(b.bucket), + Key: aws.String(b.key(key)), + }) + if err != nil { + return fmt.Errorf("delete S3 object: %w", err) + } + return nil +} + +func (b *s3Backend) Test(ctx context.Context) error { + _, err := b.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(b.bucket)}) + if err != nil { + return fmt.Errorf("access S3 bucket: %w", err) + } + return nil +} + +func (b *s3Backend) key(key string) string { + key = strings.TrimLeft(key, "/") + if b.keyPrefix == "" || strings.HasPrefix(key, b.keyPrefix+"/") { + return key + } + return b.keyPrefix + "/" + key +} diff --git a/backend/plugins/infra/storage/objectstore/storage.go b/backend/plugins/infra/storage/objectstore/storage.go new file mode 100644 index 00000000..07aa4799 --- /dev/null +++ b/backend/plugins/infra/storage/objectstore/storage.go @@ -0,0 +1,212 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package objectstore + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + "sync" + "time" + + "gorm.io/gorm" +) + +const ( + defaultContentType = "application/octet-stream" + storageDirPerm = 0o750 + storageFilePerm = 0o600 +) + +// Object describes a readable stored object. +type Object struct { + Key string + CachePath string + Body io.ReadCloser + ContentLength int64 + ContentType string +} + +// PutResult describes the result of a successful Put operation. +type PutResult struct { + Key string + Bucket string +} + +// Backend defines storage operations used by the upload domain. +type Backend interface { + Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) (PutResult, error) + Get(ctx context.Context, key string) (*Object, error) + Delete(ctx context.Context, key string) error + Test(ctx context.Context) error +} + +var ( + cacheMutex sync.RWMutex + activeDriver Driver + activeBackend Backend + activeConfigJSON string + lastChecked time.Time + pubSubOnce sync.Once + + mockBackend Backend + + // IsEnabledFunc controls whether mock/in-memory backend is activated in tests. + IsEnabledFunc = func() bool { return false } +) + +// ConfigInvalidationChannel is the Redis pub/sub channel used to evict storage caches cluster-wide. +const ConfigInvalidationChannel = "storage:config_invalidation" + +// SetMockBackend forces an in-memory/mock backend for testing. +func SetMockBackend(b Backend) { + mockBackend = b +} + +// ResetCache clears cached driver and backend instances. +func ResetCache() { + cacheMutex.Lock() + defer cacheMutex.Unlock() + activeBackend = nil + activeDriver = "" + activeConfigJSON = "" + lastChecked = time.Time{} +} + +// PublishCacheInvalidation broadcasts cache eviction to all nodes in the cluster via Redis. +func PublishCacheInvalidation(ctx context.Context) { + if cache := getCache(ctx); cache != nil { + _ = cache.Invalidate(ctx, ConfigInvalidationChannel) + } + ResetCache() +} + +// startPubSubListener starts the background subscriber for cache invalidations. +func startPubSubListener() { +} + +// Active returns the configured active driver and backend, using an in-memory cache with 5s TTL. +func Active(ctx context.Context) (Driver, Backend, error) { + if IsEnabledFunc() && mockBackend != nil { + return DriverS3, mockBackend, nil + } + + pubSubOnce.Do(startPubSubListener) + + cacheMutex.RLock() + isCacheValid := time.Since(lastChecked) < 5*time.Second && activeBackend != nil + if isCacheValid { + d, b := activeDriver, activeBackend + cacheMutex.RUnlock() + return d, b, nil + } + cacheMutex.RUnlock() + + cacheMutex.Lock() + defer cacheMutex.Unlock() + + // Double-check under write lock + if time.Since(lastChecked) < 5*time.Second && activeBackend != nil { + return activeDriver, activeBackend, nil + } + + var val string + db := getDB(ctx) + if db != nil { + err := db.Table("w_system_configs").Where("key = ?", "storage_config").Pluck("value", &val).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return "", nil, err + } + } + sc := struct{ Value string }{Value: val} + + lastChecked = time.Now() + + // Reuse existing backend client singleton if configuration JSON matches + if sc.Value == activeConfigJSON && activeBackend != nil { + return activeDriver, activeBackend, nil + } + + cfg := DefaultConfig() + if strings.TrimSpace(sc.Value) != "" { + if err := json.Unmarshal([]byte(sc.Value), &cfg); err != nil { + return "", nil, fmt.Errorf("parse storage config: %w", err) + } + } + + backend, err := NewBackend(ctx, cfg, cfg.Driver) + if err != nil { + return "", nil, err + } + + activeDriver = cfg.Driver + activeBackend = backend + activeConfigJSON = sc.Value + + return activeDriver, activeBackend, nil +} + +type functionBackend struct { + put func(context.Context, string, io.Reader, int64, string) error + get func(context.Context, string) (*Object, error) + delete func(context.Context, string) error +} + +func (b *functionBackend) Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) (PutResult, error) { + if err := b.put(ctx, key, body, size, contentType); err != nil { + return PutResult{}, err + } + return PutResult{Key: key}, nil +} + +func (b *functionBackend) Get(ctx context.Context, key string) (*Object, error) { + return b.get(ctx, key) +} + +func (b *functionBackend) Delete(ctx context.Context, key string) error { + return b.delete(ctx, key) +} + +func (b *functionBackend) Test(context.Context) error { + return nil +} + +// MockStorage replaces object operations for package tests and returns a restore function. +func MockStorage( + put func(context.Context, string, io.Reader, int64, string) error, + get func(context.Context, string) (*Object, error), + deleteObject func(context.Context, string) error, +) func() { + previous := mockBackend + mockBackend = &functionBackend{put: put, get: get, delete: deleteObject} + return func() { + mockBackend = previous + } +} + +// NewBackend constructs a concrete backend from configuration. +func NewBackend(ctx context.Context, cfg Config, driver Driver) (Backend, error) { + if driver == DriverS3 && mockBackend != nil { + return mockBackend, nil + } + switch driver { + case DriverLocal: + return newLocalBackend(cfg.Local) + case DriverS3: + return newS3Backend(ctx, cfg.S3) + case DriverR2: + return newR2Backend(ctx, cfg.R2) + case DriverMinIO: + return newS3Backend(ctx, cfg.MinIO) + case DriverOSS: + return newOSSBackend(cfg.OSS) + case DriverWebDAV: + return newWebDAVBackend(cfg.WebDAV) + default: + return nil, fmt.Errorf("unsupported storage driver %q", driver) + } +} diff --git a/backend/plugins/infra/storage/objectstore/storage_test.go b/backend/plugins/infra/storage/objectstore/storage_test.go new file mode 100644 index 00000000..1e0013cd --- /dev/null +++ b/backend/plugins/infra/storage/objectstore/storage_test.go @@ -0,0 +1,144 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package objectstore + +import ( + "context" + "encoding/json" + "io" + "sync" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + + cache "Wavelet/plugins/infra/cache" +) + +func TestStorageCache(t *testing.T) { + // 1. Reset cache + ResetCache() + + if activeConfigJSON != "" || activeDriver != "" || activeBackend != nil || !lastChecked.IsZero() { + t.Fatal("ResetCache did not clear cache variables") + } + + // 2. Set up cache manually + expectedConfig := Config{ + Driver: DriverLocal, + Local: LocalConfig{Root: "/tmp/wavelet-test"}, + } + cfgJSON, err := json.Marshal(expectedConfig) + if err != nil { + t.Fatalf("Marshal config failed: %v", err) + } + + cacheMutex.Lock() + activeConfigJSON = string(cfgJSON) + lastChecked = time.Now() + cacheMutex.Unlock() + + // 3. Call LoadConfig and verify it loads from cache (doesn't hit database, which would fail/panic because DB is not initialized) + ctx := context.Background() + loadedCfg, err := LoadConfig(ctx) + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + if loadedCfg.Driver != expectedConfig.Driver || loadedCfg.Local.Root != expectedConfig.Local.Root { + t.Errorf("Loaded config %+v, expected %+v", loadedCfg, expectedConfig) + } + + // 4. Test Active() returns cached driver and backend + mockBnd := &functionBackend{ + put: func(context.Context, string, io.Reader, int64, string) error { return nil }, + get: func(context.Context, string) (*Object, error) { return nil, nil }, + delete: func(context.Context, string) error { return nil }, + } + + cacheMutex.Lock() + activeBackend = mockBnd + activeDriver = DriverLocal + cacheMutex.Unlock() + + drv, bnd, err := Active(ctx) + if err != nil { + t.Fatalf("Active failed: %v", err) + } + if drv != DriverLocal || bnd != mockBnd { + t.Errorf("Active returned driver %v, backend %v; expected %v, %v", drv, bnd, DriverLocal, mockBnd) + } + + // 5. Test ResetCache again + ResetCache() + if activeConfigJSON != "" || activeDriver != "" || activeBackend != nil || !lastChecked.IsZero() { + t.Fatal("ResetCache did not clear cache variables after setting them") + } +} + +func TestStorageCachePubSub(t *testing.T) { + // 1. Start miniredis + mr, err := miniredis.Run() + if err != nil { + t.Fatalf("Failed to run miniredis: %v", err) + } + defer mr.Close() + + // 2. Initialize Redis client + rdb := redis.NewClient(&redis.Options{ + Addr: mr.Addr(), + }) + defer rdb.Close() + + // 3. Set cache.Redis to our client + oldRedis := cache.Redis + cache.Redis = rdb + defer func() { + cache.Redis = oldRedis + }() + + // Reset cache and set some cached config + ResetCache() + cacheMutex.Lock() + activeConfigJSON = "some_config" + lastChecked = time.Now() + cacheMutex.Unlock() + + // 4. Force trigger lazy initialization of subscription + // Reset the once guard so it runs the listener + pubSubOnce = sync.Once{} + ctx := context.Background() + + // Create mock backend for Active call + mockBnd := &functionBackend{ + put: func(context.Context, string, io.Reader, int64, string) error { return nil }, + get: func(context.Context, string) (*Object, error) { return nil, nil }, + delete: func(context.Context, string) error { return nil }, + } + cacheMutex.Lock() + activeBackend = mockBnd + activeDriver = DriverLocal + cacheMutex.Unlock() + + _, _, _ = Active(ctx) // This calls startPubSubListener() + + // Allow some time for subscriber connection + time.Sleep(100 * time.Millisecond) + + // 5. Publish cache invalidation + PublishCacheInvalidation(ctx) + + // Allow message propagation + time.Sleep(100 * time.Millisecond) + + // 6. Verify cache was cleared + cacheMutex.RLock() + configJSON := activeConfigJSON + cacheMutex.RUnlock() + + if configJSON != "" { + t.Error("Memory cache was not cleared after Redis Pub/Sub broadcast") + } +} diff --git a/backend/plugins/infra/storage/objectstore/webdav.go b/backend/plugins/infra/storage/objectstore/webdav.go new file mode 100644 index 00000000..8e270101 --- /dev/null +++ b/backend/plugins/infra/storage/objectstore/webdav.go @@ -0,0 +1,102 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package objectstore + +import ( + "Wavelet/pkg/httppool" + "context" + "fmt" + "io" + "net/http" + "path" + "strings" + + "github.com/studio-b12/gowebdav" +) + +type contextTransport struct { + ctx context.Context + parent http.RoundTripper +} + +func (t *contextTransport) RoundTrip(req *http.Request) (*http.Response, error) { + return t.parent.RoundTrip(req.WithContext(t.ctx)) +} + +type webDAVBackend struct { + endpoint string + username string + password string + basePath string +} + +func newWebDAVBackend(cfg WebDAVConfig) (*webDAVBackend, error) { + return &webDAVBackend{ + endpoint: strings.TrimRight(cfg.Endpoint, "/"), + username: cfg.Username, + password: cfg.Password, + basePath: strings.Trim(cfg.BasePath, "/"), + }, nil +} + +func (b *webDAVBackend) newClient(ctx context.Context) *gowebdav.Client { + client := gowebdav.NewClient(b.endpoint, b.username, b.password) + client.SetTransport(&contextTransport{ + ctx: ctx, + parent: httppool.DefaultTransport(), + }) + return client +} + +func (b *webDAVBackend) Put(ctx context.Context, key string, body io.Reader, size int64, _ string) (PutResult, error) { + key = b.key(key) + client := b.newClient(ctx) + if dir := path.Dir(key); dir != "." && dir != "/" { + if err := client.MkdirAll(dir, storageDirPerm); err != nil { + return PutResult{}, fmt.Errorf("create WebDAV directory: %w", err) + } + } + if err := client.WriteStreamWithLength(key, body, size, storageFilePerm); err != nil { + return PutResult{}, fmt.Errorf("put WebDAV object: %w", err) + } + return PutResult{Key: key}, nil +} + +func (b *webDAVBackend) Get(ctx context.Context, key string) (*Object, error) { + key = b.key(key) + client := b.newClient(ctx) + info, err := client.Stat(key) + if err != nil { + return nil, fmt.Errorf("stat WebDAV object: %w", err) + } + body, err := client.ReadStream(key) + if err != nil { + return nil, fmt.Errorf("get WebDAV object: %w", err) + } + contentType := defaultContentType + if typed, ok := info.(interface{ ContentType() string }); ok && typed.ContentType() != "" { + contentType = typed.ContentType() + } + return &Object{Body: body, ContentLength: info.Size(), ContentType: contentType}, nil +} + +func (b *webDAVBackend) Delete(ctx context.Context, key string) error { + client := b.newClient(ctx) + if err := client.Remove(b.key(key)); err != nil { + return fmt.Errorf("delete WebDAV object: %w", err) + } + return nil +} + +func (b *webDAVBackend) Test(ctx context.Context) error { + client := b.newClient(ctx) + if err := client.Connect(); err != nil { + return fmt.Errorf("connect WebDAV: %w", err) + } + return nil +} + +func (b *webDAVBackend) key(key string) string { + return "/" + path.Join(b.basePath, strings.TrimLeft(key, "/")) +} diff --git a/backend/plugins/infra/storage/plugin.go b/backend/plugins/infra/storage/plugin.go new file mode 100644 index 00000000..3cd105af --- /dev/null +++ b/backend/plugins/infra/storage/plugin.go @@ -0,0 +1,147 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package storage provides the object storage and ingestion infrastructure plugin for Cordis. +package storage + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "Wavelet/plugins/infra/storage/diskcache" + "Wavelet/plugins/infra/storage/objectstore" + "context" + "errors" + "fmt" + "io" +) + +// Option configures the storage plugin. +type Option func(*Plugin) + +// WithBackend sets an explicit storage backend instance (useful for testing or custom engines). +func WithBackend(b objectstore.Backend) Option { + return func(p *Plugin) { + p.backend = b + } +} + +// Plugin implements core.Plugin to provide contracts.StorageService. +type Plugin struct { + backend objectstore.Backend +} + +// New creates a new storage infrastructure plugin. +func New(opts ...Option) *Plugin { + p := &Plugin{} + for _, opt := range opts { + if opt != nil { + opt(p) + } + } + return p +} + +// Name returns the unique identifier of the storage plugin. +func (p *Plugin) Name() string { + return "storage" +} + +// Apply mounts the storage service into the Context. +func (p *Plugin) Apply(ctx *core.Context) error { + // Bind DBService + if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil { + objectstore.SetDBService(db) + diskcache.SetDBService(db) + } else { + core.When[contracts.DBService](ctx, func(db contracts.DBService) { + objectstore.SetDBService(db) + diskcache.SetDBService(db) + }) + } + + // Bind CacheService + if cache, err := core.Inject[contracts.CacheService](ctx); err == nil && cache != nil { + objectstore.SetCacheService(cache) + } else { + core.When[contracts.CacheService](ctx, func(cache contracts.CacheService) { + objectstore.SetCacheService(cache) + }) + } + + ctx.OnDispose(func() error { + objectstore.SetDBService(nil) + diskcache.SetDBService(nil) + objectstore.SetCacheService(nil) + return nil + }) + + svc := &storageServiceImpl{ + backend: p.backend, + } + core.Provide[contracts.StorageService](ctx, svc) + return nil +} + +type storageServiceImpl struct { + backend objectstore.Backend +} + +func (s *storageServiceImpl) getBackend(ctx context.Context) (objectstore.Backend, error) { + if s.backend != nil { + return s.backend, nil + } + _, b, err := objectstore.Active(ctx) + if err != nil { + return nil, fmt.Errorf("storage: get active backend failed: %w", err) + } + return b, nil +} + +func (s *storageServiceImpl) Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) (contracts.StoragePutResult, error) { + b, err := s.getBackend(ctx) + if err != nil { + return contracts.StoragePutResult{}, err + } + + res, err := b.Put(ctx, key, body, size, contentType) + if err != nil { + return contracts.StoragePutResult{}, err + } + + return contracts.StoragePutResult{ + Key: res.Key, + Bucket: res.Bucket, + }, nil +} + +func (s *storageServiceImpl) Get(ctx context.Context, key string) (*contracts.StorageObject, error) { + b, err := s.getBackend(ctx) + if err != nil { + return nil, err + } + + obj, err := b.Get(ctx, key) + if err != nil { + return nil, err + } + + return &contracts.StorageObject{ + Key: key, + CachePath: obj.CachePath, + Body: obj.Body, + ContentLength: obj.ContentLength, + ContentType: obj.ContentType, + }, nil +} + +func (s *storageServiceImpl) Delete(ctx context.Context, key string) error { + b, err := s.getBackend(ctx) + if err != nil { + return err + } + return b.Delete(ctx, key) +} + +func (s *storageServiceImpl) Ingest(_ context.Context, _ io.Reader, _ contracts.IngestOptions) (*contracts.IngestResult, error) { + return nil, errors.New("storage: programmatic ingest is managed by domain/upload plugin") +} diff --git a/backend/plugins/infra/storage/plugin_test.go b/backend/plugins/infra/storage/plugin_test.go new file mode 100644 index 00000000..ebb54a20 --- /dev/null +++ b/backend/plugins/infra/storage/plugin_test.go @@ -0,0 +1,99 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package storage_test + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "Wavelet/plugins/infra/storage" + "Wavelet/plugins/infra/storage/objectstore" + "bytes" + "context" + "errors" + "io" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockStorageBackend struct { + mu sync.RWMutex + files map[string][]byte +} + +func newMockStorageBackend() *mockStorageBackend { + return &mockStorageBackend{files: make(map[string][]byte)} +} + +func (m *mockStorageBackend) Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) (objectstore.PutResult, error) { + m.mu.Lock() + defer m.mu.Unlock() + data, err := io.ReadAll(body) + if err != nil { + return objectstore.PutResult{}, err + } + m.files[key] = data + return objectstore.PutResult{Key: key, Bucket: "mock-bucket"}, nil +} + +func (m *mockStorageBackend) Get(ctx context.Context, key string) (*objectstore.Object, error) { + m.mu.RLock() + defer m.mu.RUnlock() + data, ok := m.files[key] + if !ok { + return nil, errors.New("file not found") + } + return &objectstore.Object{ + Body: io.NopCloser(bytes.NewReader(data)), + ContentLength: int64(len(data)), + ContentType: "application/octet-stream", + }, nil +} + +func (m *mockStorageBackend) Delete(ctx context.Context, key string) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.files, key) + return nil +} + +func (m *mockStorageBackend) Test(ctx context.Context) error { + return nil +} + +func TestStoragePluginOperations(t *testing.T) { + backend := newMockStorageBackend() + p := storage.New(storage.WithBackend(backend)) + assert.Equal(t, "storage", p.Name()) + + ctx := core.NewContext(context.Background()) + require.NoError(t, p.Apply(ctx)) + + svc, err := core.Inject[contracts.StorageService](ctx) + require.NoError(t, err) + require.NotNil(t, svc) + + testCtx := context.Background() + + // Put + content := []byte("wavelet storage content") + putRes, err := svc.Put(testCtx, "avatar/user1.png", bytes.NewReader(content), int64(len(content)), "image/png") + require.NoError(t, err) + assert.Equal(t, "avatar/user1.png", putRes.Key) + assert.Equal(t, "mock-bucket", putRes.Bucket) + + // Get + obj, err := svc.Get(testCtx, "avatar/user1.png") + require.NoError(t, err) + data, err := io.ReadAll(obj.Body) + require.NoError(t, err) + assert.Equal(t, content, data) + + // Delete + require.NoError(t, svc.Delete(testCtx, "avatar/user1.png")) + _, err = svc.Get(testCtx, "avatar/user1.png") + assert.Error(t, err) +} diff --git a/cmd/agent/main.go b/cmd/agent/main.go deleted file mode 100644 index 5c6842ee..00000000 --- a/cmd/agent/main.go +++ /dev/null @@ -1,156 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Command agent runs the OpenFlare edge agent daemon. -package main - -import ( - "context" - "errors" - "flag" - "log/slog" - "os" - "os/signal" - "syscall" - - "github.com/Rain-kl/Wavelet/internal/apps/agent/agent" - "github.com/Rain-kl/Wavelet/internal/apps/agent/config" - "github.com/Rain-kl/Wavelet/internal/apps/agent/geoipupdate" - "github.com/Rain-kl/Wavelet/internal/apps/agent/heartbeat" - "github.com/Rain-kl/Wavelet/internal/apps/agent/httpclient" - "github.com/Rain-kl/Wavelet/internal/apps/agent/logging" - "github.com/Rain-kl/Wavelet/internal/apps/agent/nginx" - "github.com/Rain-kl/Wavelet/internal/apps/agent/runtimeuser" - "github.com/Rain-kl/Wavelet/internal/apps/agent/state" - syncservice "github.com/Rain-kl/Wavelet/internal/apps/agent/sync" - "github.com/Rain-kl/Wavelet/internal/apps/agent/updater" - "github.com/Rain-kl/Wavelet/internal/apps/agent/wsclient" -) - -func main() { - logging.Setup() - - configPath := flag.String("config", "./agent.json", "agent config path") - flag.Parse() - - cfg, err := config.Load(*configPath) - if err != nil { - slog.Error("load agent config failed", "error", err) - os.Exit(1) - } - if err = runtimeuser.EnsureProcessUser(); err != nil { - slog.Error("ensure runtime user failed", "error", err) - os.Exit(1) - } - if err = runtimeuser.EnsurePathOwnership(cfg.DataDir, runtimeuser.DefaultDirPerm, runtimeuser.DefaultFilePerm); err != nil { - slog.Error("ensure data dir ownership failed", "error", err, "data_dir", cfg.DataDir) - os.Exit(1) - } - cfg.ExtVersion = nginx.DetectVersion( - context.Background(), - nginx.ExecutorOptions{ - NginxPath: cfg.OpenrestyPath, - MainConfigPath: cfg.MainConfigPath, - RouteConfigPath: cfg.RouteConfigPath, - CertDir: cfg.CertDir, - NginxCertDir: cfg.OpenrestyCertDir, - LuaDir: cfg.LuaDir, - NginxLuaDir: cfg.OpenrestyLuaDir, - OpenrestyObservabilityPort: cfg.OpenrestyObservabilityPort, - }, - ) - slog.Info("agent config loaded", - "server", cfg.ServerURL, - "node", cfg.NodeName, - "ip", cfg.NodeIP, - "heartbeat_interval", cfg.HeartbeatInterval, - "route_config", cfg.RouteConfigPath, - "access_log", cfg.AccessLogPath, - "cert_dir", cfg.CertDir, - "lua_dir", cfg.LuaDir, - "runtime_config_dir", cfg.RuntimeConfigDir, - "mmdb_path", cfg.MMDBPath, - "city_mmdb_path", cfg.CityMMDBPath, - ) - - client := httpclient.New(cfg.ServerURL, cfg.InitialAuthToken(), cfg.RequestTimeout.Duration()) - wsClient := wsclient.New(cfg.ServerURL, cfg.InitialAuthToken(), cfg.RequestTimeout.Duration()) - stateStore := state.NewStore(cfg.StatePath) - observabilityBuffer := state.NewObservabilityBufferStore(cfg.ObservabilityBufferPath) - runtimeManager := &nginx.Manager{ - MainConfigPath: cfg.MainConfigPath, - RouteConfigPath: cfg.RouteConfigPath, - AccessLogPath: cfg.AccessLogPath, - CertDir: cfg.CertDir, - NginxCertDir: cfg.OpenrestyCertDir, - LuaDir: cfg.LuaDir, - NginxLuaDir: cfg.OpenrestyLuaDir, - RuntimeConfigDir: cfg.RuntimeConfigDir, - MMDBPath: cfg.MMDBPath, - CityMMDBPath: cfg.CityMMDBPath, - PagesDir: cfg.PagesDir, - OpenrestyObservabilityListen: nginx.ObservabilityListenAddress(cfg.OpenrestyObservabilityPort), - OpenrestyObservabilityPort: cfg.OpenrestyObservabilityPort, - OpenrestyResolverDirective: "", - Executor: nginx.NewExecutor(nginx.ExecutorOptions{ - NginxPath: cfg.OpenrestyPath, - MainConfigPath: cfg.MainConfigPath, - RouteConfigPath: cfg.RouteConfigPath, - CertDir: cfg.CertDir, - NginxCertDir: cfg.OpenrestyCertDir, - LuaDir: cfg.LuaDir, - NginxLuaDir: cfg.OpenrestyLuaDir, - OpenrestyObservabilityPort: cfg.OpenrestyObservabilityPort, - }), - } - if err = runtimeManager.EnsureLuaAssets(); err != nil { - slog.Error("ensure managed lua assets failed", "error", err) - os.Exit(1) - } - syncService := syncservice.New(client, runtimeManager, stateStore) - syncService.SetPagesDir(cfg.PagesDir) - heartbeatService := heartbeat.New(client) - updateService := updater.New() - runner := &agent.Runner{ - Config: cfg, - StateStore: stateStore, - HeartbeatCycle: &heartbeat.Cycle{ - Config: cfg, - StateStore: stateStore, - ObservabilityBuffer: observabilityBuffer, - Heartbeat: heartbeatService, - Sync: syncService, - Updater: updateService, - }, - HeartbeatService: heartbeatService, - SyncService: syncService, - RuntimeManager: runtimeManager, - WebSocketService: wsClient, - } - - ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - geoIPUpdater := newGeoIPUpdater(cfg) - if err = geoIPUpdater.EnsureInitialDatabases(ctx); err != nil { - slog.Warn("failed to prepare GeoIP databases before agent startup", "error", err) - } - go geoIPUpdater.Run(ctx) - slog.Info("agent process started") - - if err = runner.Run(ctx); err != nil && !errors.Is(err, context.Canceled) { - slog.Error("agent process exited with error", "error", err) - stop() - os.Exit(1) - } - stop() - slog.Info("agent process stopped") -} - -func newGeoIPUpdater(cfg *config.Config) *geoipupdate.Updater { - return &geoipupdate.Updater{ - MMDBPath: cfg.MMDBPath, - DownloadURL: cfg.MMDBDownloadURL, - CityMMDBPath: cfg.CityMMDBPath, - CityDownloadURL: cfg.CityMMDBDownloadURL, - UpdateInterval: cfg.MMDBUpdateInterval.Duration(), - } -} diff --git a/cmd/agent/main_test.go b/cmd/agent/main_test.go deleted file mode 100644 index 98a34474..00000000 --- a/cmd/agent/main_test.go +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "testing" - "time" - - "github.com/Rain-kl/Wavelet/internal/apps/agent/config" -) - -func TestNewGeoIPUpdaterWiresCountryAndCity(t *testing.T) { - cfg := &config.Config{ - MMDBPath: "/data/GeoLite2-Country.mmdb", - MMDBDownloadURL: "https://geo.example/GeoLite2-Country.mmdb", - CityMMDBPath: "/data/GeoLite2-City.mmdb", - CityMMDBDownloadURL: "https://geo.example/GeoLite2-City.mmdb", - MMDBUpdateInterval: config.MillisecondDuration(time.Hour), - } - updater := newGeoIPUpdater(cfg) - if updater.MMDBPath != cfg.MMDBPath || updater.DownloadURL != cfg.MMDBDownloadURL || - updater.CityMMDBPath != cfg.CityMMDBPath || updater.CityDownloadURL != cfg.CityMMDBDownloadURL || - updater.UpdateInterval != time.Hour { - t.Fatalf("GeoIP updater wiring incomplete: %#v", updater) - } -} diff --git a/cmd/flared/main.go b/cmd/flared/main.go deleted file mode 100644 index 7425e14c..00000000 --- a/cmd/flared/main.go +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Command flared runs the OpenFlare tunnel client daemon. -package main - -import ( - "context" - "errors" - "flag" - "log/slog" - "os" - "os/signal" - "syscall" - - edgelogging "github.com/Rain-kl/Wavelet/internal/apps/edge/logging" - "github.com/Rain-kl/Wavelet/internal/apps/flared/config" - "github.com/Rain-kl/Wavelet/internal/apps/flared/flared" - "github.com/Rain-kl/Wavelet/internal/apps/flared/frpc" - "github.com/Rain-kl/Wavelet/internal/apps/flared/heartbeat" - "github.com/Rain-kl/Wavelet/internal/apps/flared/httpclient" - "github.com/Rain-kl/Wavelet/internal/apps/flared/sync" - "github.com/Rain-kl/Wavelet/internal/apps/flared/wsclient" -) - -func main() { - edgelogging.Setup(edgelogging.Options{}) - - configPath := flag.String("config", "./flared.json", "flared config path") - flag.Parse() - - cfg, err := config.Load(*configPath) - if err != nil { - slog.Error("load flared config failed", "error", err) - os.Exit(1) - } - - slog.Info("flared config loaded", - "server", cfg.ServerURL, - "frpc_path", cfg.FrpcPath, - "data_dir", cfg.DataDir, - "heartbeat_interval", cfg.HeartbeatInterval, - "sync_interval", cfg.SyncInterval, - ) - - frpcManager := frpc.NewManager(cfg) - _ = frpcManager.LoadState() - - slog.Info("detected frpc version", "version", frpcManager.GetVersion(context.Background())) - - httpClient := httpclient.New(cfg.ServerURL, cfg.InitialAuthToken(), cfg.RequestTimeout.Duration()) - wsClient := wsclient.New(cfg.ServerURL, cfg.InitialAuthToken(), cfg.RequestTimeout.Duration()) - - syncService := sync.New(httpClient, frpcManager, cfg) - heartbeatService := heartbeat.New(httpClient, frpcManager, cfg) - - runner := &flared.Runner{ - Config: cfg, - FrpcManager: frpcManager, - HTTPClient: httpClient, - WebSocketService: wsClient, - HeartbeatService: heartbeatService, - SyncService: syncService, - } - - ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - - slog.Info("flared process started") - - if err := runner.Run(ctx); err != nil && !errors.Is(err, context.Canceled) { - slog.Error("flared process exited with error", "error", err) - stop() - os.Exit(1) - } - stop() - slog.Info("flared process stopped") -} diff --git a/cmd/relay/main.go b/cmd/relay/main.go deleted file mode 100644 index d180cf00..00000000 --- a/cmd/relay/main.go +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Command relay runs the OpenFlare relay node daemon. -package main - -import ( - "context" - "errors" - "flag" - "log/slog" - "os" - "os/signal" - "syscall" - - edgelogging "github.com/Rain-kl/Wavelet/internal/apps/edge/logging" - "github.com/Rain-kl/Wavelet/internal/apps/relay/config" - "github.com/Rain-kl/Wavelet/internal/apps/relay/frps" - "github.com/Rain-kl/Wavelet/internal/apps/relay/heartbeat" - "github.com/Rain-kl/Wavelet/internal/apps/relay/httpclient" - "github.com/Rain-kl/Wavelet/internal/apps/relay/relay" - "github.com/Rain-kl/Wavelet/internal/apps/relay/state" - "github.com/Rain-kl/Wavelet/internal/apps/relay/wsclient" -) - -func main() { - edgelogging.Setup(edgelogging.Options{}) - - configPath := flag.String("config", "./relay.json", "relay config path") - flag.Parse() - - cfg, err := config.Load(*configPath) - if err != nil { - slog.Error("load relay config failed", "error", err) - os.Exit(1) - } - - slog.Info("relay config loaded", - "server", cfg.ServerURL, - "node", cfg.NodeName, - "ip", cfg.NodeIP, - "frps_path", cfg.FrpsPath, - "data_dir", cfg.DataDir, - "heartbeat_interval", cfg.HeartbeatInterval, - ) - - stateStore := state.NewStore(cfg.StatePath) - _ = stateStore // In the future we may use stateStore for auth caching - - frpsManager := frps.NewManager(cfg.FrpsPath, cfg.DataDir, cfg.InitialAuthToken()) - - slog.Info("detected frps version", "version", frpsManager.GetVersion(context.Background())) - - httpClient := httpclient.New(cfg.ServerURL, cfg.InitialAuthToken(), cfg.RequestTimeout.Duration()) - wsClient := wsclient.New(cfg.ServerURL, cfg.InitialAuthToken(), cfg.RequestTimeout.Duration()) - - runner := &relay.Runner{ - Config: cfg, - StateStore: stateStore, - FrpsManager: frpsManager, - HTTPClient: httpClient, - WebSocketService: wsClient, - HeartbeatService: heartbeat.New(httpClient, frpsManager, cfg, stateStore), - } - - ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - - slog.Info("relay process started") - - if err := runner.Run(ctx); err != nil && !errors.Is(err, context.Canceled) { - slog.Error("relay process exited with error", "error", err) - stop() - os.Exit(1) - } - stop() - slog.Info("relay process stopped") -} diff --git a/docker/Dockerfile b/docker/Dockerfile index 29247e2c..67ec552d 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -36,16 +36,16 @@ RUN apk add --no-cache ca-certificates git WORKDIR /workspace -COPY go.mod go.sum ./ +COPY backend/go.mod backend/go.sum ./ RUN go mod download -COPY . . +COPY backend/ ./ COPY --from=frontend-builder /workspace/frontend/out ./internal/router/root/dist RUN CGO_ENABLED=0 GOOS=linux go build \ -tags embed_frontend \ -trimpath \ - -ldflags="-s -w -X github.com/Rain-kl/Wavelet/internal/buildinfo.Version=${VERSION} -X github.com/Rain-kl/Wavelet/internal/buildinfo.BuildTime=${BUILD_DATE}" \ + -ldflags="-s -w -X Wavelet/pkg/buildinfo.Version=${VERSION} -X Wavelet/pkg/buildinfo.BuildTime=${BUILD_DATE}" \ -o /out/openflare-server \ ./main.go diff --git a/docker/Dockerfile.agent b/docker/Dockerfile.agent index f9c37e8d..76f37491 100644 --- a/docker/Dockerfile.agent +++ b/docker/Dockerfile.agent @@ -13,14 +13,14 @@ ENV CGO_ENABLED=0 \ GOARCH=${TARGETARCH} WORKDIR /build -COPY go.mod go.sum ./ +COPY backend/go.mod backend/go.sum ./ RUN --mount=type=cache,target=/go/pkg/mod \ go mod download -COPY . . +COPY backend/ ./ RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ - go build -trimpath -ldflags "-s -w -X 'github.com/Rain-kl/Wavelet/internal/apps/agent/config.Version=$VERSION'" -o /build/bin/openflare-agent ./cmd/agent/main.go + go build -trimpath -ldflags "-s -w -X 'Wavelet/OpenFlare/plugins/agent/config.Version=$VERSION'" -o /build/bin/openflare-agent ./cmd/agent/main.go # Fetch MMDB into dist/geoip for COPY into the runtime image (not go:embed). RUN apk add --no-cache bash curl \ diff --git a/docker/Dockerfile.backend b/docker/Dockerfile.backend index 0757780c..d3eb807b 100644 --- a/docker/Dockerfile.backend +++ b/docker/Dockerfile.backend @@ -12,14 +12,14 @@ RUN apk add --no-cache ca-certificates git WORKDIR /workspace -COPY go.mod go.sum ./ +COPY backend/go.mod backend/go.sum ./ RUN go mod download -COPY . . +COPY backend/ ./ RUN CGO_ENABLED=0 GOOS=linux go build \ -trimpath \ - -ldflags="-s -w -X github.com/Rain-kl/Wavelet/internal/buildinfo.Version=${VERSION} -X github.com/Rain-kl/Wavelet/internal/buildinfo.BuildTime=${BUILD_DATE}" \ + -ldflags="-s -w -X Wavelet/pkg/buildinfo.Version=${VERSION} -X Wavelet/pkg/buildinfo.BuildTime=${BUILD_DATE}" \ -o /out/openflare-server \ ./main.go diff --git a/docker/Dockerfile.cross b/docker/Dockerfile.cross index df13ea4c..bf572ecc 100644 --- a/docker/Dockerfile.cross +++ b/docker/Dockerfile.cross @@ -66,10 +66,10 @@ RUN apk add --no-cache ca-certificates git WORKDIR /workspace # Cache dependency layer -COPY go.mod go.sum ./ +COPY backend/go.mod backend/go.sum ./ RUN go mod download -COPY . . +COPY backend/ ./ # Overlay the compiled frontend into the embed path COPY --from=frontend-builder /workspace/frontend/out ./internal/router/root/dist @@ -96,7 +96,7 @@ RUN set -e; \ go build \ -tags embed_frontend \ -trimpath \ - -ldflags="-s -w -X github.com/Rain-kl/Wavelet/internal/buildinfo.Version=${VERSION} -X github.com/Rain-kl/Wavelet/internal/buildinfo.BuildTime=${BUILD_DATE}" \ + -ldflags="-s -w -X Wavelet/pkg/buildinfo.Version=${VERSION} -X Wavelet/pkg/buildinfo.BuildTime=${BUILD_DATE}" \ -o "${OUTPUT}" \ ./main.go; \ done; \ diff --git a/docker/Dockerfile.flared b/docker/Dockerfile.flared index a8f82b6d..21287f4a 100644 --- a/docker/Dockerfile.flared +++ b/docker/Dockerfile.flared @@ -6,14 +6,14 @@ FROM golang:1.25-alpine AS builder ARG VERSION WORKDIR /build -COPY go.mod go.sum ./ +COPY backend/go.mod backend/go.sum ./ RUN --mount=type=cache,target=/go/pkg/mod \ go mod download -COPY . . +COPY backend/ ./ RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ - CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-s -w -X 'github.com/Rain-kl/Wavelet/internal/apps/flared/config.Version=$VERSION'" -o flared ./cmd/flared/main.go + CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-s -w -X 'Wavelet/OpenFlare/plugins/flared/config.Version=$VERSION'" -o flared ./cmd/flared/main.go # Final runtime image FROM fatedier/frpc:v0.69.0 diff --git a/docker/Dockerfile.relay b/docker/Dockerfile.relay index 3c2fb2c0..b37cc058 100644 --- a/docker/Dockerfile.relay +++ b/docker/Dockerfile.relay @@ -6,14 +6,14 @@ FROM golang:1.25-alpine AS builder ARG VERSION WORKDIR /build -COPY go.mod go.sum ./ +COPY backend/go.mod backend/go.sum ./ RUN --mount=type=cache,target=/go/pkg/mod \ go mod download -COPY . . +COPY backend/ ./ RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ - CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-s -w -X 'github.com/Rain-kl/Wavelet/internal/apps/relay/config.Version=$VERSION'" -o /build/bin/openflare-relay ./cmd/relay/main.go + CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-s -w -X 'Wavelet/OpenFlare/plugins/relay/config.Version=$VERSION'" -o /build/bin/openflare-relay ./cmd/relay/main.go # Final runtime image FROM fatedier/frps:v0.69.0 diff --git a/docs/changelog/index.md b/docs/changelog/index.md index a36c31aa..cf802657 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -10,6 +10,14 @@ sidebar: false ## [Unreleased] +### 💄 其他/体验 + +- 后端代码整体迁入 `backend/`,与上游 Wavelet 的仓库布局对齐(Cordis 插件化改造第一阶段),模块名保持 `Wavelet` 以保证上游包路径逐字一致。构建、测试、镜像与发布链路已同步调整,`make build-all` / `make dev` / `make swagger` 等本地命令用法不变;HTTP 接口与控制台行为均无变化。 +- 引入上游 Cordis 微内核与平台插件到 `backend/{core,pkg,plugins}`(与上游逐字一致,可用 `scripts/sync-upstream.sh` 重复同步),并新增 `backend/OpenFlare/share/` 承载多插件共享资源(控制消息协议、GeoIP、边缘日志)。此阶段仅落位结构与共享层,尚未改变运行时行为。 +- 下游代码按功能职责拆为 `server`/`agent`/`relay`/`flared` 四个插件与 `backend/OpenFlare/share` 共享层;三个边缘守护进程改由 Cordis 内核装配启动(profile `agent`/`relay`/`flared`),`-config` 旗标、默认配置路径、退出码与启动日志保持原样。 +- 控制台 API 改由 Cordis 内核提供服务:`server` 插件以声明式路由注册,HTTP 监听与优雅退出交给内核的 http 驱动。全部 256 条路由(含 20 条带尾部斜杠的历史列表接口)与改造前逐条一致,232 条对外 API 操作无变化。 + +- 清理与上游 Wavelet 重复的平台实现:响应封装、日志、邮件、链路追踪、HTTP 连接池、内存/磁盘缓存、批量写入等 8 个本地副本删除并改为使用上游能力(约 600 行重复代码消失,接口形状与文档定义完全一致);顺带把磁盘缓存的类型断言健壮性修复回流上游。 ## [v3.5.4] - 2026-08-29 ### ✨ 新功能 diff --git a/docs/design/architecture.md b/docs/design/architecture.md index c63c5e0a..df96c92f 100644 --- a/docs/design/architecture.md +++ b/docs/design/architecture.md @@ -78,7 +78,7 @@ OpenResty (Agent, TLS/WAF) ## 组件架构与分工 ### 1. Server (控制面) -仓库根目录的 Go 后端(模块 `github.com/Rain-kl/Wavelet`)是 OpenFlare 控制面,基于 Wavelet 全栈脚手架构建: +仓库根目录的 Go 后端(模块 `OpenFlare`)是 OpenFlare 控制面,基于 Wavelet 全栈脚手架构建: * 提供管理端 REST API(`/api/v1/d/*`),通过 **Session Cookie** 鉴权,可选 `X-Access-Token` 访问令牌。 * 边缘节点协议走 `/api/v1/agent|relay|tunnel/*`,分别使用 `X-Agent-Token` / `X-Tunnel-Token` 鉴权。 * 包含配置编译器(Compiler),将数据库中的规则、证书与全局参数统一编译为不可变的配置快照及 OpenResty 物理配置文件文本。 diff --git a/docs/design/index.md b/docs/design/index.md index 365d7f98..654ffe3d 100644 --- a/docs/design/index.md +++ b/docs/design/index.md @@ -70,7 +70,7 @@ OpenFlare 适合需要统一管理多台 OpenResty 代理节点的团队,具 ## 仓库结构 -OpenFlare 已收敛为**单 monorepo**(Go 模块 `github.com/Rain-kl/Wavelet`)。控制面 Server 与边缘组件(Agent、Relay、OpenFlared)共享同一仓库,业务代码按 Wavelet `internal/apps/` 领域模块组织。 +OpenFlare 已收敛为**单 monorepo**(Go 模块 `OpenFlare`)。控制面 Server 与边缘组件(Agent、Relay、OpenFlared)共享同一仓库,业务代码按 Wavelet `internal/apps/` 领域模块组织。 在贡献代码时,请严格遵守以下物理分层与目录分工: diff --git a/docs/en/design/architecture.md b/docs/en/design/architecture.md index 6d0e8942..4d99044b 100644 --- a/docs/en/design/architecture.md +++ b/docs/en/design/architecture.md @@ -78,7 +78,7 @@ OpenResty (Agent, TLS/WAF) ## Component Architecture and Division ### 1. Server (control plane) -The Go backend at the repo root (module `github.com/Rain-kl/Wavelet`) is the OpenFlare control plane, built on the Wavelet full-stack scaffold: +The Go backend at the repo root (module `OpenFlare`) is the OpenFlare control plane, built on the Wavelet full-stack scaffold: * Provides admin REST APIs (`/api/v1/d/*`) authenticated via **Session Cookie**, with optional `X-Access-Token`. * Edge node protocols go through `/api/v1/agent|relay|tunnel/*`, authenticated with `X-Agent-Token` / `X-Tunnel-Token` respectively. * Contains the config Compiler, uniformly compiling DB rules, certs, and global params into immutable config snapshots and OpenResty physical config file text. diff --git a/docs/en/design/index.md b/docs/en/design/index.md index 352a551f..37551a59 100644 --- a/docs/en/design/index.md +++ b/docs/en/design/index.md @@ -70,7 +70,7 @@ When developing and contributing code, **you must strictly follow** these busine ## Repository Structure -OpenFlare has converged to a **single monorepo** (Go module `github.com/Rain-kl/Wavelet`). The control-plane Server and edge components (Agent, Relay, OpenFlared) share the repo, organized by Wavelet `internal/apps/` domain modules. +OpenFlare has converged to a **single monorepo** (Go module `OpenFlare`). The control-plane Server and edge components (Agent, Relay, OpenFlared) share the repo, organized by Wavelet `internal/apps/` domain modules. When contributing code, strictly follow this physical layering and directory division: diff --git a/docs/superpowers/plans/2026-08-29-openflare-cordis-foundation.md b/docs/superpowers/plans/2026-08-29-openflare-cordis-foundation.md new file mode 100644 index 00000000..a5e2b7ba --- /dev/null +++ b/docs/superpowers/plans/2026-08-29-openflare-cordis-foundation.md @@ -0,0 +1,620 @@ +# OpenFlare Cordis 基础改造实施计划(P0–P3) + +> **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:** 把 OpenFlare 后端迁入 `backend/` 双 module 结构、以 vendoring 方式引入 Wavelet Cordis 上游与 `share` 共享层,并在不改动任何已部署数据库语义的前提下完成 per-plugin 迁移与版本 stamp 桥接。 + +**Architecture:** `backend/`(module `OpenFlare`)持有 4 个业务插件与装配根;`backend/Wavelet/`(module `Wavelet`)为上游原样拷贝,`replace` + `go.work` 实现单命令构建。迁移引擎自持 `w_schema_versions(plugin_id, version_id)`,历史 76 链以 plugin id `openflare/legacy` 保真注册,桥接逐行搬运 `goose_db_version` 状态,并保留 Go 侧 zone 导入钩子。 + +**Tech Stack:** Go 1.25、Gin、GORM、PostgreSQL/SQLite、ClickHouse(独立链,不动)、goose v3、Cobra、Viper。 + +**规格来源:** `docs/superpowers/specs/2026-08-29-openflare-cordis-refactor-design.md` + +**后续计划:** P4(server 插件)、P5(agent/relay/flared 插件)、P6(平台分叉回流)、P7/P8(工具链与加固)各出独立 plan,依赖本 plan 产出的骨架与门禁。 + +--- + +## 进度与布局修正(2026-08-29 执行中更新) + +**已完成** +- Task 1 基线:`docs/superpowers/specs/baseline/{schema-A.sql,versions-A.txt,routes.txt}`,由 `backend/OpenFlare/infra/persistence/migrator/legacy_dump_test.go` 产出(`OF_DUMP_SCHEMA` / `OF_DUMP_VERSIONS` 触发,A 路 43 表、版本止于 `202608090003`)。 +- Task 2 布局:Go 树已入 `backend/`(提交 `f6dd7988`)。 + +**Task 3 / Task 4 作废原命令,改为**:与 Wavelet **同构的单 module**(提交 `9bf0b2de`)——`backend/{core,pkg,plugins}` 为上游拷贝,下游业务整体位于 `backend/OpenFlare/`(占据上游 `backend/downstream` 的位置),模块名保持 `Wavelet`。因此不再有 `go.work`、`replace`、第二 go.mod。 + +- Task 3 剩余动作:仅需 `scripts/sync-upstream.sh`(覆盖 `backend/{core,pkg,plugins}`,排除 `share`、`OpenFlare`)与上游 `scripts/check_cordis_architecture.sh` 接入。 +- Task 4 改为:新建 `backend/share/`(import `Wavelet/share/...`)并写入所有权声明;把跨插件共享资源 `pkg/{protocol,geoip,wsclient}`、`apps/edge/logging` 移入;退役与上游重复的 `OpenFlare/pkg/{logger,trace,mail,httppool}` 与 `OpenFlare/{buildinfo,testhelper}`(已核实上游覆盖除 `util` 与 `testhelper.SetupLogStoresForTest` 外的全部符号);`OpenFlare/pkg/util` 的 8 个仅存函数与 `IdentifiableTimeRecord`/`VersionInfo` 合并进上游 `pkg/util`。 +- 已实测门禁:`go build ./...` 通过、`go test ./...` exit 0(142 包 ok)、`make swagger` exit 0(232 条操作与基线逐条一致)、`make build-all` 四进制产出、`frontend/` 零改动。 +- 遗留待办:swaggo 暂 `--exclude plugins,core`,Task 4/P4 需连带解除该排除。 + +## 关键事实(执行前必读,均已核实) + +- 历史链:`internal/infra/persistence/migrator/goose/{postgres,sqlite}` 各 76 个文件,终版 `202608090003_add_log_indexes.sql`;版本表为 goose 默认 `goose_db_version`。ClickHouse 链独立:`goose/clickhouse` 14 文件 + `goose_clickhouse_version`。 +- **历史链含 Go 侧数据迁移**:`migrator.Migrate()`(`internal/infra/persistence/migrator/migrator.go:91-103`)先 `goose.UpTo(202607120002)`,再在版本窗口 `[202607120002, 202607130001)` 内执行 `zone.ImportLegacyTx`,最后 `goose.Up()`。改造后该钩子必须仍然生效,且判断依据改为 `w_schema_versions` 中 `openflare/legacy` 的 max version。 +- 另有 PG 序列修复 `resyncGooseVersionSequence`(同文件 `:158-183`)与迁移后 `clearSystemConfigCache`(`:185-189`),两者必须保留。 +- 上游迁移引擎实现位于 `Wavelet/backend/cmd/app.go:112-360`(`sharedStore` + `gooseEngine`),`core.MigrationEngine` 为公开接口,OpenFlare 需自持一份实现(放 `OpenFlare/internal/platform/migration`),不可依赖上游 `cmd` 包内未导出符号。 +- `MigrationEntry.Dir` 被上游引擎忽略;目录基名必须为 `postgres`/`sqlite`。goose 会给每个 plugin_id 插入哨兵 `version_id=0`。 +- 内核:`core.Profile("agent")` 合法(未知 profile 原样透传);`App.Run` 始终阻塞在信号;长生命周期服务用 `ctx.RegisterDriver` + `ctx.OnDispose`。 +- 全仓 476 个 `.go` 文件引用 `github.com/Rain-kl/Wavelet`,且**无任何非 import 出现**(已验证),模块路径替换可全局进行。 +- 前端 `frontend/` 禁止改动;`//go:embed all:dist` 位于 `internal/router/root/frontend.go:18`,dist 由 `make build-embedded` 拷入。 + +## 文件结构(本计划涉及) + +| 动作 | 路径 | 职责 | +| :-- | :-- | :-- | +| Create | `backend/`(git mv 而来) | Go 代码根 | +| Modify | `backend/go.mod` | `module OpenFlare` + `require/replace Wavelet` | +| Create | `backend/go.work` | 本地双 module 一体构建 | +| Create | `backend/Wavelet/**` | 上游拷贝(`core`、`plugins`、`pkg`、`scripts`、`go.mod`) | +| Create | `backend/Wavelet/share/{protocol,wsclient,geoip,edge}/` | 跨插件共享资源(OpenFlare 所有,同步排除) | +| Create | `backend/Wavelet/share/README.md` | 所有权与同步排除声明 | +| Create | `backend/internal/platform/migration/{store,engine,bridge,lock}.go` | 迁移引擎、桥接、锁 | +| Create | `backend/plugins/server/migrations/{postgres,sqlite}/*.sql` | 76×2 历史链原样保真 | +| Create | `backend/cmd/migrate-audit/main.go` | 三方 schema 一致性门禁工具 | +| Create | `scripts/sync-upstream.sh` | 上游同步(排除 `share/`) | +| Modify | `Makefile`、`docker/Dockerfile*`、`.github/workflows/*` | 路径切至 `backend/`、ldflags 模块路径 | + +--- + +## Task 1: 基线快照(P0) + +**Files:** +- Create: `docs/superpowers/specs/baseline/routes.txt` +- Create: `docs/superpowers/specs/baseline/go-test.txt` + +- [ ] **Step 1: 导出改造前路由与测试基线** + +```bash +cd /Users/ryan/Code/Go/OpenFlare-cordis +mkdir -p docs/superpowers/specs/baseline +python3 -c "import json;d=json.load(open('docs/swagger.json'));print('\n'.join(sorted(f'{m.upper()} {p}' for p,v in d.get('paths',{}).items() for m in v)))" > docs/superpowers/specs/baseline/routes.txt +go test ./... > docs/superpowers/specs/baseline/go-test.txt 2>&1; echo "exit=$?" +``` +Expected: `exit=0`,`routes.txt` 非空。 + +- [ ] **Step 2: 生成 A 路 schema 基线(改造前二进制,供 Task 9 对拍)** + +```bash +go build -o /tmp/of-baseline-server . +OF_DB_PATH=/tmp/of-baseline-a.db timeout 25 /tmp/of-baseline-server api >/tmp/of-a.log 2>&1 || true +sqlite3 /tmp/of-baseline-a.db "SELECT name,sql FROM sqlite_master WHERE type='table' ORDER BY name;" > docs/superpowers/specs/baseline/schema-A.sql +sqlite3 /tmp/of-baseline-a.db "SELECT version FROM goose_db_version WHERE is_applied=1 ORDER BY version;" > docs/superpowers/specs/baseline/versions-A.txt +``` +Expected: `schema-A.sql` 含 `w_users`、`of_zones`;`versions-A.txt` 末行为 `202608090003`。 + +- [ ] **Step 3: 提交基线** + +```bash +git add docs/superpowers/specs/baseline && git commit -m "chore(cordis): 记录改造前 schema 与路由基线" +``` + +## Task 2: Go 树迁入 `backend/` 并改 module 名(P1) + +**Files:** +- Modify: `backend/**`(全部 Go 文件)、`Makefile`、`docker/*`、`.github/workflows/*` + +- [ ] **Step 1: 移动文件(保留 git 历史)** + +```bash +mkdir -p backend +git mv cmd internal pkg main.go go.mod go.sum backend/ +``` + +- [ ] **Step 2: 改 module 名并重写全部 import 路径** + +```bash +perl -pi -e 's{^module github\.com/Rain-kl/Wavelet$}{module OpenFlare}' backend/go.mod +grep -rl 'github.com/Rain-kl/Wavelet' --include='*.go' backend | xargs perl -pi -e 's{\bgithub\.com/Rain-kl/Wavelet\b}{OpenFlare}g' +grep -rl 'github.com/Rain-kl/Wavelet' --include='*.yml' --include='Dockerfile*' Makefile .github docker | xargs perl -pi -e 's{github\.com/Rain-kl/Wavelet}{OpenFlare}g' +``` + +- [ ] **Step 3: Makefile 全部 Go 目标切到 `backend/`(对齐 Wavelet 惯例)** + +`MODULE := $(shell cd backend && go list -m)`;每个含 `go ` 命令的目标前缀 `cd backend &&`;`swagger` 输出目录仍为仓库根 `docs/`(`swag init -g backend/main.go -o docs --dir backend`),确保 `frontend` 消费路径不变。 + +- [ ] **Step 4: 修正内嵌与 ldflags 路径** + +`build-embedded` 中前端导出物拷贝目标改为 `backend/internal/router/root/dist`;`.github/workflows/build-release.yml` 三处 `-X 'OpenFlare/plugins/{agent,relay,flared}/config.Version=...'` 在 Task 12 前暂以 `OpenFlare/internal/apps/...` 为准(P5 再随插件落位改一次),并全部加 `working-directory: backend`。 + +- [ ] **Step 5: 验证编译与测试未回归** + +```bash +cd backend && go build ./... && go test ./... 2>&1 | tail -5 +``` +Expected: build 无输出;test 与 `docs/superpowers/specs/baseline/go-test.txt` 同结果(exit 0)。 + +- [ ] **Step 6: 验证 A 路 schema 仍一致(移动未改变行为)** + +重跑 Task 1 Step 2(输出改 `schema-A2.sql`、`versions-A2.txt`)并 `diff -u` 两个 schema 文件。 +Expected: diff 为空。 + +- [ ] **Step 7: 提交** + +```bash +git add -A && git commit -m "refactor(layout): Go 代码迁入 backend/ 并将模块名简化为 OpenFlare" +``` + +## Task 3: Vendoring 上游为第二 module(P2) + +**Files:** +- Create: `backend/Wavelet/**`、`backend/Wavelet/go.mod`、`backend/go.work`、`scripts/sync-upstream.sh` + +- [ ] **Step 1: 拷入上游内核与插件(排除上游装配根与运行期产物)** + +```bash +cd /Users/ryan/Code/Go/OpenFlare-cordis/backend +mkdir -p Wavelet +rsync -a --delete --exclude data --exclude uploads --exclude docs --exclude cmd --exclude main.go --exclude downstream \ + /Users/ryan/Code/Go/Wavelet/backend/ Wavelet/ +``` +Expected: `Wavelet/core`、`Wavelet/plugins/{drivers,infra,domain}`、`Wavelet/pkg`、`Wavelet/scripts`、`Wavelet/go.mod` 存在。 + +- [ ] **Step 2: 建立双 module 关联** + +```bash +printf 'go 1.25.7\n\nuse (\n\t.\n\t./Wavelet\n)\n' > go.work +perl -pi -e 's{^go \d.*$}{go 1.25.7\n\nrequire Wavelet v0.0.0\n\nreplace Wavelet => ./Wavelet}' go.mod +go work sync && go build ./... && cd Wavelet && go build ./... && cd .. +``` +Expected: 两次 `go build ./...` 均无输出。若 `replace` 插入位置与 go.mod 现有语句冲突,手工将 `require Wavelet v0.0.0` / `replace Wavelet => ./Wavelet` 置于 `go` 指令之后。 + +- [ ] **Step 3: 写同步脚本(`share/` 永不覆盖)** + +```bash +cat > ../scripts/sync-upstream.sh <<'SH' +#!/usr/bin/env bash +# 从 Wavelet 上游同步内核与插件;share/ 由 OpenFlare 拥有,显式排除。 +set -euo pipefail +SRC="${1:-/Users/ryan/Code/Go/Wavelet/backend}" +DST="$(cd "$(dirname "$0")/../backend/Wavelet" && pwd)" +rsync -a --delete \ + --exclude 'data' --exclude 'uploads' --exclude 'docs' --exclude 'cmd' \ + --exclude 'main.go' --exclude 'downstream' --exclude 'share' \ + "$SRC/" "$DST/" +echo "synced upstream -> $DST (share/ preserved)" +SH +chmod +x ../scripts/sync-upstream.sh +``` + +- [ ] **Step 4: 验证 `go.mod` 中上游依赖不丢失** + +`go build ./...` 若报 `missing go.sum entry`,执行 `go mod tidy && cd Wavelet && go mod tidy && cd .. && go build ./...`。 +Expected: 构建通过。 + +- [ ] **Step 5: 提交** + +```bash +git add -A && git commit -m "feat(cordis): 以第二 module 形态 vendoring Wavelet 内核与平台插件" +``` + +## Task 4: `share` 共享层(P2,G3) + +**Files:** +- Create: `backend/Wavelet/share/{protocol,wsclient,geoip,edge}/`、`backend/Wavelet/share/README.md` + +- [ ] **Step 1: 建立所有权声明** + +```bash +mkdir -p Wavelet/share +cat > Wavelet/share/README.md <<'MD' +# share — 跨插件共享资源层 + +本目录由 **OpenFlare 拥有**,不属于 Wavelet 上游同步范围(`scripts/sync-upstream.sh` 显式排除)。 + +用途:被 `server` 与 `agent`/`relay`/`flared` 中两个及以上插件共同消费、且无法放入 +`Wavelet/pkg/`(禁止 import 业务代码)或插件内部(禁止插件互相 import)的资源。 +MD +``` + +- [ ] **Step 2: 迁移四个真正跨插件的资源包** + +```bash +mkdir -p Wavelet/share/{protocol,wsclient,geoip,edge} +git mv pkg/protocol Wavelet/share/protocol +git mv pkg/wsclient Wavelet/share/wsclient +git mv pkg/geoip Wavelet/share/geoip +git mv internal/apps/edge/logging Wavelet/share/edge/logging +grep -rl 'OpenFlare/pkg/protocol\|OpenFlare/pkg/wsclient\|OpenFlare/pkg/geoip\|OpenFlare/internal/apps/edge/logging' --include='*.go' . \ + | xargs perl -pi -e 's{OpenFlare/pkg/(protocol|wsclient|geoip)}{Wavelet/share/$1}g; s{OpenFlare/internal/apps/edge/logging}{Wavelet/share/edge/logging}g' +go build ./... +``` +Expected: 构建通过。 + +- [ ] **Step 3: 消除 `pkg` 与上游 `Wavelet/pkg` 的重复** + +对每个 OpenFlare 保留包,先比对符号集,再决定归属: + +```bash +for p in util logger trace cache mail response idgen ginutil httppool buildinfo testhelper; do + echo "=== $p: openflare-only symbols ===" + comm -23 <(grep -rhoE '^func [A-Z][A-Za-z0-9_]*' OpenFlare/pkg/$p/*.go 2>/dev/null | awk '{print $2}' | sort -u) \ + <(grep -rhoE '^func [A-Z][A-Za-z0-9_]*' Wavelet/pkg/$p/*.go 2>/dev/null | awk '{print $2}' | sort -u) +done +``` +- 上游同名包**已覆盖**的符号 → 删除 OpenFlare 副本,import 改指 `Wavelet/pkg/...`。 +- 仅 OpenFlare 需要的符号(如 `util.Go`、`util.EscapeLike`、`util.DummyCheckPassword`)→ 追加到 `Wavelet/pkg/

/openflare.go`(OpenFlare 拥有该 fork,P6 回流上游),再删除 OpenFlare 副本。 +- `pkg/cap`、`pkg/push`、`pkg/render`、`pkg/pagesarchive` → 与上游有交集者归 `Wavelet/pkg`,纯边缘共享者归 `Wavelet/share`。 +每迁一包后 `go build ./... && go test ./...`,全部完成后提交。 + +- [ ] **Step 4: 验证共享层被真实使用并提交** + +```bash +grep -rn 'Wavelet/share' --include='*.go' plugins internal cmd 2>/dev/null | head -5 +go build ./... && git add -A && git commit -m "feat(cordis): 新增 Wavelet/share 跨插件共享层并收敛重复 pkg 实现" +``` +Expected: 至少 server 与一个 daemon 路径 import `Wavelet/share/...`。 + +## Task 5: 迁移引擎骨架(P3) + +**Files:** +- Create: `backend/internal/platform/migration/store.go`(自持 `sharedStore`) +- Create: `backend/internal/platform/migration/engine.go`(实现 `core.MigrationEngine`) +- Test: `backend/internal/platform/migration/store_test.go` + +- [ ] **Step 1: 写失败测试——版本表 DDL 与上游逐字节一致** + +```go +func TestSharedStoreDDLMatchesUpstream(t *testing.T) { + for _, dialect := range []string{"sqlite3", "postgres"} { + s := &sharedStore{pluginID: "t", dialect: dialect} + db, err := sql.Open("sqlite", ":memory:") + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + require.NoError(t, s.CreateVersionTable(context.Background(), db)) + var got string + require.NoError(t, db.QueryRow("SELECT sql FROM sqlite_master WHERE name='w_schema_versions'").Scan(&got)) + require.Contains(t, got, "plugin_id") + require.Contains(t, got, "version_id") + require.Contains(t, got, "PRIMARY KEY (plugin_id, version_id)") + } +} +``` + +- [ ] **Step 2: 运行验证失败** + +Run: `cd backend && go test ./internal/platform/migration/ -run TestSharedStoreDDLMatchesUpstream -v` +Expected: FAIL(`sharedStore` 未定义)。 + +- [ ] **Step 3: 从上游移植 `sharedStore`** + +将 `Wavelet/backend/cmd/app.go:142-240` 的 `sharedStore`(`Tablename`/`CreateVersionTable`/`Insert`/`Delete`/`GetMigration`/`GetLatestVersion`/`ListMigrations`/`placeholder`)整段复制到 `store.go`,仅改包名与注释;DDL 字符串**一字不动**。 +Run: `go test ./internal/platform/migration/ -run TestSharedStoreDDLMatchesUpstream` +Expected: PASS。 + +- [ ] **Step 4: 写失败测试——历史链按 plugin id 独立计数** + +```go +func TestLegacyPluginResumesFromStampedMax(t *testing.T) { + dir := t.TempDir() + db, err := sql.Open("sqlite", filepath.Join(dir, "t.db")) + require.NoError(t, err) + defer func() { _ = db.Close() }() + s := &sharedStore{pluginID: LegacyPluginID, dialect: "sqlite3"} + ctx := context.Background() + require.NoError(t, s.CreateVersionTable(ctx, db)) + require.NoError(t, s.Insert(ctx, db, goosedb.InsertRequest{Version: 202608090003})) + got, err := s.GetLatestVersion(ctx, db) + require.NoError(t, err) + require.Equal(t, int64(202608090003), got) +} +``` +Run: `go test ./internal/platform/migration/ -run TestLegacyPluginResumesFromStampedMax` +Expected: FAIL(`LegacyPluginID` 未定义)。 + +- [ ] **Step 5: 实现引擎** + +```go +// LegacyPluginID 是 OpenFlare 76 个历史 goose 文件的归属标识。 +const LegacyPluginID = "openflare/legacy" + +const ( + zoneImportSQLVersion int64 = 202607120002 + zoneDropLegacySQLVersion int64 = 202607130001 +) +``` +`engine.go` 以上游 `gooseEngine.Migrate`(`cmd/app.go:243-306`)为基线,差异仅两处: +1. 遍历 `entries` 时,`entry.PluginID == LegacyPluginID` 走三段式:`provider.Up` 到 `zoneImportSQLVersion` → 若 `store.GetLatestVersion` 落在 `[zoneImportSQLVersion, zoneDropLegacySQLVersion)` 则在事务内执行 `zone.ImportLegacyTx` → 继续 `provider.Up` 至末尾; +2. 引擎入口先调用 `resyncGooseVersionSequence(sqlDB)`(自 `migrator.go:158-183` 原样移植,改收 `*sql.DB` 与方言参数),并在全部 entry 完成后调用 `clearSystemConfigCache`。 + +Run: `go test ./internal/platform/migration/ -v` +Expected: PASS。 + +- [ ] **Step 6: 提交** + +```bash +git add backend/internal/platform/migration && git commit -m "feat(migration): 自持 w_schema_versions 引擎并保留 zone 导入钩子" +``` + +## Task 6: 历史链归属与注册(P3) + +**Files:** +- Create: `backend/internal/platform/migration/legacy/migrations/{postgres,sqlite}`(76×2 文件) +- Modify: `backend/plugins/server/plugin.go`(`ctx.Migrations().Register(LegacyPluginID, legacyFS)`) + +- [ ] **Step 1: 原样保真移动 SQL** + +```bash +mkdir -p internal/platform/migration/legacy/migrations +git mv internal/infra/persistence/migrator/goose/postgres internal/platform/migration/legacy/migrations/postgres +git mv internal/infra/persistence/migrator/goose/sqlite internal/platform/migration/legacy/migrations/sqlite +git mv internal/infra/persistence/migrator/goose/clickhouse internal/platform/migration/legacy/migrations-clickhouse +``` +Expected: `git diff --stat -M HEAD~1 -- '*/*.sql'` 显示纯 rename,零内容变更。 + +- [ ] **Step 2: embed 并注册** + +```go +//go:embed legacy/migrations/postgres/*.sql legacy/migrations/sqlite/*.sql +var legacyFS embed.FS +``` +ClickHouse 目录**不放**在 `migrations/` 下(避免被 `findMigrationFS` 的 `postgres|sqlite` 目录探测命中),继续由既有 `MigrateClickHouse` + `goose_clickhouse_version` 驱动,行为与改造前完全一致。 + +- [ ] **Step 3: 校验双方言文件集合一致** + +```bash +diff <(ls internal/platform/migration/legacy/migrations/postgres) \ + <(ls internal/platform/migration/legacy/migrations/sqlite) +``` +Expected: 无差异(否则 `version_id` 会跨方言漂移)。 + +- [ ] **Step 4: 构建并提交** + +`go build ./... && go test ./...` → PASS 后 +```bash +git add -A && git commit -m "refactor(migration): 历史 goose 链原样归入 openflare/legacy 插件标识" +``` + +## Task 7: 版本 stamp 桥接(P3) + +**Files:** +- Create: `backend/internal/platform/migration/bridge.go` +- Create: `backend/internal/platform/migration/lock.go` +- Test: `backend/internal/platform/migration/bridge_test.go` + +- [ ] **Step 1: 写失败测试——老库逐行搬运且不重复执行** + +```go +func TestBridgeCopiesLegacyVersions(t *testing.T) { + db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "old.db")) + require.NoError(t, err) + defer func() { _ = db.Close() }() + _, err = db.Exec(`CREATE TABLE goose_db_version (id INTEGER PRIMARY KEY AUTOINCREMENT, + version_id INTEGER NOT NULL, is_applied INTEGER NOT NULL, timestamp DATETIME)`) + require.NoError(t, err) + _, err = db.Exec(`INSERT INTO goose_db_version (version_id,is_applied) VALUES + (202606090001,1),(202607120002,1),(202608090003,1),(202608100001,0)`) + require.NoError(t, err) + + n, err := Bridge(context.Background(), db, "sqlite3") + require.NoError(t, err) + require.Equal(t, 3, n) // 仅 is_applied=1 行被搬运 + + again, err := Bridge(context.Background(), db, "sqlite3") + require.NoError(t, err) + require.Equal(t, 0, again) // 幂等 + + var maxV int64 + require.NoError(t, db.QueryRow( + "SELECT MAX(version_id) FROM w_schema_versions WHERE plugin_id='openflare/legacy'").Scan(&maxV)) + require.Equal(t, int64(202608090003), maxV) +} +``` +Run: `go test ./internal/platform/migration/ -run TestBridgeCopiesLegacyVersions` +Expected: FAIL(`Bridge` 未定义)。 + +- [ ] **Step 2: 实现 `Bridge`** + +要点:以 `SELECT version_id FROM goose_db_version WHERE is_applied=1` 为源(表不存在则直接返回 0,视为新库);`INSERT INTO w_schema_versions (plugin_id, version_id) VALUES (?, ?) ON CONFLICT (plugin_id, version_id) DO NOTHING` 逐行写入 `openflare/legacy`,并以 `RowsAffected` 汇总真实新增数;同时补写哨兵 `0`。占位符沿用 `sharedStore.placeholder`。**禁止**读写任何业务表。 + +- [ ] **Step 3: 写失败测试——迁移期互斥锁** + +```go +func TestMigrationLockIsExclusive(t *testing.T) { + db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "l.db")) + require.NoError(t, err) + defer func() { _ = db.Close() }() + unlock, err := LockMigration(context.Background(), db, "sqlite3") + require.NoError(t, err) + _, err2 := db.Exec("INSERT INTO w_schema_versions VALUES ('x',1,'2026-01-01')") + require.Error(t, err2) // 锁持有期间其他写者被阻塞/失败 + require.NoError(t, unlock()) + _, err3 := db.Exec("INSERT INTO w_schema_versions VALUES ('x',1,'2026-01-01')") + require.NoError(t, err3) +} +``` +Expected: 先 FAIL(`LockMigration` 未定义),实现后 PASS。 + +- [ ] **Step 4: 实现 `LockMigration`** + +PG:`SELECT pg_advisory_lock($1)` / `pg_advisory_unlock($1)`(固定 key 常量);SQLite:`PRAGMA busy_timeout` + 单写者事务(`BEGIN IMMEDIATE`)。返回 `func() error` 解锁,上游无 session locker,此处补齐多节点竞争保护。 + +- [ ] **Step 5: 接入装配根** + +`Engine.Migrate` 开头:`unlock, err := LockMigration(...)`、`defer unlock()`;`Bridge(...)` 紧随 `CreateVersionTable` 之后、遍历 `entries` 之前。 + +- [ ] **Step 6: 全量测试并提交** + +```bash +go test ./internal/platform/migration/ -race && go test ./... && git add -A && git commit -m "feat(migration): 一次性 goose_db_version 到 w_schema_versions 桥接与迁移互斥锁" +``` + +## Task 8: 上游插件初建与对齐(P3) + +**Files:** +- Create: `backend/Wavelet/plugins/domain/*/migrations/{postgres,sqlite}/00002_openflare_align.sql`(仅在确有缺口时) +- Test: `backend/internal/platform/migration/parity_test.go` + +- [ ] **Step 1: 列出对齐缺口(只读探测,不写库)** + +```sql +-- 对 A 路基线库执行,逐表比对上游模型期望列 +SELECT table_name, column_name FROM information_schema.columns +WHERE table_name LIKE 'w\_%' ORDER BY table_name, column_name; +``` +Expected: 产出「上游需要但现库缺失」的列/表清单;`w_message_channels`、`w_message_bindings`、`w_message_pairing_codes` 应出现在“缺失表”中(OpenFlare 无 bot 渠道表)。 + +- [ ] **Step 2: 缺口只以追加式迁移补齐** + +```sql +-- +goose Up +-- +goose StatementBegin +ALTER TABLE w_users ADD COLUMN IF NOT EXISTS need_change_password BOOLEAN NOT NULL DEFAULT FALSE; +-- +goose StatementEnd +-- +goose Down +-- +goose StatementBegin +SELECT 1; +-- +goose StatementEnd +``` +`Down` 一律写成 no-op(禁止 DROP,避免回滚破坏现网数据)。sqlite 方言无 `ADD COLUMN IF NOT EXISTS`,需拆为独立文件并按上游惯例手写 sqlite 版本(不接受条件 SQL)。 + +- [ ] **Step 3: 一致性检查(B 路 vs A 路,复用 Task 1 的 dump harness)** + +`legacydump_test.go` 通过环境变量 `OF_DUMP_SCHEMA=<输出路径>` 触发,把「当前 embed 内的全部 SQL」应用到 `t.TempDir()` 的临时 sqlite 库并导出表/索引定义。B 路 = 迁移文件已全部就位后的 dump: + +```bash +cd backend +OF_DUMP_SCHEMA=/tmp/of-b.sql go test ./internal/platform/migration/legacy -run DumpFreshSchema +diff -u docs/superpowers/specs/baseline/schema-A.sql /tmp/of-b.sql +``` +Run: 上述 diff 命令 +Expected: 初期 FAIL 并逐表列出漂移(缺失的 `ADD COLUMN`、被 align 迁移改动的列);补齐 align 迁移后 diff 为空。 + +- [ ] **Step 4: 提交** + +```bash +git add -A && git commit -m "fix(migration): 追加式 align 迁移使全新安装与升级路径 schema 收敛" +``` + +## Task 9: 三方一致性门禁工具(P3 验收) + +**Files:** +- Create: `backend/cmd/migrate-audit/main.go` +- Modify: `Makefile`(`migrate-audit` 目标) + +- [ ] **Step 1: 实现只读 dump 与 diff CLI** + +```go +// dump: sqlite -> SELECT name,sql FROM sqlite_master WHERE type IN ('table','index') ORDER BY name; +// postgres-> information_schema.columns + pg_indexes ORDER BY 1,2 +// mode: A=基线文件 B=全新生成 C=恢复 A 后再跑一次 +func main() { + mode := flag.String("mode", "B", "A|B|C") + dialect := flag.String("dialect", "sqlite3", "sqlite3|postgres") + out := flag.String("out", "", "dump 输出路径") + flag.Parse() + // 1) 临时目录一律 os.MkdirTemp("", "of-audit-"),禁止写源码树 + // 2) B: 空库 -> bridge(no-op) -> Engine.Migrate(entries) + // 3) C: 复制 A 的 db 文件 -> bridge(stamp) -> Engine.Migrate -> 断言 results 为空 + // 4) 输出规范化 dump 到 --out,交由 Makefile 的 diff 判定 +} +``` + +- [ ] **Step 2: Makefile 目标与期望输出** + +```make +migrate-audit: + cd backend && go run ./cmd/migrate-audit --mode B --out /tmp/of-b.sql + cd backend && go run ./cmd/migrate-audit --mode C --base docs/superpowers/specs/baseline/schema-A.sql --out /tmp/of-c.sql + diff -u docs/superpowers/specs/baseline/schema-A.sql /tmp/of-b.sql + @sqlite3 /tmp/of-c.db "SELECT count(*) FROM w_schema_versions" >/dev/null + @grep -q 'no-op' /tmp/of-c.log && echo "MIGRATION PARITY OK" +``` +Expected: `make migrate-audit` 两个 diff 全空并打印 `MIGRATION PARITY OK`。 + +- [ ] **Step 3: 提交并在 changelog 记录** + +`docs/changelog/index.md` 的 `[Unreleased]` 增补:迁移改为按插件版本表 `w_schema_versions` 管理,已部署库通过一次性桥接保持不重跑。 + +## Task 10: 架构门禁与 CI 收口(P3 收尾) + +- [ ] **Step 1: 接入上游架构检查脚本** + +`make arch-check` → `bash backend/Wavelet/scripts/check_cordis_architecture.sh`,并按 OpenFlare 布局增补 `backend/plugins` 与 `backend/Wavelet/share` 两条扫描根。 +Expected: 首次运行对 P3 范围(尚无 4 插件)通过。 + +- [ ] **Step 2: 四二进制构建回归 + code-check + format** + +```bash +make build-backend && make code-check && make format +cd backend && go test -race ./... && go test -shuffle=on ./... +``` +Expected: 全部 exit 0;`-shuffle=on` 无顺序依赖失败。 + +- [ ] **Step 3: 前端零改动断言与提交** + +```bash +git diff --name-only main...HEAD -- frontend/ | tee /tmp/fe.diff +test ! -s /tmp/fe.diff && echo "FRONTEND UNTOUCHED" +git add -A && git commit -m "chore(ci): cordis 布局路径门禁与迁移一致性检查接入" +``` +Expected: 打印 `FRONTEND UNTOUCHED`。 + +--- + +## 完成定义(本计划) + +- `backend/` 双 module 构建通过;`Wavelet/` 与上游差异仅 `share/` 与被显式记录的回流项。 +- `make migrate-audit` 三方 diff 为空;已部署库升级演练零 SQL 变更。 +- `go test -race ./...`、`-shuffle=on`、`make code-check` 全绿;`frontend/` 零改动。 +- 4 个插件与 daemon 内核化由 P4/P5 承接,本计划不含业务路由迁移。 + +--- + +## 附录 A:server 插件接入内核的可行路径(P4 前置调查结论) + +调查上游 `core` 与 `plugins/drivers/driver_http` 后确认:`ctx.Router().Use` **不会**作用到 +gin engine(只把中间件摊平进 `RouteDefinition`),且以下 OpenFlare 现有 engine 级行为 +在内核中没有插件级贡献点: + +| 需求 | 现状 | 出处 | +| :-- | :-- | :-- | +| `RedirectTrailingSlash = false` | 内核无处设置(全仓 0 命中) | `router/router.go:42-43` | +| 自有 CORS / logger 分级 / session cookie 语义与 fatal 策略 | 硬编码在 `driver_http/engine.go` | `router.go:45-79` | +| Next.js 静态导出 NoRoute(含动态页 shell 回退) | 被 `registerFrontend` 占用且缺该回退 | `router/root/frontend.go:104-111` | +| `http.Server` 读写字节超时 / TLS | 仅 `ReadHeaderTimeout` | `router.go:83-87` | +| 绑定成功后回调 `onStarted` | 无驱动级回调 | `router.go:89-95` | +| 路由前缀取自 `app.api_prefix` | `ctx.Router()` 只收字面量 | `config.example.yaml:18` | + +**结论:不改上游即可闭合——`driver_http.New(driver_http.WithEngine(自建 engine))` 已存在。** +装配根(`backend/cmd`)负责构造 gin engine(保留 Recovery/CORS/session/otelgin/ +RedirectTrailingSlash/NoRoute 与超时),把 engine 交给 http 驱动;`server` 插件的 +`Apply` 只做 `ctx.Router().Group(...)` 声明式路由注册。其余两点: + +- `onStarted` 改由 `ctx.Events().On("app:ready")` + `ctx.Driver(core.DriverTypeHTTP).Addr()` 提供; + 启动横幅在 `app.Run()` 前后打印,不再依赖回调。 +- 免鉴权路径:**保留 OpenFlare 自有 `oauth.LoginRequired()`(不查白名单)**,语义与今天一致。 + 注意内核 `ctx.Router().RegisterWhitelist` 目前是**空转**(`driver_http.SetWhitelist`/ + `IsPathWhitelisted` 无非测试调用方),不得依赖它放行——否则会 401。 + +待办:以上三项(engine 中间件贡献点、NoRoute 贡献点、白名单真正生效)应回流上游 Wavelet, +届时可去掉 `WithEngine` 例外。契约校验仍以 `docs/superpowers/specs/baseline/routes.txt` +的 232 条 **操作**(方法×路径,`.Any()` 计 7)为准,与代码内 215 个注册调用点不矛盾。 + +## 附录 B:server 插件化已就位件与剩余步骤 + +**已落地并验证** +- `router.BuildEngine()` 从 `Serve()` 中抽出(仅构造引擎与中间件,不监听),`Serve()` 复用它,行为不变。 +- `router/routes_dump_test.go`:以 `OF_DUMP_ROUTES=` 导出现存注册路径的 **(方法 路径) 全集**, + 已固化基线 `docs/superpowers/specs/baseline/routes-engine.txt`:**256 条**(含 20 条尾部斜杠变体, + 全为 `GET /api/v1/d/*`)。这是 server 插件化唯一的路由对拍基准(232 是 swagger 操作数, + 二者差异来自斜杠变体与 `Any` 展开,不是矛盾)。 +- 内核补丁:`RouterExtension` 新增 `HandleRaw`(保留尾部斜杠)与 `BasePath`,作用域包装器同步实现并登记反注册; + 用例 `core/extpoints/router_raw_test.go` 覆盖;补丁登记在 `backend/OpenFlare/upstream-patches.md`, + `sync-upstream.sh` 同步后会打印需确认的补丁文件。 + +**剩余步骤(按序,每步都要过对拍)** +1. `openflare/apiutil.RegisterCollection` 改用 `route.Handle(method,"")` + `route.HandleRaw(method,"/")` + (取代 `route.BasePath()` 的 gin 用法与 `[]gin.HandlerFunc`→`[]any` 转换)。 +2. `router/root`:`RegisterDefaultRootRoutes`/`RegisterCustomRootRoutes` 参数改 `core.RouterExtension`; + `RegisterFrontend`(`NoRoute` + 静态资源,含 Next.js 动态 shell 回退)保留在引擎层,由 `BuildEngine()` 调用。 +3. 33 个 `*gin.RouterGroup` 注册函数改 `core.RouterExtension`(本轮已验证方法面只需 + GET/POST/PUT/DELETE/Group/Use,且无人接收返回值),随后删除 `registerRoutes()`。 +4. 新增 `OpenFlare/plugins/server/plugin.go`:`Name()="server"`,`Apply(ctx)` 内以 + `ctx.Router().Group(config.Config.App.APIPrefix)` 复刻 `registerRoutes` 的树 + (`/api` → `/v1` → v1/user/admin/openflare;根级 4 条走注册表绝对路径)。 +5. 装配根切换:`cmd/{api,all}.go` 改为 `core.NewApp(core.WithProfile(...))` + + `app.Use(server.New())` + `app.Use(driver_http.New(driver_http.WithEngine(router.BuildEngine())))`, + 启动横幅改挂 `ctx.Events().On("app:ready")` 并用 `httpDrv.Addr()` 取实际监听地址; + `otel_trace.Shutdown`/`bootstrap.Stop` 迁入 `ctx.OnDispose`,删除 `Serve()` 自带的信号循环。 +6. 对拍:新测试把插件注册表产出的 (方法 路径) 集合与 `routes-engine.txt` 逐条比对, + 差异必须为空;再实跑 server(sqlite)`curl` 健康检查、一个免鉴权端点与一个需鉴权端点(期望 401)。 diff --git a/docs/superpowers/plans/2026-08-29-openflare-upstream-dedupe.md b/docs/superpowers/plans/2026-08-29-openflare-upstream-dedupe.md new file mode 100644 index 00000000..eaa44f53 --- /dev/null +++ b/docs/superpowers/plans/2026-08-29-openflare-upstream-dedupe.md @@ -0,0 +1,98 @@ +# server 插件复用上游能力清理计划 + +**目标**:`backend/OpenFlare/plugins/server` 里大量能力上游 Wavelet 已提供,删除本地副本改为复用; +按规约判定归属——通用能力回流上游,OpenFlare 业务留在插件内。 + +**事实基线**:`server` 实测 **68,117** 行非测试 Go 代码(早先记的 29.6k 只是 `openflare/` 一个域)。 +路由契约冻结基准:`docs/superpowers/specs/baseline/routes-engine.txt`(256 条 方法+路径)与 +`routes.txt`(232 条 swagger 操作),由 `plugin_parity_test.go` 逐条把守。 + +## 关键防线(先读,否则会做错) + +直接挂上游插件**会破坏契约**,以下差异必须逐项处理,不允许“上游有就换”: + +1. **验证码门禁丢失**:OF 在 `POST /api/v1/user/{login,register,send-email-code}` 上挂了 + `cap.VerifyMiddleware`(`router/v1/user.go:71-73`),上游 `domain/user` 没有该门禁 + (`user/plugin.go:126-129`)。直接替换=安全回归。 +2. **路径变化**:`/api/cap/{challenge,redeem}`(OF,挂在 `/api` 组)vs `/api/v1/cap/*`(上游,且多一个 `GET`); + `GET /api/health` vs `/healthz` + `/api/healthz`;`/api/v1/upload` 的 `GET /my`、`PUT /:id`、 + `GET /download/:id`、`POST /download/batch` vs 上游 `GET ""`、`POST /batch-download`; + OF 独有 `GET /api/v1/user/self`。 +3. **公共配置响应体不同**:`GET /api/v1/config/public` 路径相同但 JSON 字段不同,SPA 直接消费。 +4. 上游额外需要表 `w_message_channels` / `w_message_bindings` / `w_message_pairing_codes`(OF 库中不存在)。 + 除此之外 13 张共享 `w_*` 表**列级零差异**;唯一缺口 `w_access_tokens.description/expires_at` 上游 Go 模型也未使用,非阻塞。 + +## 判定清单 + +| 包 | 行数 | 上游对应 | 判定 | +| :-- | --: | :-- | :-- | +| `shared/response` | 142 | `pkg/response` | 删(7/7 `Abort*` 逐字一致,仅风格差异) | +| `pkg/logger` | 382 | `pkg/logger` | 删(`Config` 字段完全一致) | +| `pkg/mail` | 266 | `pkg/mail` | 删 | +| `pkg/httppool` | 106 | `pkg/httppool` | 删(逐字节相同) | +| `pkg/trace` | 148 | `pkg/trace` | 删(差异仅默认 tracer 名,由 `app.app_name`/`otel.tracer_name` 决定) | +| `pkg/cache/ram` | 305 | `pkg/cache/ram` | 删(OF 用裸 `go`,上游用 `util.Go`,换过去是修复) | +| `infra/persistence/idgen` | 47 | `pkg/idgen` | 删(上游为超集) | +| `infra/persistence/batchwriter` | 337 | `pkg/batchwriter` | 删 | +| `listener` | 42 | `core/events.go Subscribe[T]` | 删,改事件订阅 | +| `testhelper` | 479 | `pkg/testhelper` | 合并,仅留 `SetupLogStoresForTest` | +| `pkg/cache/disk` | 412 | `pkg/cache/disk` | 先回流 OF 的 5 处类型断言 `ok` 守卫(通用健壮性修复),再删本地副本 | +| `pkg/cap` | 511 | `plugins/domain/cap/pow` | **不能直接复用**(跨插件 import 被禁)。二选一:pow 提取到上游 `pkg/pow`,或留 `server` 内并放弃“复用” | +| `infra/objectstore`、`infra/diskcache` | 1,058 | `plugins/infra/storage/*` | 改走 `contracts.StorageService`/上游 infra(P6) | +| `infra/task`(worker/scheduler/handlers) | 1,125 | `plugins/drivers/driver_asynq_*` | 换内核驱动(P6) | +| `infra/config`(全局 `Config` 单例) | 444 | `core/config.go` + `plugins/infra/config` | 改 `ctx.Config().Bind`(P6,18 处引用) | +| `infra/persistence`(DB/Redis 初始化) | 639 | `plugins/infra/database` | 换 `contracts.DBService`(P6) | +| `infra/persistence/migrator` | 298 | `w_schema_versions` + 每插件迁移 | 见 foundation 计划 P3(含历史 stamp 桥接) | +| `repository/logstore` | 3,286 | `plugins/domain/risk_control/logstore` | 近似分叉:合并并保留 OF 的 `AccessLogStore`/`ObservabilityStore` 接口 | +| `repository/analytics` | 2,848 | 同上(仅用户访问日志半边) | 合并半边;`node_*` 与 CH 维护上游没有,留 `server` | +| `repository`、`model` | 8,527 | 各上游域插件内 | 拆分:`w_*` 部分随 P6 归上游,`of_*` 留 `server` | +| `oauth`、`user`、`admin`、`upload`、`cap`、`config`、`health` | ~7,600 | `plugins/domain/{auth,user,admin,upload,cap,system}` | **受“关键防线”约束**,逐端点判定后合并,禁止整体替换 | +| `admin/push` | 1,776 | `plugins/domain/message_gateway` | 合并并保留 OF 的 `RegisterBuiltInEvent`/`RegisterChannelDefinition`/`SyncEvents` 钩子 | +| `admin/updater` | 832 | 上游仅 `GetUpdateStatus/ApplyUpdate` | OF 的 Prepare/Apply/Finish 三段式为业务能力,保留 | +| `openflare/`(27 子包) | 29,678 | 无 | 保留(产品逻辑);`apiutil` 与 `integration/githubrelease` 若被多插件消费再移 `share` | +| `router`、`router/root`、`router/v1` | 1,242 | `core/extpoints` + `driver_http` | 保留为接线层,直至上游具备引擎中间件与 NoRoute 贡献点 | + +## 执行顺序 + +1. **T1 纯风格差异**(`shared/response`、`pkg/{logger,mail,httppool,trace,cache/ram}`、 + `infra/persistence/{idgen,batchwriter}`):改 import → 删本地副本 → 全量门禁。 +2. **T2 `listener` 改事件订阅**、`testhelper` 合并。 +3. **T3 `pkg/cache/disk` 断言守卫回流上游**,再删本地副本。 +4. **T4 基础设施换内核**(`infra/config` → `ctx.Config()`;`infra/persistence` → `contracts.DBService`; + `infra/task` → asynq 驱动):与 P3/P4b 合并推进。 +5. **T5 平台域逐个合并**(oauth/user/admin/upload/cap/config/health/push):每个域先列差异 + (路由、门禁、响应体、表列),差异以 `server` 内薄封装或回流上游解决,逐域过 256 条对拍。 +6. **T6 `repository`/`model` 按表所有权拆分**:`w_*` 归上游,`of_*` 留 `server`。 + +每步完成判据:`go build ./...` + `go test ./...` 全绿 + `plugin_parity_test` 零差异 + +`make swagger` 232 条零差异 + `golangci-lint` 0 issues + `frontend/` 零改动。 + +## 执行记录与阻塞点 + +### T1 已完成(提交 `0adbfc1a`) + +删除并改用上游:`shared/response`→`pkg/response`、`pkg/{logger,mail,trace,httppool,cache/ram}`、 +`infra/persistence/batchwriter`→`pkg/batchwriter`。共 7 个本地包消失,`go test` 包数 144→138。 + +证据:256 条路由对拍零差异;232 条 swagger 操作零增减,且**把两个随包路径改名的定义 +(`…shared_response.Any`→`response.Any`、`…pkg_logger.LogEntry`→`logger.LogEntry`)归一化后, +新旧 swagger.json 深度相等**——即接口形状完全未变。`$ref` 名由 Go 包路径派生,属生成物命名变化。 + +### 新发现的阻塞点(勿重复踩) + +1. **`pkg/idgen` 暂不能换**:上游 `pkg/idgen` 要求先 `Init(nodeID)` 否则 panic + (`idgen: Init must be called before generating IDs`),而 OpenFlare 副本是懒初始化。 + 5 个 push/user 用例直接 panic。必须与 T4(`infra/config`、`infra/persistence` 改走 + `ctx.Config()`/`contracts.DBService`)一起做,在 bootstrap 阶段显式 `Init`。 +2. **`pkg/cap` 不能“复用上游”**:上游 pow 实现在 `plugins/domain/cap/pow` 里, + 跨插件 import 被架构门禁禁止。合规出路二选一:把 pow 提取为上游 `pkg/pow`(通用能力, + 按规约回流 Wavelet 后两边共用),或承认它是 server 的业务实现留在插件内。 +3. **`listener` 需先有内核上下文**:`core.Subscribe[T](bus, topic, fn)` 要 `*core.EventBus` 实例, + 而调用方(`oauth/handler_callback.go`、`user/routers.go`、`admin/push/custom_events/*`)都在 + 内核之外、手上没有 `*core.Context`。现在改只会把全局变量从包级切片挪到包级总线。 + 待 `server` 的 `Apply`/bootstrap 拿到 ctx 后随 T4 一起做。 +4. **`pkg/trace` 覆盖缺口(非本次引入)**:核实后上游与 OpenFlare 副本都是 4 个同名文件、 + 都**没有**测试文件,因此删除本地副本没有降低覆盖率。要补测试应直接补在上游 `backend/pkg/trace/`。 +5. **`pkg/cache/disk` 已完成**:5 处类型断言的 `ok` 守卫按规约回流上游 + (Wavelet `f3d85d5`,附 `cache_corruption_test.go`:去掉守卫即 panic、加上守卫 4 用例全过), + 上游的 `Default()` 全局实例保留(下游不使用它,不构成行为差异);本地副本已删除。 diff --git a/docs/superpowers/specs/2026-08-29-openflare-cordis-refactor-design.md b/docs/superpowers/specs/2026-08-29-openflare-cordis-refactor-design.md new file mode 100644 index 00000000..f676a271 --- /dev/null +++ b/docs/superpowers/specs/2026-08-29-openflare-cordis-refactor-design.md @@ -0,0 +1,236 @@ +# OpenFlare Cordis 架构改造设计 + +- **文档类型**: 架构设计 / 改造规范(Spec) +- **日期**: 2026-08-29 +- **范围**: OpenFlare 后端全量迁移到 Wavelet Cordis 插件化架构 +- **上游参照**: `/Users/ryan/Code/Go/Wavelet`(`backend/`,module `Wavelet`) +- **改造基线**: OpenFlare `main` @ `9f79fb99`(v3.5.4),工作分支 `cordis` + +--- + +## 1. 目标与不可协商约束 + +| # | 约束 | 验证方式 | +| :-- | :-- | :-- | +| G1 | OpenFlare 改造为 Cordis 架构,上游包名为 `Wavelet` | `backend/Wavelet/` 与上游逐字节一致(`share/` 除外);`go build ./...` 通过 | +| G2 | OpenFlare 功能收敛为 **4 个插件**:`server`、`agent`、`relay`、`flared` | `backend/plugins/` 下仅此 4 个目录,均实现 `core.Plugin` | +| G3 | 在上游树内创建 `share` 包承载跨插件共享资源 | `backend/Wavelet/share/` 存在且被 4 个插件至少两处 import | +| G4 | **不修改前端** | `git diff --name-only main...cordis -- frontend/` 为空 | +| G5 | 数据库迁移谨慎:已部署库不重跑历史、不丢数据、零破坏性变更 | 三方 schema 一致性门禁(§5.4) | +| G6 | 既有 HTTP 契约不变(前端与 API 消费者零感知) | 路由清单 diff + swagger 对拍 | +| G7 | 边缘三进制的 CLI 形态不变(`-config` 默认路径、退出码、日志格式) | 启动冒烟 + 现有测试保持绿 | + +## 1.5 布局修正(v1.1,取代 §3 与 §4 中的路径描述) + +初版设计提出的「`backend/` + `backend/Wavelet/` 双 module、OpenFlare 插件放 `backend/plugins/`」已被否定。 +**采用与 Wavelet 完全同构的单 module 布局**(已落地,提交 `9bf0b2de`): + +``` +backend/ module Wavelet(与上游同名,上游 import 路径逐字一致) +├── main.go cmd/ 控制面装配根(server) +├── core/ 【上游】微内核 + contracts + extpoints +├── pkg/ 【上游】通用库(禁止 import plugins) +├── plugins/{drivers,infra,domain}/ 【上游】平台插件 +├── docs/ swaggo 产物(json/yaml 复制回根 docs/ 供站点消费) +├── share/ 【新增 G3】跨插件共享资源层 +└── OpenFlare/ 【下游】占据上游 backend/downstream 的位置 + ├── cmd/{agent,relay,flared}/ 三个 daemon 入口(package main) + └── plugins/{server,agent,relay,flared}/ 【G2】4 个业务插件 +``` + +约束更新: +- 上游同步 = 覆盖 `backend/{core,pkg,plugins}`,零 import 改写;`OpenFlare/` 与 `share/` 永不被覆盖。 +- OpenFlare 现有代码(apps/repository/model/infra/router/shared/…)暂整体驻留 `backend/OpenFlare/**`,由 P4/P5 逐个搬入 `backend/OpenFlare/plugins/<插件>`;搬完后 `backend/OpenFlare` 下不再保留平铺的遗留层。 +- 装配根在 `backend/cmd`(模块内非 internal 路径),因此下游树不得使用 `internal/`(已在本次落地时展平)。 +- 平台表由上游 `plugins/domain/*` 拥有,业务表 `of_*` 由 `OpenFlare/plugins/server` 拥有;§5 迁移方案不变,仅历史链落点改为 `backend/OpenFlare/plugins/server/migrations/{postgres,sqlite}`。 + +落地验证(实测,非推断):`go build ./...` 通过;`go test ./...` exit 0 且 142 个包 ok(含上游插件测试);`make swagger` exit 0 且 232 条 API 操作与改造前基线逐条一致;`make build-all` 产出 4 个二进制;`git status --short -- frontend/` 为空。 +上游 `plugins`、`core` 暂从 swaggo 扫描中排除(其包名 `model`、`disk` 与下游遗留注解的裸名引用冲突),P4 挂载上游路由时随 `OpenFlare/model` 展平一并解除。 + +## 2. 已核实的事实基线(设计前提,非推测) + +1. **OpenFlare 是 Cordis 之前的 Wavelet 分叉**:`go.mod` module 路径即 `github.com/Rain-kl/Wavelet`;`internal/apps/` 同时含继承自 Wavelet 的平台应用(admin/oauth/user/upload/cap/config/health)与 OpenFlare 自有业务(openflare 29.6k 行、agent 7.4k、edge 1.5k、relay 1.0k、flared 0.9k)。Go 代码总量 151,473 行,209 个测试文件。 +2. **表前缀双轨**:平台表沿用 `w_*`(OpenFlare 的 76 迁移已在其上分叉),业务表 `of_*`(28 张)。上游 `w_*` 表宇宙 17 张,且上游含 `w_message_channels`/`w_message_bindings`/`w_message_pairing_codes`(OpenFlare 无)→ **双向分叉**。 +3. **内核支持非 HTTP 形态**(`Wavelet/backend/core`): + - `normalizeProfile`(app.go:684)对未知 profile 字符串原样透传;`matchesProfile`(app.go:668)`default` 分支做字符串相等比较 → `core.Profile("agent")` 仅启动 `DriverType("agent")` 的驱动。 + - `App.Run`(app.go:600-613)**始终**阻塞在 `signal.NotifyContext`;即使零驱动匹配也会阻塞 → 插件自身禁止阻塞。 + - `Profile` 枚举仅 api/worker/schedule/all;驱动仅在 `Start` 中按 profile 过滤(app.go:487-494),插件 `Apply`、IoC、事件、配置在各 profile 下行为一致。 + - 长生命周期服务标准写法(`plugins/drivers/driver_inproc_worker/plugin.go:118-151`):`Apply` 内保存 coreCtx → `core.Provide` → `ctx.OnDispose(...)` → `ctx.RegisterDriver(p)`;`Stop` 逆序(LIFO)调用并受 `shutdownTimeout` 约束。 + - `RunMigrations()`(app.go:414-433)在无 `MigrationEngine` 且容器无该服务时返回 nil → 无 DB 的 daemon 合法。 + - DI 真实 API 为包级函数 `core.Provide[T](ctx, svc)` / `core.Inject[T](ctx)` / `core.Using[T](ctx, fn)` / `core.When[T](ctx, fn)`;`Context` **没有** `Provide/DB/Cache/Logger/Storage/DistLock` 方法( cookbook 中的 `ctx.DB()` 写法为文档性简化,且内核无 DistLock 能力)。 +4. **迁移子系统现状**: + - `w_schema_versions(plugin_id, version_id, applied_at)` 的 DDL 来自 Go(`Wavelet/backend/cmd/app.go:142-153`),**没有** SQL 文件创建它;插入使用 `ON CONFLICT DO NOTHING`。 + - `sharedStore` 未实现 `TableExists` → goose 回退探测失败后会给每个 plugin_id 插入哨兵 `version_id = 0` 行(幂等、良性,但桥接必须知道)。 + - 版本号解析:goose `NumericComponent` 取文件名首个 `_` 之前部分按十进制 int64 解析,须 ≥1;`2026MMDDNNNN` 与 `00001` 均合法;同一 plugin_id 内重复版本号致命。 + - `findMigrationFS`(app.go:309-358)按目录名 `postgres`/`sqlite` 探测,**注册时传入的 `Dir` 参数被忽略** → 目录基名必须是 `postgres`/`sqlite`。 + - 方言选择:`ctx.Config().Bool("database.enabled", false)` → postgres,否则 sqlite3;无 `WithSessionLocker` → **多节点并发迁移无保护**(上游未解)。 + - 上游 `risk_control/logstore/migrations-clickhouse/00001_initial.sql` 是**未被 embed、无处应用**的孤儿文件。 + - **上游不存在任何历史库基线/stamp 能力**(无 migrate 命令、无 `--baseline`),桥接为 OpenFlare 自有产物。 +5. **`backend/` 化的路径耦合点**(Phase 1 必须一并处理): + - `internal/router/root/frontend.go:18` `//go:embed all:dist`(前端导出物被 Go 包内嵌); + - `.github/workflows/build-release.yml` 三处 `-X 'github.com/Rain-kl/Wavelet/internal/apps/{agent,relay,flared}/config.Version'` 与 `./cmd/*/main.go` 构建路径; + - `Makefile` 的 `build-embedded` / `build-backend` / `cross-build` / `swagger` / `code-check` / `dev-*`; + - `docker/` 四个镜像的构建上下文与 COPY 路径;`config.example.yaml`、`config/clickhouse`、`.env.example` 的读取路径。 + +## 3. 目标仓库布局 + +``` +OpenFlare/ +├── backend/ # Go 代码根,module OpenFlare +│ ├── go.mod # require Wavelet + replace Wavelet => ./Wavelet +│ ├── go.work # 本地一体构建(use . ./Wavelet) +│ ├── Wavelet/ # 【上游】从 Wavelet/backend 原样拷入 +│ │ ├── go.mod # module Wavelet +│ │ ├── core/ # 微内核(零业务) +│ │ ├── core/contracts/ # 跨插件契约(纯 Interface + DTO) +│ │ ├── core/extpoints/ # 扩展点(Router/Task/Schedule/Migration/Setting/Config/Driver) +│ │ ├── plugins/drivers/ # driver_http / asynq_* / inproc_* +│ │ ├── plugins/infra/ # database / cache / cache_memory / config / logger / storage +│ │ ├── plugins/domain/ # admin / user / auth / message_gateway / risk_control / upload / cap / system +│ │ ├── pkg/ # 上游通用库(禁止 import plugins) +│ │ ├── scripts/check_cordis_architecture.sh +│ │ └── share/ # 【G3 新增】跨插件共享资源层,OpenFlare 所有 +│ ├── plugins/ # 【G2】OpenFlare 的 4 个插件 +│ │ ├── server/ # 控制面:of_* 业务域 + 边缘接入 API +│ │ ├── agent/ # 边缘 nginx/WAF 代理守护 +│ │ ├── relay/ # frps 中继守护 +│ │ └── flared/ # frpc 隧道客户端守护 +│ ├── cmd/ # 装配根:root/server/agent/relay/flared/bridge +│ ├── main.go # server 入口(swagger 注释保留原位) +│ └── internal/ # 仅保留非业务基础设施(迁移桥接、构建信息) +├── frontend/ # 【不修改】 +├── docs/ # VitePress + swagger 产物路径不变 +├── docker/ Makefile .github/ # 路径改写至 backend/ +└── config.example.yaml # 位置不变(新增 plugins.* 节点) +``` + +### 3.1 双 module 而非单 module 的理由 + +上游树内 import 保持 `Wavelet/core`、`Wavelet/plugins/...` 逐字节等于上游仓库,同步 = `rsync --delete` + 一条 `replace` 指令,无需任何 import 改写。单 module 方案(`OpenFlare/Wavelet/core`)每次同步都要全量重写 import,同步成本随上游演进线性上升,且必然产生漂移。以 Go module 依赖引用上游(不拷代码)被否,因为 OpenFlare 已分叉平台代码,需先在 `backend/Wavelet/` 内落地再回流。 + +### 3.2 `share` 的落点与所有权 + +`share` 建在上游 module 内(`Wavelet/share/...`),因为:`Wavelet/pkg/` 禁止 import 业务代码,而 `protocol`、`wsclient`、edge 日志/状态机、`geoip` 等资源**同时被 server 与三个 daemon 消费**;Cordis 规则禁止插件互相 import,故必须存在一个双方都可见的中立层。 + +**所有权声明**(写入 `backend/Wavelet/share/README.md`):`share/` 由 OpenFlare 拥有,同步脚本 `scripts/sync-upstream.sh` 显式排除该目录;上游同步仅覆盖 `core/`、`plugins/`、`pkg/`、`scripts/`。此决定的代价是 `share/` 不能从上游获得更新——这是刻意选择,且是唯一需要否定的子决策(备选:落点 `backend/share/`,import `OpenFlare/share/...`,其余设计不变)。 + +### 3.3 模块与命名 + +- OpenFlare module 路径:`OpenFlare`(对齐上游 `module Wavelet` 的简化命名惯例)。 +- 插件 ID 与目录名同名:`server` / `agent` / `relay` / `flared`。 +- 表所有权:`of_*` 全部归 `server`;`w_*` 归各上游平台插件;`w_schema_versions` 归装配根。 +- 插件内部分层:一律采用模式 2(`plugin.go` + `handler/ service/ repository/ model/ errs/ migrations/`),子包内文件按业务实体命名,禁止 `handlers_*` 平铺。 + +## 4. 插件设计 + +### 4.1 `server` + +装配 profile:`api` / `worker` / `schedule` / `all`(与上游语义一致)。挂载上游 infra(database、cache 或 cache_memory、logger、storage)、8 个上游 domain 插件、`driver_http` + asynq/inproc 驱动对,再挂 `server` 插件。 + +`server` 插件职责: +- 路由:`ctx.Router().Group("/api/v1/...")` 自包含注册 OpenFlare 业务路由;免鉴权路径(边缘节点接入、健康探针、OAuth 回调)由本插件 `RegisterWhitelist` 主动声明,杜绝误拦截。 +- 任务/调度:`ctx.Task().Register` / `ctx.Schedule().RegisterCron`(迁移现有 `internal/platform/bootstrap` 的显式装配)。 +- 设置:`ctx.Settings().Register`(迁移现有动态设置声明)。 +- 契约:向平台暴露 `contracts.*` 之外不新增跨插件事务;OpenFlare 自有能力经 `server` 单入口暴露。 +- 迁移:`of_*` 全量 + 历史链(§5)。 + +内部按子域分包:`zone / cloudflare / pages / waf / node / proxy_route / dns / acme / tls / edge_control / config_version / health`,每子域内部遵循 handler→service→repository→model 单向依赖。 + +### 4.2 `agent` / `relay` / `flared` + +同一模式,各自实现 `core.Plugin` + `core.Driver`: + +```go +func (p *Plugin) Name() string { return "agent" } +func (p *Plugin) Type() core.DriverType { return core.DriverType("agent") } +func (p *Plugin) Apply(ctx *core.Context) error { + if err := ctx.Config().Bind("agent", &p.cfg); err != nil { return err } // JSON 配置经 ConfigSource 适配 + core.Provide[contracts.LoggerService](ctx, lg) + ctx.OnDispose(func() error { return p.stop() }) + return ctx.RegisterDriver(p) +} +func (p *Plugin) Start(ctx context.Context) error { /* util.Go 启动 heartbeat/sync/updater,select ctx.Done() */ } +func (p *Plugin) Stop(ctx context.Context) error { /* 收敛 frpc/frps 子进程与 ws 连接 */ } +``` + +- 入口 `cmd/agent/main.go` → `core.NewApp(core.WithProfile(core.Profile("agent")), core.WithConfigSource(jsonSource))` + `app.Use(agent.New())`;**不挂** http/database/cache/asynq 驱动。 +- JSON 配置文件(`agent.json`/`relay.json`/`flared.json`)通过实现 `extpoints.ConfigSource`(`Lookup/LookupEnv/Describe`)接入,优先级:env 覆盖 → 文件 → default,与上游 `plugins/infra/config` 的解析优先级一致;`-config` 旗标与默认路径不变。 +- 三者共享的 protocol / wsclient / edge logging / state 迁入 `Wavelet/share/`;`internal/apps/edge/` 消失(其内容分别归入 `share` 与 daemon 插件)。 +- 裸 `go func()` 一律经 `util.Go`(架构门禁禁止裸 go)。 + +## 5. 数据库迁移方案(G5) + +### 5.1 历史链保真 + +`goose/postgres`(76)与 `goose/sqlite`(76)文件**逐字节原样**迁入 `backend/plugins/server/migrations/{postgres,sqlite}/`,文件名、序号、内容不改;ClickHouse 链(14,含 `goose_clickhouse_version` 版本表)与其现有 `MigrateClickHouse` 路径**完全保持不动**,不并入 `w_schema_versions`(语义不同、且上游 CH 文件为孤儿,不引入其语义)。 + +### 5.2 插件初建与对齐 + +- 上游平台插件的 `00001_initial.sql` 保留:全部 `CREATE TABLE IF NOT EXISTS` / `CREATE INDEX IF NOT EXISTS` / seed `ON CONFLICT DO NOTHING` → 在 OpenFlare 老库上只补建上游新增表(如 `w_message_channels`),已存在表自动跳过。 +- 若上游模型需要 OpenFlare 现库缺失的列,**只允许**在对应插件的 `migrations/{postgres,sqlite}/00002_openflare_align.sql` 追加(`ADD COLUMN` / `CREATE INDEX IF NOT EXISTS`)。全链路禁止 `DROP TABLE` / `DROP COLUMN` / `TRUNCATE`。 +- 双方言目录必须文件名与版本号一一对应,否则 PG 与 sqlite 的 `version_id` 漂移。 + +### 5.3 一次性桥接(`cmd/bridge`,启动前置) + +1. 以与上游 `cmd/app.go:142-153` **逐字节一致**的 DDL 建 `w_schema_versions`(保证上游 `CreateVersionTable` 的 `IF NOT EXISTS` 成为 no-op)。 +2. 取 PG advisory lock(`pg_advisory_lock(key)`)/ sqlite 依赖单写者,补齐上游缺失的并发保护。 +3. 若存在老库版本表(`goose_db_version` / OpenFlare 现名)且 `w_schema_versions` 无 `openflare/legacy` 记录:写入 `(openflare/legacy, 0)` 哨兵与 `(openflare/legacy, MAX(老库已应用版本))`,全部 `ON CONFLICT DO NOTHING`。 +4. 桥接幂等:重复执行零变更;老库此后**永不重跑**历史链,新库正常跑完整链后收敛到同一 schema。 +5. 桥接仅由 server 装配根在 `app.RunMigrations()` 之前调用一次;agent/relay/flared 不涉及(无 DB)。 + +### 5.4 三方一致性门禁(不可跳过) + +| 路径 | 构造方式 | 断言 | +| :-- | :-- | :-- | +| A(基线) | 改造前 `main` 二进制在全新 sqlite/PG 上跑完 76 链 | dump `information_schema` + seed 行数 | +| B(新架构全新安装) | 桥接 + 历史链 + 各插件 initial/align | 与 A **逐项 diff 为空** | +| C(升级路径) | 恢复 A 的库文件后再启动一次新架构 | schema diff 为空,且本次启动**零 SQL 变更**(迁移结果集为空) | + +工具:`backend/cmd/migrate-audit`(导 dump 与 diff,只读、不写源码树,临时目录用 `t.TempDir()`)。任一路径失败即视为改造未完成,禁止合并。 + +## 6. 前端与 API 契约(G4/G6) + +`frontend/` 零改动(`git diff --name-only main...cordis -- frontend/` 必须为空)。内嵌物路径由 `Makefile` 的 `build-embedded` 负责:前端导出物仍拷到 `backend/internal/router/root/dist`(或迁移后对应包目录),`//go:embed all:dist` 随包位置同步。 + +服务端 HTTP 契约保持:路径、`{error_msg,data}` 信封、分页 `{total,results}`、成功 200 / 失败 `Abort*` 语义、swagger 产物路径(`docs/swagger.json|yaml`)。验收:改造前后各跑一次 `swag init`,对拍 `paths` 键集合必须一致(新增路径允许,删改不允许)。 + +## 7. 工具链、CI 与文档 + +- `Makefile`:Go 目标切到 `backend/`;新增 `sync-upstream`、`migrate-audit`、`arch-check`。 +- `.github/workflows`:4 个 build-image 工作流的构建上下文/`Dockerfile` COPY 路径、`build-release.yml` 的 `./cmd/*/main.go` 与三处 `-X` ldflags 模块路径(改为 `OpenFlare/plugins/{agent,relay,flared}/config.Version`)。 +- 门禁移植:`backend/Wavelet/scripts/check_cordis_architecture.sh`(core 不 import gin/gorm/asynq;pkg 不 import plugins;插件间不互相 import;禁 AutoMigrate;禁裸 go)在 OpenFlare `backend/` 下纳入 `make code-check`。 +- 文档:中文文档同步(不同步英文);`docs/changelog/index.md` 的 `[Unreleased]` 记录用户可读变更。 + +## 8. 分期执行计划(每期终点:`go build ./...` 通过 + 209 测试文件绿) + +| 期 | 交付 | 验证 | +| :-- | :-- | :-- | +| P0 | 基线测量:构建/测试记录、A 路 schema dump、路由清单快照 | 存档于 `docs/superpowers/specs/baseline/` | +| P1 | Go 树 `git mv` 入 `backend/`,module 改 `OpenFlare`,import 全量改写,Makefile/CI/Docker 路径 | `go build ./...`、四二进制产出、swagger diff 为空 | +| P2 | 上游 vendoring 成 `backend/Wavelet/`(第二 module)+ `share/` 骨架 + 同步脚本 | `go work sync`、`go build ./...`、架构门禁通过 | +| P3 | 迁移子系统:`w_schema_versions` 引擎接入、桥接、历史链归属、align 迁移、`migrate-audit` | **§5.4 三方 diff 全空** | +| P4 | `server` 插件化:`apps/openflare` + health/config/edge 控制面 → 插件子包 + 扩展点注册 | 路由对拍 + 业务测试绿 | +| P5 | `agent`/`relay`/`flared` 插件化 + `share` 落地(protocol/wsclient/edge) | 三 daemon 实跑冒烟 + JSON 配置兼容 | +| P6 | 平台分叉审计与回流:OpenFlare 对 admin/oauth/user/upload/cap/push 的定制逐项移植进 `backend/Wavelet/` | 分叉清单逐项关闭,无静默丢功能 | +| P7 | 工具链/CI/文档/changelog | `make code-check`、`make format` | +| P8 | 加固:`go test -race ./...`、`-shuffle=on`、四 profile 实跑、老库升级演练 | 全绿方可声明完成 | + +依赖:P3 依赖 P2;P4/P5 依赖 P2、P3;P6 可与 P4 并行但须在同一期收口。 + +## 9. 风险与对策 + +1. **fresh 安装与升级路径 schema 漂移**(桥接最大风险,`CREATE TABLE IF NOT EXISTS` 会静默保留旧表)→ §5.4 三方 diff 门禁 + `migrate-audit` 常驻 CI。 +2. **平台分叉被静默丢弃**(OpenFlare 的 admin/oauth/push 定制)→ P6 强制逐项清单,禁止以"上游没有"为由删除行为。 +3. **多节点同时迁移竞争**(上游无 session locker)→ 桥接与迁移阶段 advisory lock。 +4. **29.6k 行业务塞进单插件导致不可维护**→ `server` 内部按子域物理分包(§4.1),插件数保持 4 不变。 +5. **CI/Docker 路径漏改导致发布链断裂**(release 工作流含硬编码 ldflags 模块路径)→ P1 一次性收口并跑 dry-run 构建。 +6. **规模**:151k 行、166 迁移文件,上游自身耗时 100+ 提交 → 分期增量、每期可编译可测;不以"部分完成"冒充目标达成。 + +## 10. 已定决策(可在复审时否定) + +- 布局:镜像 `backend/`,上游为第二 module(用户选定)。 +- 插件边界:每二进制 = 1 插件,四者全部走内核(用户选定)。 +- 迁移:保留历史 + 版本 stamp 桥接(用户选定)。 +- `share` 落点:`backend/Wavelet/share/`,OpenFlare 所有、同步排除(本设计采纳,备选见 §3.2)。 +- 桥接粒度:stamp 至 `MAX(已应用版本)` 而非逐条 76 行(版本号语义为「≤ max 即已应用」,逐条无额外收益且放大漂移面)。 +- ClickHouse:保持既有 `goose_clickhouse_version` 独立链,不并入插件版本表。 diff --git a/docs/superpowers/specs/baseline/routes-engine.txt b/docs/superpowers/specs/baseline/routes-engine.txt new file mode 100644 index 00000000..fb942364 --- /dev/null +++ b/docs/superpowers/specs/baseline/routes-engine.txt @@ -0,0 +1,256 @@ +DELETE /api/v1/admin/auth-sources/:id +DELETE /api/v1/admin/push/channels/:id +DELETE /api/v1/admin/push/events/:id +DELETE /api/v1/admin/tasks/schedules/:id +DELETE /api/v1/admin/templates/:key +DELETE /api/v1/admin/uploads/:id +DELETE /api/v1/admin/users/:id +DELETE /api/v1/upload/:id +DELETE /api/v1/user/access-tokens/:id +GET /api/health +GET /api/swagger/*any +GET /api/v1/admin/auth-sources +GET /api/v1/admin/cache/status +GET /api/v1/admin/db-export +GET /api/v1/admin/db-info +GET /api/v1/admin/db-manage/overview +GET /api/v1/admin/db-manage/table-data +GET /api/v1/admin/db-manage/tables +GET /api/v1/admin/logs +GET /api/v1/admin/logs/access +GET /api/v1/admin/logs/analytics +GET /api/v1/admin/logs/ws +GET /api/v1/admin/push/channels +GET /api/v1/admin/push/channels/definitions +GET /api/v1/admin/push/events +GET /api/v1/admin/push/events/builtin +GET /api/v1/admin/push/histories +GET /api/v1/admin/status +GET /api/v1/admin/status/log-database +GET /api/v1/admin/system-configs +GET /api/v1/admin/system-configs/:key +GET /api/v1/admin/tasks/executions +GET /api/v1/admin/tasks/executions/:id +GET /api/v1/admin/tasks/schedules +GET /api/v1/admin/tasks/types +GET /api/v1/admin/templates +GET /api/v1/admin/templates/:key +GET /api/v1/admin/update +GET /api/v1/admin/uploads +GET /api/v1/admin/uploads/download/:id +GET /api/v1/admin/uploads/stats +GET /api/v1/admin/uploads/types +GET /api/v1/admin/users +GET /api/v1/admin/users/:id +GET /api/v1/agent/config-versions/active +GET /api/v1/agent/pages/deployments/:deployment_id/hash +GET /api/v1/agent/pages/deployments/:deployment_id/package +GET /api/v1/agent/pages/projects/:project_id/latest/hash +GET /api/v1/agent/pages/projects/:project_id/latest/package +GET /api/v1/agent/ws +GET /api/v1/config/public +GET /api/v1/d/access-logs +GET /api/v1/d/access-logs/ +GET /api/v1/d/access-logs/folds +GET /api/v1/d/access-logs/folds/ip-summary +GET /api/v1/d/access-logs/ip-summary +GET /api/v1/d/access-logs/ip-summary/analysis +GET /api/v1/d/access-logs/ip-summary/trend +GET /api/v1/d/access-logs/overview +GET /api/v1/d/acme-accounts/default +GET /api/v1/d/apply-logs +GET /api/v1/d/apply-logs/ +GET /api/v1/d/cloudflare/connection +GET /api/v1/d/cloudflare/domains/available +GET /api/v1/d/cloudflare/groups +GET /api/v1/d/cloudflare/groups/ +GET /api/v1/d/cloudflare/groups/:id +GET /api/v1/d/cloudflare/groups/:id/members +GET /api/v1/d/cloudflare/overview +GET /api/v1/d/config-versions +GET /api/v1/d/config-versions/ +GET /api/v1/d/config-versions/:id +GET /api/v1/d/config-versions/active +GET /api/v1/d/config-versions/diff +GET /api/v1/d/config-versions/preview +GET /api/v1/d/dashboard/overview +GET /api/v1/d/dns-accounts +GET /api/v1/d/dns-accounts/ +GET /api/v1/d/nodes +GET /api/v1/d/nodes/ +GET /api/v1/d/nodes/:id/agent-release +GET /api/v1/d/nodes/:id/observability +GET /api/v1/d/nodes/bootstrap-token +GET /api/v1/d/option +GET /api/v1/d/option/ +GET /api/v1/d/origins +GET /api/v1/d/origins/ +GET /api/v1/d/origins/:id +GET /api/v1/d/pages +GET /api/v1/d/pages/ +GET /api/v1/d/pages/:id +GET /api/v1/d/pages/:id/deployments +GET /api/v1/d/pages/:id/source +GET /api/v1/d/pages/deployments/:deployment_id/files +GET /api/v1/d/proxy-routes +GET /api/v1/d/proxy-routes/ +GET /api/v1/d/proxy-routes/:id +GET /api/v1/d/status +GET /api/v1/d/tls-certificates +GET /api/v1/d/tls-certificates/ +GET /api/v1/d/tls-certificates/:id +GET /api/v1/d/tls-certificates/:id/content +GET /api/v1/d/waf/ip-groups +GET /api/v1/d/waf/ip-groups/:id +GET /api/v1/d/waf/rule-groups +GET /api/v1/d/waf/rule-groups/:id +GET /api/v1/d/waf/sites/:route_id/rule-groups +GET /api/v1/d/zones +GET /api/v1/d/zones/ +GET /api/v1/d/zones/:id/overview +GET /api/v1/d/zones/:id/stats +GET /api/v1/oauth/:source/authorize +GET /api/v1/oauth/external-accounts +GET /api/v1/oauth/login +GET /api/v1/oauth/logout +GET /api/v1/oauth/sources +GET /api/v1/oauth/user-info +GET /api/v1/relay/ws +GET /api/v1/tunnel/config/active +GET /api/v1/tunnel/ws +GET /api/v1/upload/download/:id +GET /api/v1/upload/my +GET /api/v1/user-info +GET /api/v1/user/access-tokens +GET /api/v1/user/logout +GET /api/v1/user/self +GET /f/:id +GET /robots.txt +POST /api/cap/challenge +POST /api/cap/redeem +POST /api/v1/admin/auth-sources +POST /api/v1/admin/cache/clear +POST /api/v1/admin/cache/config +POST /api/v1/admin/db-manage/query +POST /api/v1/admin/push/channels +POST /api/v1/admin/push/channels/test +POST /api/v1/admin/push/events +POST /api/v1/admin/push/events/:id/toggle +POST /api/v1/admin/push/test +POST /api/v1/admin/system-configs +POST /api/v1/admin/system-configs/smtp/test +POST /api/v1/admin/tasks/dispatch +POST /api/v1/admin/tasks/executions/:id/retry +POST /api/v1/admin/tasks/schedules +POST /api/v1/admin/templates +POST /api/v1/admin/update/apply +POST /api/v1/admin/uploads/download/batch +POST /api/v1/admin/users +POST /api/v1/agent/apply-logs +POST /api/v1/agent/nodes/heartbeat +POST /api/v1/agent/nodes/register +POST /api/v1/agent/waf/ip-groups/sync +POST /api/v1/d/access-logs/cleanup +POST /api/v1/d/apply-logs/cleanup +POST /api/v1/d/cloudflare/connection/clear +POST /api/v1/d/cloudflare/connection/verify +POST /api/v1/d/cloudflare/groups +POST /api/v1/d/cloudflare/groups/ +POST /api/v1/d/cloudflare/groups/:id/delete +POST /api/v1/d/cloudflare/groups/:id/members +POST /api/v1/d/cloudflare/groups/:id/members/:memberId/remove +POST /api/v1/d/cloudflare/groups/:id/members/:memberId/sync +POST /api/v1/d/cloudflare/groups/:id/members/:memberId/update +POST /api/v1/d/cloudflare/groups/:id/sync +POST /api/v1/d/cloudflare/groups/:id/update +POST /api/v1/d/config-versions/:id/activate +POST /api/v1/d/config-versions/cleanup +POST /api/v1/d/config-versions/publish +POST /api/v1/d/dns-accounts +POST /api/v1/d/dns-accounts/ +POST /api/v1/d/dns-accounts/:id/delete +POST /api/v1/d/dns-accounts/:id/update +POST /api/v1/d/nodes +POST /api/v1/d/nodes/ +POST /api/v1/d/nodes/:id/agent-update +POST /api/v1/d/nodes/:id/delete +POST /api/v1/d/nodes/:id/force-sync +POST /api/v1/d/nodes/:id/observability/cleanup +POST /api/v1/d/nodes/:id/openresty-restart +POST /api/v1/d/nodes/:id/update +POST /api/v1/d/nodes/bootstrap-token/rotate +POST /api/v1/d/option/geoip/lookup +POST /api/v1/d/option/update +POST /api/v1/d/option/update-batch +POST /api/v1/d/origins +POST /api/v1/d/origins/ +POST /api/v1/d/origins/:id/delete +POST /api/v1/d/origins/:id/update +POST /api/v1/d/pages +POST /api/v1/d/pages/ +POST /api/v1/d/pages/:id/delete +POST /api/v1/d/pages/:id/deployments/:deployment_id/activate +POST /api/v1/d/pages/:id/deployments/:deployment_id/delete +POST /api/v1/d/pages/:id/deployments/upload +POST /api/v1/d/pages/:id/deployments/upload-from-url +POST /api/v1/d/pages/:id/source/check +POST /api/v1/d/pages/:id/source/delete +POST /api/v1/d/pages/:id/source/sync +POST /api/v1/d/pages/:id/source/update +POST /api/v1/d/pages/:id/update +POST /api/v1/d/proxy-routes +POST /api/v1/d/proxy-routes/ +POST /api/v1/d/proxy-routes/:id/delete +POST /api/v1/d/proxy-routes/:id/update +POST /api/v1/d/tls-certificates +POST /api/v1/d/tls-certificates/ +POST /api/v1/d/tls-certificates/:id/convert-acme +POST /api/v1/d/tls-certificates/:id/delete +POST /api/v1/d/tls-certificates/:id/renew +POST /api/v1/d/tls-certificates/:id/update +POST /api/v1/d/tls-certificates/:id/update-acme +POST /api/v1/d/tls-certificates/apply +POST /api/v1/d/tls-certificates/import-file +POST /api/v1/d/uptimekuma/sync +POST /api/v1/d/waf/ip-groups +POST /api/v1/d/waf/ip-groups/:id/delete +POST /api/v1/d/waf/ip-groups/:id/sync +POST /api/v1/d/waf/ip-groups/:id/update +POST /api/v1/d/waf/ip-groups/test +POST /api/v1/d/waf/rule-groups +POST /api/v1/d/waf/rule-groups/:id/delete +POST /api/v1/d/waf/rule-groups/:id/graph +POST /api/v1/d/waf/rule-groups/:id/meta +POST /api/v1/d/waf/sites/:route_id/rule-groups +POST /api/v1/d/zones +POST /api/v1/d/zones/ +POST /api/v1/d/zones/:id/delete +POST /api/v1/d/zones/:id/domains +POST /api/v1/d/zones/:id/domains/:domainId/delete +POST /api/v1/d/zones/:id/domains/:domainId/update +POST /api/v1/d/zones/:id/update +POST /api/v1/oauth/callback +POST /api/v1/oauth/external-accounts/:id/delete +POST /api/v1/relay/heartbeat +POST /api/v1/tunnel/apply-log +POST /api/v1/tunnel/heartbeat +POST /api/v1/upload +POST /api/v1/upload/download/batch +POST /api/v1/user/access-tokens +POST /api/v1/user/access-tokens/:id/rotate +POST /api/v1/user/change-password +POST /api/v1/user/login +POST /api/v1/user/register +POST /api/v1/user/send-email-code +PUT /api/v1/admin/auth-sources/:id +PUT /api/v1/admin/auth-sources/:id/toggle +PUT /api/v1/admin/push/channels/:id +PUT /api/v1/admin/push/events/:id +PUT /api/v1/admin/system-configs/:key +PUT /api/v1/admin/tasks/schedules/:id +PUT /api/v1/admin/templates/:key +PUT /api/v1/admin/users/:id +PUT /api/v1/admin/users/:id/status +PUT /api/v1/d/cloudflare/connection +PUT /api/v1/upload/:id +PUT /api/v1/user/profile diff --git a/docs/superpowers/specs/baseline/routes.txt b/docs/superpowers/specs/baseline/routes.txt new file mode 100644 index 00000000..44dd10d2 --- /dev/null +++ b/docs/superpowers/specs/baseline/routes.txt @@ -0,0 +1,232 @@ +DELETE /api/v1/admin/auth-sources/{id} +DELETE /api/v1/admin/push/channels/{id} +DELETE /api/v1/admin/push/events/{id} +DELETE /api/v1/admin/tasks/schedules/{id} +DELETE /api/v1/admin/templates/{key} +DELETE /api/v1/admin/uploads/{id} +DELETE /api/v1/admin/users/{id} +DELETE /api/v1/upload/{id} +DELETE /api/v1/user/access-tokens/{id} +GET /api/health +GET /api/v1/admin/auth-sources +GET /api/v1/admin/cache/status +GET /api/v1/admin/db-export +GET /api/v1/admin/db-info +GET /api/v1/admin/db-manage/overview +GET /api/v1/admin/db-manage/tables +GET /api/v1/admin/logs +GET /api/v1/admin/logs/access +GET /api/v1/admin/logs/analytics +GET /api/v1/admin/logs/ws +GET /api/v1/admin/push/channels +GET /api/v1/admin/push/channels/definitions +GET /api/v1/admin/push/events +GET /api/v1/admin/push/events/builtin +GET /api/v1/admin/push/histories +GET /api/v1/admin/status +GET /api/v1/admin/status/log-database +GET /api/v1/admin/system-configs +GET /api/v1/admin/system-configs/{key} +GET /api/v1/admin/tasks/executions +GET /api/v1/admin/tasks/executions/{id} +GET /api/v1/admin/tasks/schedules +GET /api/v1/admin/tasks/types +GET /api/v1/admin/templates +GET /api/v1/admin/templates/{key} +GET /api/v1/admin/update +GET /api/v1/admin/uploads +GET /api/v1/admin/uploads/download/{id} +GET /api/v1/admin/uploads/stats +GET /api/v1/admin/uploads/types +GET /api/v1/admin/users +GET /api/v1/admin/users/{id} +GET /api/v1/agent/config-versions/active +GET /api/v1/agent/pages/deployments/{deployment_id}/hash +GET /api/v1/agent/pages/deployments/{deployment_id}/package +GET /api/v1/agent/pages/projects/{project_id}/latest/hash +GET /api/v1/agent/pages/projects/{project_id}/latest/package +GET /api/v1/agent/ws +GET /api/v1/config/public +GET /api/v1/d/access-logs +GET /api/v1/d/access-logs/folds +GET /api/v1/d/access-logs/folds/ip-summary +GET /api/v1/d/access-logs/ip-summary +GET /api/v1/d/access-logs/ip-summary/analysis +GET /api/v1/d/access-logs/ip-summary/trend +GET /api/v1/d/access-logs/overview +GET /api/v1/d/acme-accounts/default +GET /api/v1/d/apply-logs +GET /api/v1/d/cloudflare/connection +GET /api/v1/d/cloudflare/domains/available +GET /api/v1/d/cloudflare/groups +GET /api/v1/d/cloudflare/groups/{id} +GET /api/v1/d/cloudflare/groups/{id}/members +GET /api/v1/d/cloudflare/overview +GET /api/v1/d/config-versions +GET /api/v1/d/config-versions/active +GET /api/v1/d/config-versions/diff +GET /api/v1/d/config-versions/preview +GET /api/v1/d/config-versions/{id} +GET /api/v1/d/dashboard/overview +GET /api/v1/d/dns-accounts +GET /api/v1/d/nodes +GET /api/v1/d/nodes/bootstrap-token +GET /api/v1/d/nodes/{id}/agent-release +GET /api/v1/d/nodes/{id}/observability +GET /api/v1/d/option +GET /api/v1/d/origins +GET /api/v1/d/origins/{id} +GET /api/v1/d/pages +GET /api/v1/d/pages/deployments/{deployment_id}/files +GET /api/v1/d/pages/{id} +GET /api/v1/d/pages/{id}/deployments +GET /api/v1/d/pages/{id}/source +GET /api/v1/d/proxy-routes +GET /api/v1/d/proxy-routes/{id} +GET /api/v1/d/status +GET /api/v1/d/tls-certificates +GET /api/v1/d/tls-certificates/{id} +GET /api/v1/d/tls-certificates/{id}/content +GET /api/v1/d/waf/ip-groups +GET /api/v1/d/waf/ip-groups/{id} +GET /api/v1/d/waf/rule-groups +GET /api/v1/d/waf/rule-groups/{id} +GET /api/v1/d/waf/sites/{route_id}/rule-groups +GET /api/v1/d/zones +GET /api/v1/d/zones/{id}/overview +GET /api/v1/d/zones/{id}/stats +GET /api/v1/oauth/external-accounts +GET /api/v1/oauth/login +GET /api/v1/oauth/logout +GET /api/v1/oauth/sources +GET /api/v1/oauth/user-info +GET /api/v1/oauth/{source}/authorize +GET /api/v1/relay/ws +GET /api/v1/tunnel/config/active +GET /api/v1/tunnel/ws +GET /api/v1/upload/my +GET /api/v1/user-info +GET /api/v1/user/access-tokens +GET /api/v1/user/logout +GET /api/v1/user/self +GET /f/{id} +GET /robots.txt +POST /api/cap/challenge +POST /api/cap/redeem +POST /api/v1/admin/auth-sources +POST /api/v1/admin/cache/clear +POST /api/v1/admin/cache/config +POST /api/v1/admin/db-manage/query +POST /api/v1/admin/push/channels +POST /api/v1/admin/push/channels/test +POST /api/v1/admin/push/events +POST /api/v1/admin/push/events/{id}/toggle +POST /api/v1/admin/push/test +POST /api/v1/admin/system-configs +POST /api/v1/admin/system-configs/smtp/test +POST /api/v1/admin/tasks/dispatch +POST /api/v1/admin/tasks/executions/{id}/retry +POST /api/v1/admin/tasks/schedules +POST /api/v1/admin/templates +POST /api/v1/admin/update/apply +POST /api/v1/admin/uploads/download/batch +POST /api/v1/admin/users +POST /api/v1/agent/apply-logs +POST /api/v1/agent/nodes/heartbeat +POST /api/v1/agent/nodes/register +POST /api/v1/agent/waf/ip-groups/sync +POST /api/v1/d/access-logs/cleanup +POST /api/v1/d/apply-logs/cleanup +POST /api/v1/d/cloudflare/connection/clear +POST /api/v1/d/cloudflare/connection/verify +POST /api/v1/d/cloudflare/groups +POST /api/v1/d/cloudflare/groups/{id}/delete +POST /api/v1/d/cloudflare/groups/{id}/members +POST /api/v1/d/cloudflare/groups/{id}/members/{memberId}/remove +POST /api/v1/d/cloudflare/groups/{id}/members/{memberId}/sync +POST /api/v1/d/cloudflare/groups/{id}/members/{memberId}/update +POST /api/v1/d/cloudflare/groups/{id}/sync +POST /api/v1/d/cloudflare/groups/{id}/update +POST /api/v1/d/config-versions/cleanup +POST /api/v1/d/config-versions/publish +POST /api/v1/d/config-versions/{id}/activate +POST /api/v1/d/dns-accounts +POST /api/v1/d/dns-accounts/{id}/delete +POST /api/v1/d/dns-accounts/{id}/update +POST /api/v1/d/nodes +POST /api/v1/d/nodes/bootstrap-token/rotate +POST /api/v1/d/nodes/{id}/agent-update +POST /api/v1/d/nodes/{id}/delete +POST /api/v1/d/nodes/{id}/force-sync +POST /api/v1/d/nodes/{id}/observability/cleanup +POST /api/v1/d/nodes/{id}/openresty-restart +POST /api/v1/d/nodes/{id}/update +POST /api/v1/d/option/geoip/lookup +POST /api/v1/d/option/update +POST /api/v1/d/option/update-batch +POST /api/v1/d/origins +POST /api/v1/d/origins/{id}/delete +POST /api/v1/d/origins/{id}/update +POST /api/v1/d/pages +POST /api/v1/d/pages/{id}/delete +POST /api/v1/d/pages/{id}/deployments/upload +POST /api/v1/d/pages/{id}/deployments/upload-from-url +POST /api/v1/d/pages/{id}/deployments/{deployment_id}/activate +POST /api/v1/d/pages/{id}/deployments/{deployment_id}/delete +POST /api/v1/d/pages/{id}/source/check +POST /api/v1/d/pages/{id}/source/delete +POST /api/v1/d/pages/{id}/source/sync +POST /api/v1/d/pages/{id}/source/update +POST /api/v1/d/pages/{id}/update +POST /api/v1/d/proxy-routes +POST /api/v1/d/proxy-routes/{id}/delete +POST /api/v1/d/proxy-routes/{id}/update +POST /api/v1/d/tls-certificates +POST /api/v1/d/tls-certificates/apply +POST /api/v1/d/tls-certificates/import-file +POST /api/v1/d/tls-certificates/{id}/convert-acme +POST /api/v1/d/tls-certificates/{id}/delete +POST /api/v1/d/tls-certificates/{id}/renew +POST /api/v1/d/tls-certificates/{id}/update +POST /api/v1/d/tls-certificates/{id}/update-acme +POST /api/v1/d/uptimekuma/sync +POST /api/v1/d/waf/ip-groups +POST /api/v1/d/waf/ip-groups/test +POST /api/v1/d/waf/ip-groups/{id}/delete +POST /api/v1/d/waf/ip-groups/{id}/sync +POST /api/v1/d/waf/ip-groups/{id}/update +POST /api/v1/d/waf/rule-groups +POST /api/v1/d/waf/rule-groups/{id}/delete +POST /api/v1/d/waf/rule-groups/{id}/graph +POST /api/v1/d/waf/rule-groups/{id}/meta +POST /api/v1/d/waf/sites/{route_id}/rule-groups +POST /api/v1/d/zones +POST /api/v1/d/zones/{id}/delete +POST /api/v1/d/zones/{id}/domains +POST /api/v1/d/zones/{id}/domains/{domainId}/delete +POST /api/v1/d/zones/{id}/domains/{domainId}/update +POST /api/v1/d/zones/{id}/update +POST /api/v1/oauth/callback +POST /api/v1/oauth/external-accounts/{id}/delete +POST /api/v1/relay/heartbeat +POST /api/v1/tunnel/apply-log +POST /api/v1/tunnel/heartbeat +POST /api/v1/upload +POST /api/v1/user/access-tokens +POST /api/v1/user/access-tokens/{id}/rotate +POST /api/v1/user/change-password +POST /api/v1/user/login +POST /api/v1/user/register +POST /api/v1/user/send-email-code +PUT /api/v1/admin/auth-sources/{id} +PUT /api/v1/admin/auth-sources/{id}/toggle +PUT /api/v1/admin/push/channels/{id} +PUT /api/v1/admin/push/events/{id} +PUT /api/v1/admin/system-configs/{key} +PUT /api/v1/admin/tasks/schedules/{id} +PUT /api/v1/admin/templates/{key} +PUT /api/v1/admin/users/{id} +PUT /api/v1/admin/users/{id}/status +PUT /api/v1/d/cloudflare/connection +PUT /api/v1/upload/{id} +PUT /api/v1/user/profile diff --git a/docs/superpowers/specs/baseline/schema-A.sql b/docs/superpowers/specs/baseline/schema-A.sql new file mode 100644 index 00000000..53f76824 --- /dev/null +++ b/docs/superpowers/specs/baseline/schema-A.sql @@ -0,0 +1,785 @@ +-- index idx_of_apply_logs_created_at +CREATE INDEX idx_of_apply_logs_created_at ON of_apply_logs(created_at); +-- index idx_of_apply_logs_node_id +CREATE INDEX idx_of_apply_logs_node_id ON of_apply_logs(node_id); +-- index idx_of_cf_connections_dns_account_id +CREATE INDEX idx_of_cf_connections_dns_account_id ON of_cf_connections (dns_account_id); +-- index idx_of_cf_pointing_groups_active_node_id +CREATE INDEX idx_of_cf_pointing_groups_active_node_id ON of_cf_pointing_groups (active_node_id); +-- index idx_of_cf_pointing_groups_backup_node_id +CREATE INDEX idx_of_cf_pointing_groups_backup_node_id ON of_cf_pointing_groups (backup_node_id); +-- index idx_of_cf_pointing_groups_primary_node_id +CREATE INDEX idx_of_cf_pointing_groups_primary_node_id ON of_cf_pointing_groups (primary_node_id); +-- index idx_of_cf_pointing_members_group_id +CREATE INDEX idx_of_cf_pointing_members_group_id ON of_cf_pointing_members (group_id); +-- index idx_of_cf_pointing_members_zone_domain_id +CREATE UNIQUE INDEX idx_of_cf_pointing_members_zone_domain_id ON of_cf_pointing_members (zone_domain_id); +-- index idx_of_config_versions_is_active +CREATE INDEX idx_of_config_versions_is_active ON of_config_versions (is_active); +-- index idx_of_node_access_logs_host +CREATE INDEX idx_of_node_access_logs_host ON of_node_access_logs (host, logged_at DESC); +-- index idx_of_node_access_logs_host_lower +CREATE INDEX idx_of_node_access_logs_host_lower ON of_node_access_logs (lower(trim(host))); +-- index idx_of_node_access_logs_logged_at +CREATE INDEX idx_of_node_access_logs_logged_at ON of_node_access_logs (logged_at DESC, id DESC); +-- index idx_of_node_access_logs_node_id +CREATE INDEX idx_of_node_access_logs_node_id ON of_node_access_logs (node_id, logged_at DESC); +-- index idx_of_node_access_logs_remote_addr +CREATE INDEX idx_of_node_access_logs_remote_addr ON of_node_access_logs (remote_addr, logged_at DESC); +-- index idx_of_node_access_logs_status_code +CREATE INDEX idx_of_node_access_logs_status_code ON of_node_access_logs (status_code, logged_at DESC); +-- index idx_of_node_edge_health_node +CREATE INDEX idx_of_node_edge_health_node ON of_node_edge_health (node_id, captured_at DESC); +-- index idx_of_node_health_events_event_type +CREATE INDEX idx_of_node_health_events_event_type ON of_node_health_events (event_type); +-- index idx_of_node_health_events_first_triggered_at +CREATE INDEX idx_of_node_health_events_first_triggered_at ON of_node_health_events (first_triggered_at); +-- index idx_of_node_health_events_last_triggered_at +CREATE INDEX idx_of_node_health_events_last_triggered_at ON of_node_health_events (last_triggered_at); +-- index idx_of_node_health_events_node_id +CREATE INDEX idx_of_node_health_events_node_id ON of_node_health_events (node_id); +-- index idx_of_node_health_events_reported_at +CREATE INDEX idx_of_node_health_events_reported_at ON of_node_health_events (reported_at); +-- index idx_of_node_health_events_resolved_at +CREATE INDEX idx_of_node_health_events_resolved_at ON of_node_health_events (resolved_at); +-- index idx_of_node_health_events_status +CREATE INDEX idx_of_node_health_events_status ON of_node_health_events (status); +-- index idx_of_node_metric_snapshots_node +CREATE INDEX idx_of_node_metric_snapshots_node ON of_node_metric_snapshots (node_id, captured_at DESC); +-- index idx_of_node_obs_frpc_node +CREATE INDEX idx_of_node_obs_frpc_node ON of_node_obs_frpc (node_id, captured_at DESC); +-- index idx_of_node_obs_frps_node +CREATE INDEX idx_of_node_obs_frps_node ON of_node_obs_frps (node_id, captured_at DESC); +-- index idx_of_node_system_profiles_node_id +CREATE UNIQUE INDEX idx_of_node_system_profiles_node_id ON of_node_system_profiles (node_id); +-- index idx_of_node_system_profiles_reported_at +CREATE INDEX idx_of_node_system_profiles_reported_at ON of_node_system_profiles (reported_at); +-- index idx_of_nodes_access_token +CREATE INDEX idx_of_nodes_access_token ON of_nodes (access_token); +-- index idx_of_nodes_node_id +CREATE UNIQUE INDEX idx_of_nodes_node_id ON of_nodes (node_id); +-- index idx_of_origins_address +CREATE UNIQUE INDEX idx_of_origins_address ON of_origins (address); +-- index idx_of_pages_deployment_files_deployment_id +CREATE INDEX idx_of_pages_deployment_files_deployment_id ON of_pages_deployment_files (deployment_id); +-- index idx_of_pages_deployments_checksum +CREATE INDEX idx_of_pages_deployments_checksum ON of_pages_deployments (checksum); +-- index idx_of_pages_deployments_project_id +CREATE INDEX idx_of_pages_deployments_project_id ON of_pages_deployments (project_id); +-- index idx_of_pages_deployments_project_number +CREATE UNIQUE INDEX idx_of_pages_deployments_project_number + ON of_pages_deployments (project_id, deployment_number); +-- index idx_of_pages_deployments_source_revision +CREATE UNIQUE INDEX idx_of_pages_deployments_source_revision + ON of_pages_deployments (project_id, source_identity, source_revision) + WHERE source_identity IS NOT NULL AND source_revision IS NOT NULL; +-- index idx_of_pages_deployments_status +CREATE INDEX idx_of_pages_deployments_status ON of_pages_deployments (status); +-- index idx_of_pages_deployments_upload_id +CREATE INDEX idx_of_pages_deployments_upload_id ON of_pages_deployments (upload_id); +-- index idx_of_pages_project_source_runtime_next_check_at +CREATE INDEX idx_of_pages_project_source_runtime_next_check_at + ON of_pages_project_source_runtime (next_check_at); +-- index idx_of_pages_project_sources_project_id +CREATE UNIQUE INDEX idx_of_pages_project_sources_project_id + ON of_pages_project_sources (project_id); +-- index idx_of_pages_projects_active_deployment_id +CREATE INDEX idx_of_pages_projects_active_deployment_id ON of_pages_projects (active_deployment_id); +-- index idx_of_pages_projects_slug +CREATE UNIQUE INDEX idx_of_pages_projects_slug ON of_pages_projects (slug); +-- index idx_of_proxy_routes_origin_id +CREATE INDEX idx_of_proxy_routes_origin_id ON of_proxy_routes (origin_id); +-- index idx_of_proxy_routes_pages_project_id +CREATE INDEX idx_of_proxy_routes_pages_project_id ON of_proxy_routes (pages_project_id); +-- index idx_of_proxy_routes_site_name +CREATE UNIQUE INDEX idx_of_proxy_routes_site_name ON of_proxy_routes (site_name); +-- index idx_of_proxy_routes_tunnel_node_id +CREATE INDEX idx_of_proxy_routes_tunnel_node_id ON of_proxy_routes (tunnel_node_id); +-- index idx_of_tls_certificates_name +CREATE UNIQUE INDEX idx_of_tls_certificates_name ON of_tls_certificates (name); +-- index idx_of_waf_group_route +CREATE UNIQUE INDEX idx_of_waf_group_route ON of_waf_rule_group_bindings (rule_group_id, proxy_route_id); +-- index idx_of_waf_ip_groups_next_sync_at +CREATE INDEX idx_of_waf_ip_groups_next_sync_at ON of_waf_ip_groups (next_sync_at); +-- index idx_of_waf_ip_groups_type +CREATE INDEX idx_of_waf_ip_groups_type ON of_waf_ip_groups (type); +-- index idx_of_waf_rule_group_bindings_proxy_route_id +CREATE INDEX idx_of_waf_rule_group_bindings_proxy_route_id ON of_waf_rule_group_bindings (proxy_route_id); +-- index idx_of_waf_rule_groups_is_global +CREATE INDEX idx_of_waf_rule_groups_is_global ON of_waf_rule_groups (is_global); +-- index idx_of_zone_domains_cert_id +CREATE INDEX idx_of_zone_domains_cert_id ON of_zone_domains (cert_id); +-- index idx_of_zone_domains_domain +CREATE UNIQUE INDEX idx_of_zone_domains_domain ON of_zone_domains (domain); +-- index idx_of_zone_domains_proxy_route_id +CREATE INDEX idx_of_zone_domains_proxy_route_id ON of_zone_domains (proxy_route_id); +-- index idx_of_zone_domains_zone_id +CREATE INDEX idx_of_zone_domains_zone_id ON of_zone_domains (zone_id); +-- index idx_of_zones_domain +CREATE UNIQUE INDEX idx_of_zones_domain ON of_zones (domain); +-- index idx_w_access_tokens_user_id +CREATE INDEX idx_w_access_tokens_user_id ON w_access_tokens (user_id); +-- index idx_w_auth_sources_is_active +CREATE INDEX idx_w_auth_sources_is_active ON w_auth_sources (is_active); +-- index idx_w_external_accounts_auth_source_id +CREATE INDEX idx_w_external_accounts_auth_source_id ON w_external_accounts (auth_source_id); +-- index idx_w_external_accounts_source_external +CREATE UNIQUE INDEX idx_w_external_accounts_source_external ON w_external_accounts (auth_source_id, external_id); +-- index idx_w_external_accounts_user_id +CREATE INDEX idx_w_external_accounts_user_id ON w_external_accounts (user_id); +-- index idx_w_push_channels_enabled +CREATE INDEX idx_w_push_channels_enabled ON w_push_channels(enabled); +-- index idx_w_push_channels_name +CREATE INDEX idx_w_push_channels_name ON w_push_channels(name); +-- index idx_w_push_events_enabled +CREATE INDEX idx_w_push_events_enabled ON w_push_events(enabled); +-- index idx_w_push_events_task_type +CREATE INDEX idx_w_push_events_task_type ON w_push_events(task_type); +-- index idx_w_push_histories_created +CREATE INDEX idx_w_push_histories_created ON w_push_histories(created_at); +-- index idx_w_push_histories_event +CREATE INDEX idx_w_push_histories_event ON w_push_histories(event_key); +-- index idx_w_schedules_is_active +CREATE INDEX idx_w_schedules_is_active ON w_schedules (is_active); +-- index idx_w_task_executions_created_at +CREATE INDEX idx_w_task_executions_created_at ON w_task_executions (created_at); +-- index idx_w_task_executions_started_at +CREATE INDEX idx_w_task_executions_started_at ON w_task_executions (started_at); +-- index idx_w_task_executions_status +CREATE INDEX idx_w_task_executions_status ON w_task_executions (status); +-- index idx_w_task_executions_task_type +CREATE INDEX idx_w_task_executions_task_type ON w_task_executions (task_type); +-- index idx_w_templates_created_at +CREATE INDEX idx_w_templates_created_at ON w_templates (created_at); +-- index idx_w_templates_is_system +CREATE INDEX idx_w_templates_is_system ON w_templates (is_system); +-- index idx_w_templates_updated_at +CREATE INDEX idx_w_templates_updated_at ON w_templates (updated_at); +-- index idx_w_uploads_file_path +CREATE INDEX idx_w_uploads_file_path ON w_uploads (file_path); +-- index idx_w_uploads_hash +CREATE INDEX idx_w_uploads_hash ON w_uploads (hash); +-- index idx_w_uploads_hash_file_size_status +CREATE INDEX idx_w_uploads_hash_file_size_status ON w_uploads (hash, file_size, status); +-- index idx_w_uploads_status_created_at +CREATE INDEX idx_w_uploads_status_created_at ON w_uploads (status, created_at); +-- index idx_w_uploads_type +CREATE INDEX idx_w_uploads_type ON w_uploads (type); +-- index idx_w_uploads_user_id +CREATE INDEX idx_w_uploads_user_id ON w_uploads (user_id); +-- index idx_w_user_access_logs_user_id +CREATE INDEX idx_w_user_access_logs_user_id ON w_user_access_logs (user_id, created_at DESC); +-- index idx_w_users_created_at +CREATE INDEX idx_w_users_created_at ON w_users (created_at); +-- index idx_w_users_email +CREATE INDEX idx_w_users_email ON w_users (email); +-- index idx_w_users_is_active +CREATE INDEX idx_w_users_is_active ON w_users (is_active); +-- index idx_w_users_last_login_at +CREATE INDEX idx_w_users_last_login_at ON w_users (last_login_at); +-- table of_acme_accounts +CREATE TABLE of_acme_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL DEFAULT '', + url TEXT NOT NULL DEFAULT '', + private_key TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +-- table of_apply_logs +CREATE TABLE of_apply_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id TEXT NOT NULL, + version TEXT NOT NULL, + result TEXT NOT NULL, + message TEXT, + checksum TEXT NOT NULL DEFAULT '', + main_config_checksum TEXT NOT NULL DEFAULT '', + route_config_checksum TEXT NOT NULL DEFAULT '', + support_file_count INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL +); +-- table of_cf_connections +CREATE TABLE of_cf_connections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL DEFAULT '', + dns_account_id INTEGER, + authorization TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT '', + verified_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +-- table of_cf_pointing_groups +CREATE TABLE of_cf_pointing_groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + primary_node_id INTEGER NOT NULL, + backup_node_id INTEGER, + active_node_id INTEGER NOT NULL, + default_proxied BOOLEAN NOT NULL DEFAULT FALSE, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +-- table of_cf_pointing_members +CREATE TABLE of_cf_pointing_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + group_id INTEGER NOT NULL, + zone_domain_id INTEGER NOT NULL, + proxied BOOLEAN NOT NULL DEFAULT FALSE, + cf_zone_id TEXT NOT NULL DEFAULT '', + cf_record_id TEXT NOT NULL DEFAULT '', + desired_ip TEXT NOT NULL DEFAULT '', + sync_status TEXT NOT NULL DEFAULT 'pending', + last_error TEXT NOT NULL DEFAULT '', + synced_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +-- table of_config_versions +CREATE TABLE "of_config_versions" ( + version VARCHAR(32) PRIMARY KEY, + snapshot_json TEXT NOT NULL, + main_config TEXT NOT NULL DEFAULT '', + rendered_config TEXT NOT NULL, + support_files_json TEXT NOT NULL DEFAULT '[]', + checksum VARCHAR(64) NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT FALSE, + created_by VARCHAR(64) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +-- table of_dns_accounts +CREATE TABLE of_dns_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type TEXT NOT NULL, + authorization TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +-- table of_node_access_logs +CREATE TABLE of_node_access_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id TEXT NOT NULL DEFAULT '', + logged_at DATETIME NOT NULL, + remote_addr TEXT NOT NULL DEFAULT '', + region TEXT NOT NULL DEFAULT '', + host TEXT NOT NULL DEFAULT '', + path TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + cache_status TEXT NOT NULL DEFAULT '', + status_code INTEGER NOT NULL DEFAULT 0, + bytes_sent INTEGER NOT NULL DEFAULT 0, + request_length INTEGER NOT NULL DEFAULT 0, + request_time_ms INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +-- table of_node_edge_health +CREATE TABLE of_node_edge_health ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id TEXT NOT NULL DEFAULT '', + captured_at DATETIME NOT NULL, + status TEXT NOT NULL DEFAULT '', + connections INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +-- table of_node_health_events +CREATE TABLE of_node_health_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id TEXT NOT NULL, + event_type TEXT NOT NULL, + severity TEXT NOT NULL, + status TEXT NOT NULL, + message TEXT, + first_triggered_at DATETIME NOT NULL, + last_triggered_at DATETIME NOT NULL, + reported_at DATETIME NOT NULL, + resolved_at DATETIME, + metadata_json TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +-- table of_node_metric_snapshots +CREATE TABLE of_node_metric_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id TEXT NOT NULL DEFAULT '', + captured_at DATETIME NOT NULL, + cpu_usage_percent REAL NOT NULL DEFAULT 0, + memory_used_bytes INTEGER NOT NULL DEFAULT 0, + memory_total_bytes INTEGER NOT NULL DEFAULT 0, + storage_used_bytes INTEGER NOT NULL DEFAULT 0, + storage_total_bytes INTEGER NOT NULL DEFAULT 0, + disk_read_bytes INTEGER NOT NULL DEFAULT 0, + disk_write_bytes INTEGER NOT NULL DEFAULT 0, + network_rx_bytes INTEGER NOT NULL DEFAULT 0, + network_tx_bytes INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +-- table of_node_obs_frpc +CREATE TABLE of_node_obs_frpc ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id TEXT NOT NULL DEFAULT '', + captured_at DATETIME NOT NULL, + tunnel_status TEXT NOT NULL DEFAULT '', + connected_relays_count INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +-- table of_node_obs_frps +CREATE TABLE of_node_obs_frps ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id TEXT NOT NULL DEFAULT '', + captured_at DATETIME NOT NULL, + frps_connections INTEGER NOT NULL DEFAULT 0, + frps_proxy_count INTEGER NOT NULL DEFAULT 0, + frps_client_count INTEGER NOT NULL DEFAULT 0, + frps_proxies TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +-- table of_node_system_profiles +CREATE TABLE of_node_system_profiles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id TEXT NOT NULL, + hostname TEXT NOT NULL DEFAULT '', + os_name TEXT NOT NULL DEFAULT '', + os_version TEXT NOT NULL DEFAULT '', + kernel_version TEXT NOT NULL DEFAULT '', + architecture TEXT NOT NULL DEFAULT '', + cpu_model TEXT NOT NULL DEFAULT '', + cpu_cores INTEGER NOT NULL DEFAULT 0, + total_memory_bytes INTEGER NOT NULL DEFAULT 0, + total_disk_bytes INTEGER NOT NULL DEFAULT 0, + uptime_seconds INTEGER NOT NULL DEFAULT 0, + reported_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +-- table of_nodes +CREATE TABLE of_nodes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id TEXT NOT NULL, + name TEXT NOT NULL, + ip TEXT NOT NULL DEFAULT '', + ip_manual_override INTEGER NOT NULL DEFAULT 0, + geo_name TEXT NOT NULL DEFAULT '', + geo_latitude REAL, + geo_longitude REAL, + geo_manual_override INTEGER NOT NULL DEFAULT 0, + access_token TEXT NOT NULL DEFAULT '', + auto_update_enabled INTEGER NOT NULL DEFAULT 0, + update_requested INTEGER NOT NULL DEFAULT 0, + update_channel TEXT NOT NULL DEFAULT 'stable', + update_tag TEXT NOT NULL DEFAULT '', + restart_openresty_requested INTEGER NOT NULL DEFAULT 0, + version TEXT NOT NULL DEFAULT '', + ext_version TEXT NOT NULL DEFAULT '', + openresty_status TEXT NOT NULL DEFAULT 'unknown', + openresty_message TEXT, + status TEXT NOT NULL DEFAULT 'offline', + current_version TEXT NOT NULL DEFAULT '', + last_seen_at DATETIME, + last_error TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + node_type TEXT NOT NULL DEFAULT 'edge_node', + relay_bind_port INTEGER NOT NULL DEFAULT 0, + relay_vhost_http_port INTEGER NOT NULL DEFAULT 0, + relay_auth_token TEXT NOT NULL DEFAULT '', + relay_agent_access_addr TEXT NOT NULL DEFAULT '', + relay_client_access_addr TEXT NOT NULL DEFAULT '', + relay_client_proxy_url TEXT NOT NULL DEFAULT '', + capabilities_json TEXT NOT NULL DEFAULT '[]', + relay_status TEXT NOT NULL DEFAULT 'unknown', + relay_web_server_enabled INTEGER NOT NULL DEFAULT 0 +); +-- table of_origins +CREATE TABLE of_origins ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + address TEXT NOT NULL, + remark TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +-- table of_pages_deployment_files +CREATE TABLE of_pages_deployment_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + deployment_id INTEGER NOT NULL, + path TEXT NOT NULL, + size INTEGER NOT NULL DEFAULT 0, + checksum TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +-- table of_pages_deployments +CREATE TABLE of_pages_deployments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL, + deployment_number INTEGER NOT NULL, + checksum TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'uploaded', + artifact_path TEXT NOT NULL, + file_count INTEGER NOT NULL DEFAULT 0, + total_size INTEGER NOT NULL DEFAULT 0, + created_by TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + activated_at DATETIME +, upload_id INTEGER NOT NULL DEFAULT 0, source_type TEXT NOT NULL DEFAULT '', source_identity TEXT, source_revision TEXT, source_label TEXT NOT NULL DEFAULT '', source_meta TEXT NOT NULL DEFAULT '', trigger_type TEXT NOT NULL DEFAULT ''); +-- table of_pages_project_source_runtime +CREATE TABLE of_pages_project_source_runtime ( + source_id INTEGER PRIMARY KEY, + etag TEXT NOT NULL DEFAULT '', + last_seen_revision TEXT NOT NULL DEFAULT '', + last_seen_detail TEXT NOT NULL DEFAULT '', + last_applied_revision TEXT NOT NULL DEFAULT '', + last_applied_detail TEXT NOT NULL DEFAULT '', + sync_status TEXT NOT NULL DEFAULT '', + last_error TEXT NOT NULL DEFAULT '', + last_checked_at DATETIME, + last_synced_at DATETIME, + next_check_at DATETIME, + lease_expires_at DATETIME, + lease_token TEXT NOT NULL DEFAULT '', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +-- table of_pages_project_sources +CREATE TABLE of_pages_project_sources ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL, + source_type TEXT NOT NULL DEFAULT '', + remote_url TEXT NOT NULL DEFAULT '', + allow_insecure INTEGER NOT NULL DEFAULT 0, + github_repository TEXT NOT NULL DEFAULT '', + release_selector TEXT NOT NULL DEFAULT '', + release_tag TEXT NOT NULL DEFAULT '', + asset_name TEXT NOT NULL DEFAULT '', + auto_update_enabled INTEGER NOT NULL DEFAULT 0, + check_interval_minutes INTEGER NOT NULL DEFAULT 0, + config_version INTEGER NOT NULL DEFAULT 0, + source_identity TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +-- table of_pages_projects +CREATE TABLE of_pages_projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + slug TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 1, + spa_fallback_enabled INTEGER NOT NULL DEFAULT 0, + spa_fallback_path TEXT NOT NULL DEFAULT '/index.html', + api_proxy_enabled INTEGER NOT NULL DEFAULT 0, + api_proxy_path TEXT NOT NULL DEFAULT '', + api_proxy_pass TEXT NOT NULL DEFAULT '', + api_proxy_rewrite TEXT NOT NULL DEFAULT '', + active_deployment_id INTEGER, + root_dir TEXT NOT NULL DEFAULT '', + entry_file TEXT NOT NULL DEFAULT 'index.html', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +, content_config_version INTEGER NOT NULL DEFAULT 0); +-- table of_proxy_routes +CREATE TABLE "of_proxy_routes" ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_name TEXT NOT NULL DEFAULT '', + origin_id INTEGER, + origin_url TEXT NOT NULL, + origin_host TEXT NOT NULL DEFAULT '', + upstreams TEXT NOT NULL DEFAULT '[]', + enabled INTEGER NOT NULL DEFAULT 1, + enable_https INTEGER NOT NULL DEFAULT 0, + redirect_http INTEGER NOT NULL DEFAULT 0, + limit_conn_per_server INTEGER NOT NULL DEFAULT 0, + limit_conn_per_ip INTEGER NOT NULL DEFAULT 0, + limit_rate TEXT NOT NULL DEFAULT '', + cache_enabled INTEGER NOT NULL DEFAULT 0, + cache_policy TEXT NOT NULL DEFAULT '', + cache_rules TEXT NOT NULL DEFAULT '[]', + custom_headers TEXT NOT NULL DEFAULT '[]', + basic_auth_enabled INTEGER NOT NULL DEFAULT 0, + basic_auth_username TEXT NOT NULL DEFAULT '', + basic_auth_password TEXT NOT NULL DEFAULT '', + upstream_type TEXT NOT NULL DEFAULT 'direct', + tunnel_node_id INTEGER, + tunnel_target_addr TEXT NOT NULL DEFAULT '', + tunnel_target_protocol TEXT NOT NULL DEFAULT '', + pages_project_id INTEGER, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +, limit_req_per_ip VARCHAR(32) NOT NULL DEFAULT ''); +-- table of_tls_certificates +CREATE TABLE of_tls_certificates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + cert_pem TEXT NOT NULL, + key_pem TEXT NOT NULL, + not_before DATETIME, + not_after DATETIME, + remark TEXT NOT NULL DEFAULT '', + provider TEXT NOT NULL DEFAULT 'upload', + acme_account_id INTEGER NOT NULL DEFAULT 0, + dns_account_id INTEGER NOT NULL DEFAULT 0, + key_algorithm TEXT NOT NULL DEFAULT '', + auto_renew INTEGER NOT NULL DEFAULT 0, + primary_domain TEXT NOT NULL DEFAULT '', + other_domains TEXT NOT NULL DEFAULT '', + disable_cname INTEGER NOT NULL DEFAULT 0, + skip_dns INTEGER NOT NULL DEFAULT 0, + dns1 TEXT NOT NULL DEFAULT '', + dns2 TEXT NOT NULL DEFAULT '', + apply_status TEXT NOT NULL DEFAULT 'ready', + apply_message TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +-- table of_waf_ip_groups +CREATE TABLE of_waf_ip_groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + ip_list TEXT NOT NULL DEFAULT '[]', + auto_config TEXT NOT NULL DEFAULT '{}', + ext_ips TEXT NOT NULL DEFAULT '[]', + subscription_url TEXT NOT NULL DEFAULT '', + subscription_format TEXT NOT NULL DEFAULT 'text', + subscription_mapping_rule TEXT NOT NULL DEFAULT '', + sync_interval_minutes INTEGER NOT NULL DEFAULT 1440, + last_synced_at DATETIME, + next_sync_at DATETIME, + last_sync_status TEXT NOT NULL DEFAULT '', + last_sync_message TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +-- table of_waf_rule_group_bindings +CREATE TABLE of_waf_rule_group_bindings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rule_group_id INTEGER NOT NULL, + proxy_route_id INTEGER NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +, sequence INTEGER NOT NULL DEFAULT 0); +-- table of_waf_rule_groups +CREATE TABLE of_waf_rule_groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + is_global BOOLEAN NOT NULL DEFAULT FALSE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +, graph TEXT NOT NULL DEFAULT '', revision INTEGER NOT NULL DEFAULT 1); +-- table of_zone_domains +CREATE TABLE of_zone_domains ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + zone_id INTEGER NOT NULL, + proxy_route_id INTEGER, + domain TEXT NOT NULL, + cert_id INTEGER, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +-- table of_zones +CREATE TABLE of_zones ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + domain TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +-- table w_access_tokens +CREATE TABLE "w_access_tokens" ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id BIGINT NOT NULL, + name VARCHAR(128) NOT NULL, + token_hash VARCHAR(64) NOT NULL UNIQUE, + masked_token VARCHAR(64) NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +, is_admin BOOLEAN NOT NULL DEFAULT 0); +-- table w_auth_sources +CREATE TABLE "w_auth_sources" ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(80) NOT NULL UNIQUE, + type VARCHAR(20) NOT NULL, + display_name VARCHAR(100), + is_active BOOLEAN NOT NULL DEFAULT FALSE, + client_id VARCHAR(255), + client_secret VARCHAR(1024), + openid_discovery_url VARCHAR(1024), + scopes VARCHAR(255), + icon_url VARCHAR(1024), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +-- table w_external_accounts +CREATE TABLE "w_external_accounts" ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + auth_source_id BIGINT, + user_id BIGINT NOT NULL, + external_id VARCHAR(255) NOT NULL, + external_username VARCHAR(255), + email VARCHAR(255), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +-- table w_push_channels +CREATE TABLE w_push_channels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + description TEXT, + type TEXT NOT NULL DEFAULT 'custom', + token TEXT, + url TEXT NOT NULL, + other TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL +); +-- table w_push_events +CREATE TABLE w_push_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_key TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + channels TEXT NOT NULL, + targets TEXT NOT NULL, + template TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL +, task_type VARCHAR(100) NOT NULL DEFAULT ''); +-- table w_push_histories +CREATE TABLE w_push_histories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_key TEXT NOT NULL, + channel TEXT NOT NULL, + target TEXT NOT NULL, + title TEXT NOT NULL, + content TEXT NOT NULL, + level TEXT NOT NULL, + status TEXT NOT NULL, + error_msg TEXT, + created_at DATETIME NOT NULL +); +-- table w_schedules +CREATE TABLE "w_schedules" ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(128) NOT NULL, + task_type VARCHAR(64) NOT NULL, + cron VARCHAR(64) NOT NULL, + payload TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +-- table w_schedules_backup_of_database_auto_cleanup +CREATE TABLE w_schedules_backup_of_database_auto_cleanup( + id INT, + name TEXT, + task_type TEXT, + cron TEXT, + payload TEXT, + is_active NUM, + created_at NUM, + updated_at NUM +); +-- table w_system_configs +CREATE TABLE "w_system_configs" ( + key VARCHAR(64) PRIMARY KEY, + value TEXT NOT NULL, + type VARCHAR(32) NOT NULL DEFAULT 'system', + visibility INTEGER NOT NULL DEFAULT 0, + description VARCHAR(255), + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +-- table w_task_executions +CREATE TABLE "w_task_executions" ( + id BIGINT PRIMARY KEY, + task_id VARCHAR(128) NOT NULL UNIQUE, + task_type VARCHAR(64) NOT NULL, + task_name VARCHAR(128), + status VARCHAR(32) NOT NULL, + retryable BOOLEAN NOT NULL DEFAULT FALSE, + max_retry INTEGER NOT NULL DEFAULT 0, + retry_count INTEGER NOT NULL DEFAULT 0, + log TEXT, + error_message TEXT, + result TEXT, + started_at DATETIME, + finished_at DATETIME, + duration BIGINT, + payload TEXT, + triggered_by VARCHAR(32) NOT NULL DEFAULT 'system', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +-- table w_templates +CREATE TABLE "w_templates" ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key VARCHAR(80) NOT NULL UNIQUE, + name VARCHAR(100) NOT NULL, + type VARCHAR(20) NOT NULL DEFAULT 'email', + subject VARCHAR(255), + content TEXT NOT NULL, + description VARCHAR(255), + is_system BOOLEAN NOT NULL DEFAULT FALSE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +-- table w_upload_stats +CREATE TABLE w_upload_stats ( + dimension VARCHAR(32) NOT NULL, + stat_key VARCHAR(64) NOT NULL DEFAULT '', + file_count BIGINT NOT NULL DEFAULT 0, + file_size BIGINT NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (dimension, stat_key) +); +-- table w_uploads +CREATE TABLE "w_uploads" ( + id BIGINT PRIMARY KEY, + user_id BIGINT NOT NULL, + file_name VARCHAR(255) NOT NULL, + file_path VARCHAR(500) NOT NULL, + file_size BIGINT NOT NULL, + mime_type VARCHAR(100) NOT NULL, + extension VARCHAR(50) NOT NULL, + hash VARCHAR(64), + type VARCHAR(50) NOT NULL, + status VARCHAR(20) NOT NULL, + metadata JSON, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +, access_mode INTEGER NOT NULL DEFAULT 0); +-- table w_user_access_logs +CREATE TABLE w_user_access_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL DEFAULT 0, + path TEXT NOT NULL DEFAULT '', + method TEXT NOT NULL DEFAULT '', + ip TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + headers TEXT NOT NULL DEFAULT '', + status INTEGER NOT NULL DEFAULT 0, + latency INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +-- table w_users +CREATE TABLE "w_users" ( + id BIGINT PRIMARY KEY, + username VARCHAR(64) UNIQUE, + password VARCHAR(255), + nickname VARCHAR(255), + email VARCHAR(255), + avatar_url VARCHAR(255), + is_active BOOLEAN DEFAULT TRUE, + is_admin BOOLEAN DEFAULT FALSE, + bio VARCHAR(500), + phone VARCHAR(32), + gender VARCHAR(16), + website VARCHAR(255), + location VARCHAR(255), + last_login_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +-- total objects (excl. bookkeeping): 130 diff --git a/docs/superpowers/specs/baseline/versions-A.txt b/docs/superpowers/specs/baseline/versions-A.txt new file mode 100644 index 00000000..990755f1 --- /dev/null +++ b/docs/superpowers/specs/baseline/versions-A.txt @@ -0,0 +1,77 @@ +0 +202606050001 +202606090001 +202606100001 +202606100002 +202606110001 +202606110002 +202606110003 +202606110004 +202606110005 +202606120001 +202606120002 +202606130001 +202606130002 +202606130003 +202606140001 +202606140003 +202606140004 +202606140005 +202606150001 +202606160001 +202606170001 +202606170002 +202606170003 +202606180001 +202606190001 +202606190002 +202606190003 +202606190004 +202606190005 +202606190006 +202606190007 +202606190008 +202606190009 +202606190010 +202606190011 +202606190012 +202606190013 +202606190014 +202606200001 +202606200002 +202606200003 +202606200004 +202606200005 +202606200006 +202606200007 +202606220001 +202606220002 +202606220003 +202606220004 +202606220005 +202606270001 +202606300001 +202607120001 +202607120002 +202607130001 +202607140001 +202607150001 +202607150002 +202607150003 +202607170001 +202607180001 +202607190001 +202607190002 +202607190003 +202607200001 +202608040001 +202608060001 +202608060002 +202608070001 +202608070002 +202608080001 +202608080002 +202608080003 +202608090001 +202608090002 +202608090003 diff --git a/docs/swagger.json b/docs/swagger.json index 798c9a09..00149de2 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -50,7 +50,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_internal_apps_cap.ChallengeResponse" + "$ref": "#/definitions/Wavelet_OpenFlare_plugins_server_cap.ChallengeResponse" } } } @@ -102,7 +102,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_internal_apps_cap.RedeemResponse" + "$ref": "#/definitions/Wavelet_OpenFlare_plugins_server_cap.RedeemResponse" } } } @@ -4515,7 +4515,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.PagesDeploymentHashResponse" + "$ref": "#/definitions/Wavelet_OpenFlare_share_protocol.PagesDeploymentHashResponse" } } } @@ -4619,7 +4619,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.PagesProjectLatestHashResponse" + "$ref": "#/definitions/Wavelet_OpenFlare_share_protocol.PagesProjectLatestHashResponse" } } } @@ -14253,7 +14253,7 @@ } }, "400": { - "description": "用户名或密码错误、帐号已禁用等", + "description": "用户名或密码错误", "schema": { "$ref": "#/definitions/response.Any" } @@ -14590,6 +14590,316 @@ } }, "definitions": { + "Wavelet_OpenFlare_plugins_server_cap.ChallengeResponse": { + "type": "object", + "properties": { + "challenge": { + "type": "object", + "properties": { + "c": { + "type": "integer" + }, + "d": { + "type": "integer" + }, + "s": { + "type": "integer" + } + } + }, + "expires": { + "description": "ms timestamp", + "type": "integer" + }, + "token": { + "type": "string" + } + } + }, + "Wavelet_OpenFlare_plugins_server_cap.RedeemResponse": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "expires": { + "type": "integer" + }, + "success": { + "type": "boolean" + }, + "token": { + "type": "string" + } + } + }, + "Wavelet_OpenFlare_plugins_server_model_analytics.ClickHouseOperationalStats": { + "type": "object", + "properties": { + "active_parts": { + "type": "integer" + }, + "async_insert_bytes": { + "type": "integer" + }, + "async_insert_queue": { + "type": "integer" + }, + "batch_writers": { + "description": "BatchWriters reports in-process queue depth/drops/flush errors for CH writers.", + "type": "array", + "items": { + "$ref": "#/definitions/analytics.BatchWriterStats" + } + }, + "database": { + "type": "string" + }, + "pending_mutations": { + "type": "integer" + }, + "total_rows": { + "type": "integer" + } + } + }, + "Wavelet_OpenFlare_share_protocol.ActiveConfigMeta": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "Wavelet_OpenFlare_share_protocol.BufferedObservabilityRecord": { + "type": "object", + "properties": { + "access_logs": { + "type": "array", + "items": { + "$ref": "#/definitions/Wavelet_OpenFlare_share_protocol.NodeAccessLog" + } + }, + "captured_at_unix": { + "type": "integer" + }, + "edge_health": { + "$ref": "#/definitions/Wavelet_OpenFlare_share_protocol.NodeEdgeHealth" + }, + "host_metrics": { + "$ref": "#/definitions/Wavelet_OpenFlare_share_protocol.NodeMetricSnapshot" + } + } + }, + "Wavelet_OpenFlare_share_protocol.NodeAccessLog": { + "type": "object", + "properties": { + "bytes_sent": { + "description": "body bytes = 已提供数据", + "type": "integer" + }, + "cache_status": { + "description": "$upstream_cache_status", + "type": "string" + }, + "host": { + "type": "string" + }, + "logged_at_unix": { + "type": "integer" + }, + "path": { + "type": "string" + }, + "remote_addr": { + "type": "string" + }, + "request_length": { + "description": "接收数据", + "type": "integer" + }, + "request_time_ms": { + "description": "optional", + "type": "integer" + }, + "status_code": { + "type": "integer" + }, + "user_agent": { + "type": "string" + } + } + }, + "Wavelet_OpenFlare_share_protocol.NodeEdgeHealth": { + "type": "object", + "properties": { + "captured_at_unix": { + "type": "integer" + }, + "connections": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "Wavelet_OpenFlare_share_protocol.NodeHealthEvent": { + "type": "object", + "properties": { + "event_type": { + "type": "string" + }, + "message": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "severity": { + "type": "string" + }, + "triggered_at_unix": { + "type": "integer" + } + } + }, + "Wavelet_OpenFlare_share_protocol.NodeMetricSnapshot": { + "type": "object", + "properties": { + "captured_at_unix": { + "type": "integer" + }, + "cpu_usage_percent": { + "type": "number" + }, + "disk_read_bytes": { + "type": "integer" + }, + "disk_write_bytes": { + "type": "integer" + }, + "memory_total_bytes": { + "type": "integer" + }, + "memory_used_bytes": { + "type": "integer" + }, + "storage_total_bytes": { + "type": "integer" + }, + "storage_used_bytes": { + "type": "integer" + } + } + }, + "Wavelet_OpenFlare_share_protocol.NodeSystemProfile": { + "type": "object", + "properties": { + "architecture": { + "type": "string" + }, + "cpu_cores": { + "type": "integer" + }, + "cpu_model": { + "type": "string" + }, + "hostname": { + "type": "string" + }, + "kernel_version": { + "type": "string" + }, + "os_name": { + "type": "string" + }, + "os_version": { + "type": "string" + }, + "reported_at_unix": { + "type": "integer" + }, + "total_disk_bytes": { + "type": "integer" + }, + "total_memory_bytes": { + "type": "integer" + }, + "uptime_seconds": { + "type": "integer" + } + } + }, + "Wavelet_OpenFlare_share_protocol.PagesDeploymentHashResponse": { + "type": "object", + "properties": { + "deployment_id": { + "type": "integer" + }, + "hash": { + "type": "string" + } + } + }, + "Wavelet_OpenFlare_share_protocol.PagesProjectLatestHashResponse": { + "type": "object", + "properties": { + "deployment_id": { + "type": "integer" + }, + "file_count": { + "type": "integer" + }, + "hash": { + "type": "string" + }, + "package_size": { + "type": "integer" + }, + "project_id": { + "type": "integer" + }, + "total_size": { + "type": "integer" + } + } + }, + "Wavelet_OpenFlare_share_protocol.WAFIPGroup": { + "type": "object", + "properties": { + "checksum": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "ip_list": { + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, "agent.ActiveConfigMeta": { "type": "object", "properties": { @@ -14679,20 +14989,20 @@ "access_logs": { "type": "array", "items": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeAccessLog" + "$ref": "#/definitions/Wavelet_OpenFlare_share_protocol.NodeAccessLog" } }, "buffered": { "type": "array", "items": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.BufferedObservabilityRecord" + "$ref": "#/definitions/Wavelet_OpenFlare_share_protocol.BufferedObservabilityRecord" } }, "current_version": { "type": "string" }, "edge_health": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeEdgeHealth" + "$ref": "#/definitions/Wavelet_OpenFlare_share_protocol.NodeEdgeHealth" }, "ext_version": { "type": "string" @@ -14700,11 +15010,11 @@ "health_events": { "type": "array", "items": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeHealthEvent" + "$ref": "#/definitions/Wavelet_OpenFlare_share_protocol.NodeHealthEvent" } }, "host_metrics": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeMetricSnapshot" + "$ref": "#/definitions/Wavelet_OpenFlare_share_protocol.NodeMetricSnapshot" }, "ip": { "type": "string" @@ -14725,7 +15035,7 @@ "type": "string" }, "profile": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeSystemProfile" + "$ref": "#/definitions/Wavelet_OpenFlare_share_protocol.NodeSystemProfile" }, "schema_version": { "type": "integer" @@ -14844,7 +15154,7 @@ "groups": { "type": "array", "items": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.WAFIPGroup" + "$ref": "#/definitions/Wavelet_OpenFlare_share_protocol.WAFIPGroup" } } } @@ -15669,7 +15979,7 @@ "type": "object", "properties": { "active_config": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.ActiveConfigMeta" + "$ref": "#/definitions/Wavelet_OpenFlare_share_protocol.ActiveConfigMeta" }, "tunnel_settings": { "$ref": "#/definitions/protocol.RelaySettings" @@ -15699,316 +16009,6 @@ } } }, - "github_com_Rain-kl_Wavelet_internal_apps_cap.ChallengeResponse": { - "type": "object", - "properties": { - "challenge": { - "type": "object", - "properties": { - "c": { - "type": "integer" - }, - "d": { - "type": "integer" - }, - "s": { - "type": "integer" - } - } - }, - "expires": { - "description": "ms timestamp", - "type": "integer" - }, - "token": { - "type": "string" - } - } - }, - "github_com_Rain-kl_Wavelet_internal_apps_cap.RedeemResponse": { - "type": "object", - "properties": { - "error": { - "type": "string" - }, - "expires": { - "type": "integer" - }, - "success": { - "type": "boolean" - }, - "token": { - "type": "string" - } - } - }, - "github_com_Rain-kl_Wavelet_internal_model_analytics.ClickHouseOperationalStats": { - "type": "object", - "properties": { - "active_parts": { - "type": "integer" - }, - "async_insert_bytes": { - "type": "integer" - }, - "async_insert_queue": { - "type": "integer" - }, - "batch_writers": { - "description": "BatchWriters reports in-process queue depth/drops/flush errors for CH writers.", - "type": "array", - "items": { - "$ref": "#/definitions/analytics.BatchWriterStats" - } - }, - "database": { - "type": "string" - }, - "pending_mutations": { - "type": "integer" - }, - "total_rows": { - "type": "integer" - } - } - }, - "github_com_Rain-kl_Wavelet_pkg_protocol.ActiveConfigMeta": { - "type": "object", - "properties": { - "checksum": { - "type": "string" - }, - "version": { - "type": "string" - } - } - }, - "github_com_Rain-kl_Wavelet_pkg_protocol.BufferedObservabilityRecord": { - "type": "object", - "properties": { - "access_logs": { - "type": "array", - "items": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeAccessLog" - } - }, - "captured_at_unix": { - "type": "integer" - }, - "edge_health": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeEdgeHealth" - }, - "host_metrics": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeMetricSnapshot" - } - } - }, - "github_com_Rain-kl_Wavelet_pkg_protocol.NodeAccessLog": { - "type": "object", - "properties": { - "bytes_sent": { - "description": "body bytes = 已提供数据", - "type": "integer" - }, - "cache_status": { - "description": "$upstream_cache_status", - "type": "string" - }, - "host": { - "type": "string" - }, - "logged_at_unix": { - "type": "integer" - }, - "path": { - "type": "string" - }, - "remote_addr": { - "type": "string" - }, - "request_length": { - "description": "接收数据", - "type": "integer" - }, - "request_time_ms": { - "description": "optional", - "type": "integer" - }, - "status_code": { - "type": "integer" - }, - "user_agent": { - "type": "string" - } - } - }, - "github_com_Rain-kl_Wavelet_pkg_protocol.NodeEdgeHealth": { - "type": "object", - "properties": { - "captured_at_unix": { - "type": "integer" - }, - "connections": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "status": { - "type": "string" - } - } - }, - "github_com_Rain-kl_Wavelet_pkg_protocol.NodeHealthEvent": { - "type": "object", - "properties": { - "event_type": { - "type": "string" - }, - "message": { - "type": "string" - }, - "metadata": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "severity": { - "type": "string" - }, - "triggered_at_unix": { - "type": "integer" - } - } - }, - "github_com_Rain-kl_Wavelet_pkg_protocol.NodeMetricSnapshot": { - "type": "object", - "properties": { - "captured_at_unix": { - "type": "integer" - }, - "cpu_usage_percent": { - "type": "number" - }, - "disk_read_bytes": { - "type": "integer" - }, - "disk_write_bytes": { - "type": "integer" - }, - "memory_total_bytes": { - "type": "integer" - }, - "memory_used_bytes": { - "type": "integer" - }, - "storage_total_bytes": { - "type": "integer" - }, - "storage_used_bytes": { - "type": "integer" - } - } - }, - "github_com_Rain-kl_Wavelet_pkg_protocol.NodeSystemProfile": { - "type": "object", - "properties": { - "architecture": { - "type": "string" - }, - "cpu_cores": { - "type": "integer" - }, - "cpu_model": { - "type": "string" - }, - "hostname": { - "type": "string" - }, - "kernel_version": { - "type": "string" - }, - "os_name": { - "type": "string" - }, - "os_version": { - "type": "string" - }, - "reported_at_unix": { - "type": "integer" - }, - "total_disk_bytes": { - "type": "integer" - }, - "total_memory_bytes": { - "type": "integer" - }, - "uptime_seconds": { - "type": "integer" - } - } - }, - "github_com_Rain-kl_Wavelet_pkg_protocol.PagesDeploymentHashResponse": { - "type": "object", - "properties": { - "deployment_id": { - "type": "integer" - }, - "hash": { - "type": "string" - } - } - }, - "github_com_Rain-kl_Wavelet_pkg_protocol.PagesProjectLatestHashResponse": { - "type": "object", - "properties": { - "deployment_id": { - "type": "integer" - }, - "file_count": { - "type": "integer" - }, - "hash": { - "type": "string" - }, - "package_size": { - "type": "integer" - }, - "project_id": { - "type": "integer" - }, - "total_size": { - "type": "integer" - } - } - }, - "github_com_Rain-kl_Wavelet_pkg_protocol.WAFIPGroup": { - "type": "object", - "properties": { - "checksum": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "id": { - "type": "integer" - }, - "ip_list": { - "type": "array", - "items": { - "type": "string" - } - }, - "name": { - "type": "string" - }, - "type": { - "type": "string" - } - } - }, "handler.batchDownloadRequest": { "type": "object", "required": [ @@ -19787,7 +19787,7 @@ } }, "clickhouse": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_internal_model_analytics.ClickHouseOperationalStats" + "$ref": "#/definitions/Wavelet_OpenFlare_plugins_server_model_analytics.ClickHouseOperationalStats" }, "migration": { "description": "idle | migrating", diff --git a/docs/swagger.yaml b/docs/swagger.yaml index def997e1..47fc0e58 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,5 +1,210 @@ basePath: / definitions: + Wavelet_OpenFlare_plugins_server_cap.ChallengeResponse: + properties: + challenge: + properties: + c: + type: integer + d: + type: integer + s: + type: integer + type: object + expires: + description: ms timestamp + type: integer + token: + type: string + type: object + Wavelet_OpenFlare_plugins_server_cap.RedeemResponse: + properties: + error: + type: string + expires: + type: integer + success: + type: boolean + token: + type: string + type: object + Wavelet_OpenFlare_plugins_server_model_analytics.ClickHouseOperationalStats: + properties: + active_parts: + type: integer + async_insert_bytes: + type: integer + async_insert_queue: + type: integer + batch_writers: + description: BatchWriters reports in-process queue depth/drops/flush errors + for CH writers. + items: + $ref: '#/definitions/analytics.BatchWriterStats' + type: array + database: + type: string + pending_mutations: + type: integer + total_rows: + type: integer + type: object + Wavelet_OpenFlare_share_protocol.ActiveConfigMeta: + properties: + checksum: + type: string + version: + type: string + type: object + Wavelet_OpenFlare_share_protocol.BufferedObservabilityRecord: + properties: + access_logs: + items: + $ref: '#/definitions/Wavelet_OpenFlare_share_protocol.NodeAccessLog' + type: array + captured_at_unix: + type: integer + edge_health: + $ref: '#/definitions/Wavelet_OpenFlare_share_protocol.NodeEdgeHealth' + host_metrics: + $ref: '#/definitions/Wavelet_OpenFlare_share_protocol.NodeMetricSnapshot' + type: object + Wavelet_OpenFlare_share_protocol.NodeAccessLog: + properties: + bytes_sent: + description: body bytes = 已提供数据 + type: integer + cache_status: + description: $upstream_cache_status + type: string + host: + type: string + logged_at_unix: + type: integer + path: + type: string + remote_addr: + type: string + request_length: + description: 接收数据 + type: integer + request_time_ms: + description: optional + type: integer + status_code: + type: integer + user_agent: + type: string + type: object + Wavelet_OpenFlare_share_protocol.NodeEdgeHealth: + properties: + captured_at_unix: + type: integer + connections: + type: integer + message: + type: string + status: + type: string + type: object + Wavelet_OpenFlare_share_protocol.NodeHealthEvent: + properties: + event_type: + type: string + message: + type: string + metadata: + additionalProperties: + type: string + type: object + severity: + type: string + triggered_at_unix: + type: integer + type: object + Wavelet_OpenFlare_share_protocol.NodeMetricSnapshot: + properties: + captured_at_unix: + type: integer + cpu_usage_percent: + type: number + disk_read_bytes: + type: integer + disk_write_bytes: + type: integer + memory_total_bytes: + type: integer + memory_used_bytes: + type: integer + storage_total_bytes: + type: integer + storage_used_bytes: + type: integer + type: object + Wavelet_OpenFlare_share_protocol.NodeSystemProfile: + properties: + architecture: + type: string + cpu_cores: + type: integer + cpu_model: + type: string + hostname: + type: string + kernel_version: + type: string + os_name: + type: string + os_version: + type: string + reported_at_unix: + type: integer + total_disk_bytes: + type: integer + total_memory_bytes: + type: integer + uptime_seconds: + type: integer + type: object + Wavelet_OpenFlare_share_protocol.PagesDeploymentHashResponse: + properties: + deployment_id: + type: integer + hash: + type: string + type: object + Wavelet_OpenFlare_share_protocol.PagesProjectLatestHashResponse: + properties: + deployment_id: + type: integer + file_count: + type: integer + hash: + type: string + package_size: + type: integer + project_id: + type: integer + total_size: + type: integer + type: object + Wavelet_OpenFlare_share_protocol.WAFIPGroup: + properties: + checksum: + type: string + enabled: + type: boolean + id: + type: integer + ip_list: + items: + type: string + type: array + name: + type: string + type: + type: string + type: object agent.ActiveConfigMeta: properties: checksum: @@ -58,24 +263,24 @@ definitions: properties: access_logs: items: - $ref: '#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeAccessLog' + $ref: '#/definitions/Wavelet_OpenFlare_share_protocol.NodeAccessLog' type: array buffered: items: - $ref: '#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.BufferedObservabilityRecord' + $ref: '#/definitions/Wavelet_OpenFlare_share_protocol.BufferedObservabilityRecord' type: array current_version: type: string edge_health: - $ref: '#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeEdgeHealth' + $ref: '#/definitions/Wavelet_OpenFlare_share_protocol.NodeEdgeHealth' ext_version: type: string health_events: items: - $ref: '#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeHealthEvent' + $ref: '#/definitions/Wavelet_OpenFlare_share_protocol.NodeHealthEvent' type: array host_metrics: - $ref: '#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeMetricSnapshot' + $ref: '#/definitions/Wavelet_OpenFlare_share_protocol.NodeMetricSnapshot' ip: type: string last_error: @@ -89,7 +294,7 @@ definitions: openresty_status: type: string profile: - $ref: '#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeSystemProfile' + $ref: '#/definitions/Wavelet_OpenFlare_share_protocol.NodeSystemProfile' schema_version: type: integer version: @@ -166,7 +371,7 @@ definitions: properties: groups: items: - $ref: '#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.WAFIPGroup' + $ref: '#/definitions/Wavelet_OpenFlare_share_protocol.WAFIPGroup' type: array type: object analytics.BatchWriterStats: @@ -708,7 +913,7 @@ definitions: flared.HeartbeatResponse: properties: active_config: - $ref: '#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.ActiveConfigMeta' + $ref: '#/definitions/Wavelet_OpenFlare_share_protocol.ActiveConfigMeta' tunnel_settings: $ref: '#/definitions/protocol.RelaySettings' type: object @@ -727,211 +932,6 @@ definitions: version: type: string type: object - github_com_Rain-kl_Wavelet_internal_apps_cap.ChallengeResponse: - properties: - challenge: - properties: - c: - type: integer - d: - type: integer - s: - type: integer - type: object - expires: - description: ms timestamp - type: integer - token: - type: string - type: object - github_com_Rain-kl_Wavelet_internal_apps_cap.RedeemResponse: - properties: - error: - type: string - expires: - type: integer - success: - type: boolean - token: - type: string - type: object - github_com_Rain-kl_Wavelet_internal_model_analytics.ClickHouseOperationalStats: - properties: - active_parts: - type: integer - async_insert_bytes: - type: integer - async_insert_queue: - type: integer - batch_writers: - description: BatchWriters reports in-process queue depth/drops/flush errors - for CH writers. - items: - $ref: '#/definitions/analytics.BatchWriterStats' - type: array - database: - type: string - pending_mutations: - type: integer - total_rows: - type: integer - type: object - github_com_Rain-kl_Wavelet_pkg_protocol.ActiveConfigMeta: - properties: - checksum: - type: string - version: - type: string - type: object - github_com_Rain-kl_Wavelet_pkg_protocol.BufferedObservabilityRecord: - properties: - access_logs: - items: - $ref: '#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeAccessLog' - type: array - captured_at_unix: - type: integer - edge_health: - $ref: '#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeEdgeHealth' - host_metrics: - $ref: '#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeMetricSnapshot' - type: object - github_com_Rain-kl_Wavelet_pkg_protocol.NodeAccessLog: - properties: - bytes_sent: - description: body bytes = 已提供数据 - type: integer - cache_status: - description: $upstream_cache_status - type: string - host: - type: string - logged_at_unix: - type: integer - path: - type: string - remote_addr: - type: string - request_length: - description: 接收数据 - type: integer - request_time_ms: - description: optional - type: integer - status_code: - type: integer - user_agent: - type: string - type: object - github_com_Rain-kl_Wavelet_pkg_protocol.NodeEdgeHealth: - properties: - captured_at_unix: - type: integer - connections: - type: integer - message: - type: string - status: - type: string - type: object - github_com_Rain-kl_Wavelet_pkg_protocol.NodeHealthEvent: - properties: - event_type: - type: string - message: - type: string - metadata: - additionalProperties: - type: string - type: object - severity: - type: string - triggered_at_unix: - type: integer - type: object - github_com_Rain-kl_Wavelet_pkg_protocol.NodeMetricSnapshot: - properties: - captured_at_unix: - type: integer - cpu_usage_percent: - type: number - disk_read_bytes: - type: integer - disk_write_bytes: - type: integer - memory_total_bytes: - type: integer - memory_used_bytes: - type: integer - storage_total_bytes: - type: integer - storage_used_bytes: - type: integer - type: object - github_com_Rain-kl_Wavelet_pkg_protocol.NodeSystemProfile: - properties: - architecture: - type: string - cpu_cores: - type: integer - cpu_model: - type: string - hostname: - type: string - kernel_version: - type: string - os_name: - type: string - os_version: - type: string - reported_at_unix: - type: integer - total_disk_bytes: - type: integer - total_memory_bytes: - type: integer - uptime_seconds: - type: integer - type: object - github_com_Rain-kl_Wavelet_pkg_protocol.PagesDeploymentHashResponse: - properties: - deployment_id: - type: integer - hash: - type: string - type: object - github_com_Rain-kl_Wavelet_pkg_protocol.PagesProjectLatestHashResponse: - properties: - deployment_id: - type: integer - file_count: - type: integer - hash: - type: string - package_size: - type: integer - project_id: - type: integer - total_size: - type: integer - type: object - github_com_Rain-kl_Wavelet_pkg_protocol.WAFIPGroup: - properties: - checksum: - type: string - enabled: - type: boolean - id: - type: integer - ip_list: - items: - type: string - type: array - name: - type: string - type: - type: string - type: object handler.batchDownloadRequest: properties: ids: @@ -3433,7 +3433,7 @@ definitions: type: string type: array clickhouse: - $ref: '#/definitions/github_com_Rain-kl_Wavelet_internal_model_analytics.ClickHouseOperationalStats' + $ref: '#/definitions/Wavelet_OpenFlare_plugins_server_model_analytics.ClickHouseOperationalStats' migration: description: idle | migrating type: string @@ -4357,7 +4357,7 @@ paths: - $ref: '#/definitions/response.Any' - properties: data: - $ref: '#/definitions/github_com_Rain-kl_Wavelet_internal_apps_cap.ChallengeResponse' + $ref: '#/definitions/Wavelet_OpenFlare_plugins_server_cap.ChallengeResponse' type: object "500": description: 内部服务错误 @@ -4388,7 +4388,7 @@ paths: - $ref: '#/definitions/response.Any' - properties: data: - $ref: '#/definitions/github_com_Rain-kl_Wavelet_internal_apps_cap.RedeemResponse' + $ref: '#/definitions/Wavelet_OpenFlare_plugins_server_cap.RedeemResponse' type: object "400": description: 参数错误或核销失败 @@ -7026,7 +7026,7 @@ paths: - $ref: '#/definitions/response.Any' - properties: data: - $ref: '#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.PagesDeploymentHashResponse' + $ref: '#/definitions/Wavelet_OpenFlare_share_protocol.PagesDeploymentHashResponse' type: object "400": description: 参数错误 @@ -7089,7 +7089,7 @@ paths: - $ref: '#/definitions/response.Any' - properties: data: - $ref: '#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.PagesProjectLatestHashResponse' + $ref: '#/definitions/Wavelet_OpenFlare_share_protocol.PagesProjectLatestHashResponse' type: object "400": description: 参数错误 @@ -12869,7 +12869,7 @@ paths: $ref: '#/definitions/oauth.BasicUserInfo' type: object "400": - description: 用户名或密码错误、帐号已禁用等 + description: 用户名或密码错误 schema: $ref: '#/definitions/response.Any' "500": diff --git a/go.sum b/go.sum deleted file mode 100644 index 3673c98f..00000000 --- a/go.sum +++ /dev/null @@ -1,495 +0,0 @@ -filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= -filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= -github.com/ClickHouse/ch-go v0.74.0 h1:uYs2m4wIt0ZHSM1E72rg0maCfzhR2V3xWb/vZEgpeWE= -github.com/ClickHouse/ch-go v0.74.0/go.mod h1:sZ/r+8ttZMjyrP9PuFbgoVbth1ywIu2LIQNA2vgko6M= -github.com/ClickHouse/clickhouse-go/v2 v2.48.0 h1:auzd4VkapQYhQF8F2Gog7s3x78Bi1JZmByxGbrw3C+4= -github.com/ClickHouse/clickhouse-go/v2 v2.48.0/go.mod h1:lBjUCPRG6RpRQdMbkXq+JV8rY0/O5lw+Z7jShgReFjM= -github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= -github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= -github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw= -github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= -github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.3 h1:mFFeD2k8QE+pYd1uFik2XG85+vilL5uj9P5mHQTM1Xk= -github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.3/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M= -github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= -github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= -github.com/aws/aws-sdk-go-v2 v1.43.4 h1:b9FTvbRwy+JCsfp2Wp6wV/KbOx3Aj7nkoFb2cRX0IhE= -github.com/aws/aws-sdk-go-v2 v1.43.4/go.mod h1:70vwSy16txshwG+g55WkpgPKDIByzHI8ccBsOteo3bQ= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.16 h1:aiuaKlDweRC5qExJondpWjOgyzMHpofpwspGXUtwn4c= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.16/go.mod h1:nG/LOlmox9BDe9HvQnXWzgcK8uKbgBMZ/Hp5pVt/21I= -github.com/aws/aws-sdk-go-v2/config v1.32.35 h1:UEzXuET8E42lxBPijuACu/tEK7v5lFPlk0Q+GT5WD9E= -github.com/aws/aws-sdk-go-v2/config v1.32.35/go.mod h1:KaMtJpFa2JlL2BStjjHQVwQpzZEmw+ND/EgVrfFoo2g= -github.com/aws/aws-sdk-go-v2/credentials v1.19.34 h1:y6GkSmcv5myd1ngrYbGmiLlwQqB6TQhOuN/tbSSuWDY= -github.com/aws/aws-sdk-go-v2/credentials v1.19.34/go.mod h1:w3dTcnDVoQIewjo7JG45hduAToikiIFLC4FIO7fndvw= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.35 h1:+S7kbJoLDDQ5tE+lHrUBgMkzC8NLgsaioS2F3dVoFAE= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.35/go.mod h1:Ak7xXviIARfFdNUJ9Etb0bdVDt/KAvKjMGJVLWXDzik= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35 h1:kzVuGlatQtYinwBJEEyLAbggepCoavosiaHHX9+fD+c= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35/go.mod h1:0yLx0yEI+SfqeJMPvOtIEFoZbiQYXMGszBueiutQyaI= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35 h1:WK6CjihTuLisCjSKKbildJ79sGZZgbBz3iNa7VsKIhU= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35/go.mod h1:KYleN57luLoe97R7vTnx8PMcVrr9gAcRECtOjl91DNg= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36 h1:jbGY4CXLzZElOXgGsexlC3Hi+3YM0rSmk4opFXKqg/k= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36/go.mod h1:uBu/9aKsS/UQGc72RAt3y54kjgYQxmhut8ZD2dXCDNE= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15 h1:JJLBQxwY+AFwuPAi5ivGc1ChnTdUt4cXMv7e76m2c/Y= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15/go.mod h1:lQknBIe78MVL0cQOQDlag8KGflMbMEVFx9mB6O8ENvk= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.28 h1:Q1TF1J9jVD+vFo0LzNnmNdQ9EAt52TS+MQlq9Ir+Yxo= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.28/go.mod h1:4KqXXC/p1hrotmouDFbrRoWaLy962b9PMUReCG6+uWo= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35 h1:BBEElKh4a+rKshvjrfpajTe9CbpZvrbb4Jkg2PB7RzA= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35/go.mod h1:zaZk983w//8beSruBVec/mr4CmDwgZitW/qzGhAAX0g= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.36 h1:EUIwBoN+q7UmhAejxgD27APiRjh1vwCFo53gSqdT0BM= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.36/go.mod h1:6u00gmlTGR6W0b2k9NBrld7MnOEmf1Spqx0VVt6AqyE= -github.com/aws/aws-sdk-go-v2/service/s3 v1.106.5 h1:HpN6GgZ3T8pSvRp81ZsgumNjlvRsa+9M0ZL2o6W4uLY= -github.com/aws/aws-sdk-go-v2/service/s3 v1.106.5/go.mod h1:5FTZoQxhmLEiCAtYVk6V+t0iS/B5yGZVLZ3Wq5FDJZI= -github.com/aws/aws-sdk-go-v2/service/signin v1.5.4 h1:cOJELVNrq5Q3Udry2GLuHUM7MhwpeaQRdYaoa6GI/yI= -github.com/aws/aws-sdk-go-v2/service/signin v1.5.4/go.mod h1:f4LxzKBtaTxD7xh3PiVg3CE1tchQemfmghaJr+NbK2c= -github.com/aws/aws-sdk-go-v2/service/sso v1.33.4 h1:AMW7a7S8iQaHjBYZdU3PCq4GKRPijTPRAc7e6XtEThY= -github.com/aws/aws-sdk-go-v2/service/sso v1.33.4/go.mod h1:QQNsFV1DVXoXcZt18FS8lI8rtUrlDyAuWZLQ5shunv4= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4 h1:AsbZcJAQPRmHDJG8K1N0pof/1zPWjVT8TFlTWuGLSvo= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4/go.mod h1:6imqztH0//t0mKbl6yWl7swSEl7F/w32oAmqB3vP1ag= -github.com/aws/aws-sdk-go-v2/service/sts v1.45.4 h1:w/AryDYMjSUANSQ2uoZxJovUsMTwWJNTv3IMex30Y+4= -github.com/aws/aws-sdk-go-v2/service/sts v1.45.4/go.mod h1:WeBiAa67azG7Su9Vf+ChGDBLiAozJCXzdjXiPBUwtbc= -github.com/aws/smithy-go v1.27.6 h1:0zjT8jgK3jbrTT7JJ3EE6JsMhX8JTrZ+f1sEndYDXrA= -github.com/aws/smithy-go v1.27.6/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= -github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU= -github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs= -github.com/bodgit/sevenzip v1.6.5 h1:7H7BxgmeX0j6UX42lH+KXQ92WgMQJ49DoocFdfHbCng= -github.com/bodgit/sevenzip v1.6.5/go.mod h1:GhuB6Lq1xCpP1sps+horjZ8lgiKPJcy2zUX3prla9wc= -github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4= -github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM= -github.com/boj/redistore v1.4.2 h1:44FVJnBTdzDV9VpaByCOaQs0ND8hzABD2xBHcAIbX9s= -github.com/boj/redistore v1.4.2/go.mod h1:jjh65GXAH+5lj29pPRnQHdRNOt/lmP0LOaK+fiG2Fu8= -github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= -github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= -github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= -github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= -github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0= -github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE= -github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= -github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= -github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo= -github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= -github.com/bytedance/sonic/loader v0.5.2 h1:0QtP1gevc1OZ6/H8Lb9BRZiCXd1Ftjd3OKuj1T1lBIo= -github.com/bytedance/sonic/loader v0.5.2/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= -github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= -github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI= -github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg= -github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE= -github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/deepteams/webp v1.2.7 h1:Oj3iXbZ0U4siY1KHFTF5T21uysZkmor8pT4E38mNK2o= -github.com/deepteams/webp v1.2.7/go.mod h1:J8Ap+HAixxpKKRN9IpEeSKlfvhsef1v43jKTO7m3f4c= -github.com/dgraph-io/ristretto/v2 v2.4.2 h1:x0cvjmUKxt764Yxdk2nr94we1AvPPAMh1rh5TQ+Jo80= -github.com/dgraph-io/ristretto/v2 v2.4.2/go.mod h1:0KsrXtXvnv0EqnzyowllbVJB8yBonswa2lTCK2gGo9E= -github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38= -github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM= -github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= -github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= -github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= -github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= -github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= -github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= -github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI= -github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ= -github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= -github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= -github.com/gin-contrib/sessions v1.1.0 h1:00mhHfNEGF5sP2fwxa98aRqj1FOJdL6IkR86n2hOiBo= -github.com/gin-contrib/sessions v1.1.0/go.mod h1:TyYZDIs6qCQg2SOoYPgMT9pAkmZceVNEJMcv5qbIy60= -github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko= -github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s= -github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= -github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= -github.com/glebarez/go-sqlite v1.23.0 h1:FyhIq4jqmgphQAUlY79zPldYGwISEZikaDfhiGWkkaI= -github.com/glebarez/go-sqlite v1.23.0/go.mod h1:IIYrOH3L0rHY3jb4IXOHoWdklNajSGUN2eJcvK8WrnI= -github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= -github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= -github.com/go-acme/lego/v4 v4.35.2 h1:uVQg+KC/yj9R2g7Q9W5wDqhvQvxV5SMu5eqFVoN5xZU= -github.com/go-acme/lego/v4 v4.35.2/go.mod h1:pX2jN5n8OphMGY1IaMjYm5DAEzguBaKRt8AvJAgJXpc= -github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= -github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= -github.com/go-faster/errors v0.8.0 h1:9T9eJrM+72dFk7n4DfhuaDDe6cyuFCSW2oNUkN77Yqc= -github.com/go-faster/errors v0.8.0/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= -github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= -github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= -github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s= -github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y= -github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY= -github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0= -github.com/go-openapi/spec v0.22.9 h1:/vKIFDcGKp0ktZWGbym/tJEWbk6/XOEmAVU0kqKMH+w= -github.com/go-openapi/spec v0.22.9/go.mod h1:b/mNUYIOQOyIiUzUzXEE8xzyZqf93KvM9hQGP91yfl0= -github.com/go-openapi/swag v0.28.0 h1:xkgbOSKj6DZziNpyqRRAOt3GJGtgjgsd2RoyT30VWuw= -github.com/go-openapi/swag/conv v0.28.0 h1:GtqqbyFe7vR5Y7ehxG9W6/OvrSFdf1OLeTGp40TqxH8= -github.com/go-openapi/swag/conv v0.28.0/go.mod h1:mbUE+mzctnhxi864m0Q07SpN8OowD9JhxmxuYvZZD/k= -github.com/go-openapi/swag/jsonutils v0.28.0 h1:YIch6FwO7RXzeAnbO8Tu7dWBZeUEH+4nA0HXltVTnv4= -github.com/go-openapi/swag/jsonutils v0.28.0/go.mod h1:CYM3WlTUcagR2ZoHdz54di/cbBqt82tuxuXgAjxw+mg= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0 h1:qV+VVUAx5Oro8WjVWpZeql7YReTKhT4smR4zhcOQZr0= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY= -github.com/go-openapi/swag/loading v0.28.0 h1:td8QZdZC9MIYGGSnSPKShKiK22I2tU5UQvuUhIBPRLU= -github.com/go-openapi/swag/loading v0.28.0/go.mod h1:rXB0QiQX5mMveXEA7ouM4KiiM9jVJe4K6BVbwhD1M4k= -github.com/go-openapi/swag/pools v0.28.0 h1:HPMZWSAfce3rdVTFcjFiCIBtDg9h4x2QlRrHipwhxeU= -github.com/go-openapi/swag/pools v0.28.0/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE= -github.com/go-openapi/swag/stringutils v0.28.0 h1:ixsc9iYgDPubHL/8nSkbnryEHpD2VRlBMLKpQyPXcDU= -github.com/go-openapi/swag/stringutils v0.28.0/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM= -github.com/go-openapi/swag/typeutils v0.28.0 h1:nRBKSBXjDgf01VDPB3fWeD9nQuhCOVeIYAkUx2tbkyY= -github.com/go-openapi/swag/typeutils v0.28.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= -github.com/go-openapi/swag/yamlutils v0.28.0 h1:TV3JXH6DS46KUroDtMLAYHGkdWf5VDq3wVWFirmzROY= -github.com/go-openapi/swag/yamlutils v0.28.0/go.mod h1:x0q/yndZHEgk9Rx3DyDqzFUmHy55KTvIZldvF2dTJXs= -github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0= -github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo= -github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= -github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= -github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= -github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= -github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= -github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= -github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= -github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8= -github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= -github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= -github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= -github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= -github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= -github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= -github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= -github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/gomodule/redigo v1.9.3 h1:dNPSXeXv6HCq2jdyWfjgmhBdqnR6PRO3m/G05nvpPC8= -github.com/gomodule/redigo v1.9.3/go.mod h1:KsU3hiK/Ay8U42qpaJk+kuNa3C+spxapWpM+ywhcgtw= -github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= -github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= -github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= -github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= -github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= -github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= -github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= -github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= -github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= -github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= -github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY= -github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= -github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/hibiken/asynq v0.26.0 h1:1Zxr92MlDnb1Zt/QR5g2vSCqUS03i95lUfqx5X7/wrw= -github.com/hibiken/asynq v0.26.0/go.mod h1:Qk4e57bTnWDoyJ67VkchuV6VzSM9IQW2nPvAGuDyw58= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= -github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= -github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= -github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= -github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= -github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= -github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= -github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= -github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= -github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 h1:9Nu54bhS/H/Kgo2/7xNSUuC5G28VR8ljfrLKU2G4IjU= -github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12/go.mod h1:TBzl5BIHNXfS9+C35ZyJaklL7mLDbgUkcgXzSLa8Tk0= -github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= -github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= -github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= -github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0= -github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE= -github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= -github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= -github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= -github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/maypok86/otter/v2 v2.3.0 h1:8H8AVVFUSzJwIegKwv1uF5aGitTY+AIrtktg7OcLs8w= -github.com/maypok86/otter/v2 v2.3.0/go.mod h1:XgIdlpmL6jYz882/CAx1E4C1ukfgDKSaw4mWq59+7l8= -github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= -github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= -github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= -github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= -github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= -github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE= -github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8= -github.com/paulmach/orb v0.13.0 h1:r7n7mQGGF+cj/CbcivEj9J3HGK+XR+yXnvzRdq9saIw= -github.com/paulmach/orb v0.13.0/go.mod h1:6scRWINywA2Jf05dcjOfLfxrUIMECvTSG2MVbRLxu/k= -github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= -github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/peterbourgon/diskv/v3 v3.0.1 h1:x06SQA46+PKIUftmEujdwSEpIx8kR+M9eLYsUxeYveU= -github.com/peterbourgon/diskv/v3 v3.0.1/go.mod h1:kJ5Ny7vLdARGU3WUuy6uzO6T0nb/2gWcT1JiBvRmb5o= -github.com/pierrec/lz4/v4 v4.1.28 h1:pPEPwRJ4kybBTfGt28q7lQsRJQHhC08axprdLD5Ppio= -github.com/pierrec/lz4/v4 v4.1.28/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pressly/goose/v3 v3.27.3 h1:pIglVHjw99r4e/hDHHwbl9vfOsDMqUokfkXo6+n/RxA= -github.com/pressly/goose/v3 v3.27.3/go.mod h1:Dag+xpV6o20HR2LFY1j0q6MDwc3f7vPUFDA77R+0yGY= -github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= -github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= -github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= -github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= -github.com/quic-go/quic-go v0.61.0 h1:ui88A53s8MSVYLC56en0KQ17HARk+9986Dn0SBfKNvA= -github.com/quic-go/quic-go v0.61.0/go.mod h1:9So2anK4Tp22URSQq00k+Vo2PNkle96ycDPDHL4s9vs= -github.com/redis/go-redis/extra/rediscmd/v9 v9.22.0 h1:MQPzEEnpD0BMPufBLABnMYLJVwM7xi7vZ+srO8Nr0s8= -github.com/redis/go-redis/extra/rediscmd/v9 v9.22.0/go.mod h1:eve0JFcLRwFVj3RA85rrrV5+UJ+K9LDyU7kf2UdSueM= -github.com/redis/go-redis/extra/redisotel/v9 v9.22.0 h1:t5ul1Gl0o1rYQj5f5bK12G9xcg1niq2ON4yZFjvy1kA= -github.com/redis/go-redis/extra/redisotel/v9 v9.22.0/go.mod h1:hcS9L2RBBjYXkrfSOF26ZGejgo+yOC+28ZD2fkk3sGs= -github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0= -github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= -github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= -github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= -github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= -github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= -github.com/sethvargo/go-retry v0.4.0 h1:9qy1OoIAxBL+gBYnkTnTnWle5wlfsXQlwRzIbbpdqPw= -github.com/sethvargo/go-retry v0.4.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg= -github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= -github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= -github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= -github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= -github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= -github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= -github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= -github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= -github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= -github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= -github.com/stangelandcl/ppmd v0.1.1 h1:c25QazhlWUn5nmR1QOzafKhQxBicAr7GGCKER2aJ8H8= -github.com/stangelandcl/ppmd v0.1.1/go.mod h1:Rrv7M+/2P5jYr/GMLhBl7Ug3uJ1bUiVzr5LbbaV6xgY= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= -github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/studio-b12/gowebdav v0.13.0 h1:OcwSg6IQHOFNdYHn3bPOHwSE8looG8N56Y5xTT1asqQ= -github.com/studio-b12/gowebdav v0.13.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE= -github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= -github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= -github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= -github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY= -github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw= -github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= -github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= -github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= -github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= -github.com/ugorji/go/codec v1.3.2 h1:zkEASHHyEClGeURfgNT9PJZVfAbs9oEX9QXggwWNJbc= -github.com/ugorji/go/codec v1.3.2/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= -github.com/ulikunitz/xz v0.5.16 h1:ld6NyySjx5lowVKwJvMRLnW5nxKX/xnpSiFYZ/Lxur0= -github.com/ulikunitz/xz v0.5.16/go.mod h1:H9Rt/W6/Qj27PGauhQc6nfCDy7vHpzsOThBSaYDoEhw= -github.com/uptrace/opentelemetry-go-extra/otelutil v0.3.2 h1:3/aHKUq7qaFMWxyQV0W2ryNgg8x8rVeKVA20KJUkfS0= -github.com/uptrace/opentelemetry-go-extra/otelutil v0.3.2/go.mod h1:Zit4b8AQXaXvA68+nzmbyDzqiyFRISyw1JiD5JqUBjw= -github.com/uptrace/opentelemetry-go-extra/otelzap v0.3.2 h1:cj/Z6FKTTYBnstI0Lni9PA+k2foounKIPUmj1LBwNiQ= -github.com/uptrace/opentelemetry-go-extra/otelzap v0.3.2/go.mod h1:LDaXk90gKEC2nC7JH3Lpnhfu+2V7o/TsqomJJmqA39o= -github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= -github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA= -github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8= -github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= -github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= -go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8= -go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= -go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= -go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.70.0 h1:R+uYJnPiZLeJhFicamvZhLr0aVOrDIaxBcqgGus9nSU= -go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.70.0/go.mod h1:Zwk515MbVWCK2WOgeYBNIf8CyZGbAgkoJ6VKSGkd6aQ= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04= -go.opentelemetry.io/contrib/propagators/b3 v1.45.0 h1:audI5r8RmWVSORhzA5Y57yGvEA1358PvGk0u0sMOTDA= -go.opentelemetry.io/contrib/propagators/b3 v1.45.0/go.mod h1:SiENIek0FnzLni3/jSCiumyCA2mwP8uGaE1686SOJug= -go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= -go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 h1:QRefszxJmfPdjXUUm3j6iDzY03mTPXMjqErFqQ67vUg= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0/go.mod h1:Tiz03lTBVBrm7eWZBOidzEaYaJa8tjwGUGv6d8mlTyk= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0 h1:fG5MCxGz8+2VtrN/WgqSpJFctVz24gpxj8CxkKmc8Ww= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0/go.mod h1:BmAYTn+3ysbRe+IU2msxmf5Rx3g6DHvex+tWI3LdhYI= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0 h1:lsA/S1bxgdbyFGkTj+3meEdJ6ADVU7QoFstV6MXgE68= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0/go.mod h1:L7u+MirGoB1bjeLH66+xDykF4RC8C3RN7lIFpBiewUo= -go.opentelemetry.io/otel/log v0.6.0 h1:nH66tr+dmEgW5y+F9LanGJUBYPrRgP4g2EkmPE3LeK8= -go.opentelemetry.io/otel/log v0.6.0/go.mod h1:KdySypjQHhP069JX0z/t26VHwa8vSwzgaKmXtIB3fJM= -go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= -go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= -go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= -go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= -go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= -go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= -go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= -go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= -go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= -go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= -go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= -go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= -go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= -go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= -go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= -go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= -go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= -go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= -go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= -go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw= -go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0= -golang.org/x/arch v0.29.0 h1:8sSET5wB0+exBm0FGmOtdHMqjlRdV2DRD3/IV6OZgho= -golang.org/x/arch v0.29.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= -golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I= -golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= -golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= -golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= -golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= -golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= -golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= -golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= -golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= -gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d h1:FarXi840EJWSHYTN3ERkADbPWjl307+FGrA22KAVjjc= -google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d/go.mod h1:K/+WGbmBY7aNW1HDw1fJnKYo10i0DkAX6pows00dLig= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ= -google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= -gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gorm.io/driver/clickhouse v0.7.0 h1:BCrqvgONayvZRgtuA6hdya+eAW5P2QVagV3OlEp1vtA= -gorm.io/driver/clickhouse v0.7.0/go.mod h1:TmNo0wcVTsD4BBObiRnCahUgHJHjBIwuRejHwYt3JRs= -gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= -gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= -gorm.io/driver/postgres v1.6.2 h1:BvXQ/cNUg63q5TFNg672DmDcowZSFrNLkkA3Xe6GXq4= -gorm.io/driver/postgres v1.6.2/go.mod h1:0c4fQA44XhOklXDkgtuKqysHCycTa5i9e3EIpDGCwXk= -gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= -gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= -gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= -gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= -gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc= -gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM= -gorm.io/plugin/opentelemetry v0.1.16 h1:Kypj2YYAliJqkIczDZDde6P6sFMhKSlG5IpngMFQGpc= -gorm.io/plugin/opentelemetry v0.1.16/go.mod h1:P3RmTeZXT+9n0F1ccUqR5uuTvEXDxF8k2UpO7mTIB2Y= -modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= -modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= -modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= -modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= -modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= -modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= -modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= -modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= -modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= -modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= -modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= -modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= -modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= -modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= -modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= -modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= -modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= -modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0= -modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= -modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= -modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= -modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= -modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/apps/openflare/apiutil/routes.go b/internal/apps/openflare/apiutil/routes.go deleted file mode 100644 index f73f37a4..00000000 --- a/internal/apps/openflare/apiutil/routes.go +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package apiutil - -import ( - "strings" - - "github.com/gin-gonic/gin" -) - -// RegisterCollection registers a collection endpoint on both "" and "/" so requests -// work with or without a trailing slash. -func RegisterCollection(route *gin.RouterGroup, method string, handlers ...gin.HandlerFunc) { - route.Handle(method, "/", handlers...) - if !strings.HasSuffix(route.BasePath(), "/") { - route.Handle(method, "", handlers...) - } -} diff --git a/internal/infra/persistence/batchwriter/writer_test.go b/internal/infra/persistence/batchwriter/writer_test.go deleted file mode 100644 index 1818444c..00000000 --- a/internal/infra/persistence/batchwriter/writer_test.go +++ /dev/null @@ -1,495 +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 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 TestWriterInvokesFlushErrorHandler(t *testing.T) { - t.Parallel() - - cfg := DefaultConfig() - cfg.Name = "test-flush-err" - 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) - } - if stats.Name != "test-flush-err" { - t.Fatalf("Stats().Name = %q, want test-flush-err", stats.Name) - } -} - -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") - } -} diff --git a/internal/repository/logstore/imports_test.go b/internal/repository/logstore/imports_test.go deleted file mode 100644 index a0dd3d51..00000000 --- a/internal/repository/logstore/imports_test.go +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package logstore - -import ( - "os/exec" - "strings" - "testing" -) - -// forbiddenImports 上层应用禁止直接触碰的底层日志实现。 -var forbiddenImports = []string{ - "github.com/Rain-kl/Wavelet/internal/repository/analytics", -} - -// allowedAnalyticsDelegation 允许直接依赖 analyticsrepo 的委托层: -// - internal/repository:持久化门面,ListOpenFlareLatestMetricSnapshotsSince 的 -// CH 快速路径仍直连 analyticsrepo(LIMIT 1 BY node_id);小时级聚合读已改走 logstore; -// - internal/repository/logstore:CH 后端实现按设计委托 analyticsrepo。 -// -// 除此之外,依赖闭包内任何包都禁止引入 analyticsrepo。 -var allowedAnalyticsDelegation = map[string]bool{ - "github.com/Rain-kl/Wavelet/internal/repository": true, - "github.com/Rain-kl/Wavelet/internal/repository/logstore": true, -} - -// allowedInfraPersistence 允许 apps 引入的 infra/persistence 子包。 -var allowedInfraPersistence = []string{ - "github.com/Rain-kl/Wavelet/internal/infra/persistence/batchwriter", // batchwriter 统计类型 - "github.com/Rain-kl/Wavelet/internal/infra/persistence/idgen", // 雪花 ID 生成(无日志依赖) -} - -func TestAppsMustNotImportLogBackendDirectly(t *testing.T) { - t.Chdir("../../..") // module root,保证 ./internal/apps/... 可解析 - out, err := exec.Command("go", "list", "-test", "-f", `{{.ImportPath}} {{join .Imports " "}}`, "./internal/apps/...").Output() - if err != nil { - t.Fatalf("go list: %v", err) - } - for _, line := range strings.Split(string(out), "\n") { - fields := strings.Fields(line) - if len(fields) == 0 { - continue - } - pkg := fields[0] - if !strings.HasPrefix(pkg, "github.com/Rain-kl/Wavelet/internal/apps") { - continue - } - for _, imp := range fields[1:] { - for _, forbidden := range forbiddenImports { - if imp == forbidden && !allowedAnalyticsDelegation[pkg] { - t.Errorf("%s must not import forbidden log backend %s", pkg, forbidden) - } - } - if strings.HasPrefix(imp, "github.com/Rain-kl/Wavelet/internal/infra/persistence/") { - allowed := false - for _, a := range allowedInfraPersistence { - if imp == a || strings.HasPrefix(imp, a+"/") { - allowed = true - break - } - } - if !allowed { - t.Errorf("%s must not import infra/persistence subpackage directly: %s", pkg, imp) - } - } - } - } -} diff --git a/internal/router/router.go b/internal/router/router.go deleted file mode 100644 index 456f49d2..00000000 --- a/internal/router/router.go +++ /dev/null @@ -1,135 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package router - -import ( - "context" - "errors" - "log" - "net" - "net/http" - "os" - "os/signal" - "strconv" - "syscall" - "time" - - "github.com/Rain-kl/Wavelet/internal/platform/bootstrap" - router_root "github.com/Rain-kl/Wavelet/internal/router/root" - v1 "github.com/Rain-kl/Wavelet/internal/router/v1" - - "github.com/Rain-kl/Wavelet/internal/apps/oauth" - "github.com/Rain-kl/Wavelet/internal/infra/config" - otel_trace "github.com/Rain-kl/Wavelet/pkg/trace" - "github.com/Rain-kl/Wavelet/pkg/util" - "github.com/gin-contrib/sessions" - "github.com/gin-contrib/sessions/redis" - "github.com/gin-gonic/gin" - "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" -) - -// Serve 启动 HTTP API 服务。onStarted 仅会在 HTTP 地址成功绑定后调用。 -func Serve(onStarted func()) { - // 运行模式 - if config.Config.App.IsProduction() { - gin.SetMode(gin.ReleaseMode) - } - - // 初始化路由 - r := gin.New() - // Legacy OpenFlare list endpoints register both /resource and /resource/; disable auto slash redirects. - r.RedirectTrailingSlash = false - r.Use(gin.Recovery()) - r.Use(corsMiddleware()) - - cfg := config.Config.Redis - addrs := cfg.Addrs - sessionAddr := "localhost:6379" - if len(addrs) > 0 { - sessionAddr = addrs[0] - } - - sessionStore, err := redis.NewStoreWithDB( - cfg.MinIdleConn, - "tcp", - sessionAddr, - cfg.Username, - cfg.Password, - strconv.Itoa(cfg.DB), - []byte(config.Config.App.SessionSecret), - ) - if err != nil { - log.Fatalf("[API] init session store failed: %v\n", err) - } - - // 设置 Session Redis Key 前缀 - if cfg.KeyPrefix != "" { - if err := redis.SetKeyPrefix(sessionStore, cfg.KeyPrefix+"session:"); err != nil { - log.Printf("[API] set session key prefix failed: %v\n", err) - } - } - - sessionStore.Options(oauth.GetSessionOptions(config.Config.App.SessionAge)) - - r.Use(sessions.Sessions(config.Config.App.SessionCookieName, sessionStore)) - - // 补充中间件 - r.Use(otelgin.Middleware(config.Config.App.AppName), errorHandlerMiddleware(), loggerMiddleware()) - - registerRoutes(r) - - srv := &http.Server{ - Addr: config.Config.App.Addr, - Handler: r, - ReadHeaderTimeout: 10 * time.Second, - } - - listener, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", config.Config.App.Addr) - if err != nil { - log.Fatalf("[API] server failed to listen on %s: %v\n", config.Config.App.Addr, err) - } - if onStarted != nil { - onStarted() - } - - util.Go(func() { - log.Printf("[API] server listening on %s\n", config.Config.App.Addr) - if err := srv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { - log.Fatalf("[API] server failed: %v\n", err) - } - }) - - quit := make(chan os.Signal, 1) - signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) - <-quit - - shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Duration(config.Config.App.GracefulShutdownTimeout)*time.Second) - - otel_trace.Shutdown(shutdownCtx) - - if err := srv.Shutdown(shutdownCtx); err != nil { - log.Printf("[API] server forced to shutdown: %v\n", err) - bootstrap.Stop(shutdownCtx) - cancel() - os.Exit(1) - } - bootstrap.Stop(shutdownCtx) - cancel() - - log.Println("[API] server exited") -} - -func registerRoutes(r *gin.Engine) { - // Register custom root routes, Swagger, and frontend serving - router_root.RegisterRootRoutes(r) - - apiGroup := r.Group(config.Config.App.APIPrefix) - { - apiV1Router := apiGroup.Group("/v1") - { - v1.RegisterV1Routes(apiV1Router, apiGroup) - } - } -} diff --git a/pkg/util/goroutine_test.go b/pkg/util/goroutine_test.go deleted file mode 100644 index ae49aa68..00000000 --- a/pkg/util/goroutine_test.go +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package util - -import ( - "testing" - "time" -) - -func TestGoRunsFn(t *testing.T) { - done := make(chan struct{}) - Go(func() { close(done) }) - select { - case <-done: - case <-time.After(time.Second): - t.Fatal("fn was not run") - } -} - -func TestGoRecoversPanic(t *testing.T) { - done := make(chan struct{}) - Go(func() { - defer close(done) - panic("boom") - }) - <-done - // Give the recovering goroutine a moment to finish logging; the test - // only fails if the panic had propagated and crashed the process. - time.Sleep(10 * time.Millisecond) -} diff --git a/scripts/swagger.sh b/scripts/swagger.sh index 0fab43ff..f59bf4c1 100755 --- a/scripts/swagger.sh +++ b/scripts/swagger.sh @@ -1,6 +1,13 @@ #!/bin/sh +set -eu # execute this first # go install github.com/swaggo/swag/cmd/swag@latest -swag init -o docs --parseDependency --parseInternal --exclude fast-note-sync-service-source +# 仅扫描 OpenFlare 自身代码:上游 core/ 与 plugins/ 尚未挂载进装配根(P4 接入), +# 且其包名(如 model、disk)与下游注解里的裸名引用冲突,扫描会导致 ParseComment 失败。 +( + cd backend + swag init -o docs --parseDependency --parseInternal --exclude plugins,core +) +cp backend/docs/swagger.json backend/docs/swagger.yaml docs/ diff --git a/scripts/sync-upstream.sh b/scripts/sync-upstream.sh new file mode 100755 index 00000000..04774040 --- /dev/null +++ b/scripts/sync-upstream.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# 从 Wavelet 上游同步 Cordis 内核与平台插件到本仓库。 +# +# 只覆盖与上游同构的三个目录:backend/core、backend/pkg、backend/plugins。 +# 永不触碰下游自有内容:backend/OpenFlare(业务插件)、backend/share(共享层)、 +# backend/cmd 与 backend/main.go(本仓库自持的装配根)。 +# +# 用法: +# scripts/sync-upstream.sh [上游 backend 路径] +# scripts/sync-upstream.sh --check # 仅报告差异,不写入 +set -euo pipefail + +CHECK=0 +SRC="/Users/ryan/Code/Go/Wavelet/backend" +if [[ "${1:-}" == "--check" ]]; then + CHECK=1 + shift +fi +if [[ -n "${1:-}" ]]; then + SRC="$1" +fi + +if [[ ! -d "$SRC/core" ]]; then + echo "error: 上游 backend 路径无效:$SRC" >&2 + exit 1 +fi + +DST="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/backend" + +RSYNC_OPTS=(-a --delete) +if [[ "$CHECK" == "1" ]]; then + RSYNC_OPTS+=(-n -i) + echo "==> dry-run:仅列出将变更的文件" +fi + +# 上游工作区里被 gitignore 的运行期产物绝不允许带进本仓库(曾经误提交 +# uploads/diskcache 缓存块与 driver_http/dist 前端构建物共 1000+ 文件)。 +EXCLUDES=(--exclude 'uploads' --exclude 'dist' --exclude 'data' --exclude '*.db' --exclude '.DS_Store') + +for dir in core pkg plugins; do + echo "==> sync $dir" + rsync "${RSYNC_OPTS[@]}" "${EXCLUDES[@]}" "$SRC/$dir/" "$DST/$dir/" | sed "s|^|$dir/|" +done + +PATCHES="$DST/OpenFlare/upstream-patches.md" + +if [[ "$CHECK" == "1" ]]; then + echo "==> 检查完成(未写入)。以上 >f/ 同步完成。" + if [[ -f "$PATCHES" ]]; then + echo "注意:以下上游文件带有本地补丁,同步后请确认补丁仍在(cd backend && go build ./... && go test ./core/...):" + sed -n 's/^- `\(.*\)`$/\t\1/p' "$PATCHES" + fi +fi