mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-06 15:46:37 +08:00
改名
This commit is contained in:
+175
-47
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: "new-async-task"
|
name: "new-async-task"
|
||||||
description: "项目专用:指导如何在本项目中新增异步任务(TaskHandler),包括常量定义、处理器实现、注册、Cron 调度、AppendLog 日志规范等完整步骤。"
|
description: "项目专用:指导如何在本项目中新增异步任务(TaskHandler),包括常量定义、参数传递(TaskParam)、处理器实现、Admin 校验、注册、Cron 调度、AppendLog 日志规范等完整步骤。"
|
||||||
---
|
---
|
||||||
|
|
||||||
# 新增异步任务开发指南
|
# 新增异步任务开发指南
|
||||||
@@ -27,11 +27,12 @@ TaskHandler.Execute (← 你写这一步)
|
|||||||
|
|
||||||
**关键文件**:
|
**关键文件**:
|
||||||
- `internal/task/handler.go` — `TaskHandler` 接口、`TaskResult` 类型
|
- `internal/task/handler.go` — `TaskHandler` 接口、`TaskResult` 类型
|
||||||
- `internal/task/constants.go` — 任务类型常量、`TaskMeta`、`DispatchableTasks`
|
- `internal/task/constants.go` — 任务类型常量、`TaskMeta`、`TaskParam`、`DispatchableTasks`
|
||||||
- `internal/task/executor.go` — 注册、下发、执行、日志追加
|
- `internal/task/executor.go` — 注册、下发、执行、日志追加
|
||||||
- `internal/task/worker/worker.go` — Worker 启动、处理器注册、路由
|
- `internal/task/worker/worker.go` — Worker 启动、处理器注册、路由
|
||||||
- `internal/task/scheduler/scheduler.go` — Cron 定时调度
|
- `internal/task/scheduler/scheduler.go` — Cron 定时调度
|
||||||
- `internal/model/task_execution.go` — `TaskExecution` GORM 模型
|
- `internal/model/task_execution.go` — `TaskExecution` GORM 模型
|
||||||
|
- `internal/apps/admin/task/routers.go` — 管理 API(下发、查询、重试)
|
||||||
|
|
||||||
## 新增任务的完整步骤
|
## 新增任务的完整步骤
|
||||||
|
|
||||||
@@ -53,51 +54,116 @@ const TaskTypeCleanupUploads = "cleanup_unused_uploads"
|
|||||||
|
|
||||||
**3. 在 `DispatchableTasks` 切片中添加 TaskMeta**:
|
**3. 在 `DispatchableTasks` 切片中添加 TaskMeta**:
|
||||||
|
|
||||||
|
不带参数的任务:
|
||||||
```go
|
```go
|
||||||
var DispatchableTasks = []TaskMeta{
|
{
|
||||||
{
|
Type: TaskTypeCleanupUploads,
|
||||||
Type: TaskTypeCleanupUploads, // 管理员 API 用的标识
|
AsynqTask: CleanupUnusedUploadsTask,
|
||||||
AsynqTask: CleanupUnusedUploadsTask, // Asynq 路由用的标识
|
Name: "清理未使用上传",
|
||||||
Name: "清理未使用上传", // 管理后台显示名
|
Description: "清理超过1小时未使用的上传文件",
|
||||||
Description: "清理超过1小时未使用的上传文件", // 管理后台显示描述
|
SupportsTime: false,
|
||||||
SupportsTime: false, // 是否支持时间范围参数(暂未使用)
|
MaxRetry: 3,
|
||||||
MaxRetry: 3, // Asynq 最大自动重试次数
|
Queue: QueueDefault,
|
||||||
Queue: QueueDefault, // 投递到的队列名
|
Retryable: true,
|
||||||
Retryable: true, // 管理端是否允许手动重试
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
带参数的任务(`Params` 定义前端表单字段):
|
||||||
|
```go
|
||||||
|
{
|
||||||
|
Type: TaskTypeSendEmail,
|
||||||
|
AsynqTask: SendEmailTask,
|
||||||
|
Name: "发送邮件",
|
||||||
|
Description: "异步发送系统邮件",
|
||||||
|
SupportsTime: false,
|
||||||
|
MaxRetry: 3,
|
||||||
|
Queue: QueueDefault,
|
||||||
|
Retryable: true,
|
||||||
|
Params: []TaskParam{
|
||||||
|
{
|
||||||
|
Name: "to",
|
||||||
|
Label: "接收邮箱 (To)",
|
||||||
|
Type: "string",
|
||||||
|
Required: true,
|
||||||
|
Placeholder: "receiver@example.com",
|
||||||
|
Description: "接收邮件的目标邮箱地址",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "subject",
|
||||||
|
Label: "邮件主题 (Subject)",
|
||||||
|
Type: "string",
|
||||||
|
Required: true,
|
||||||
|
Placeholder: "请输入邮件主题",
|
||||||
|
Description: "发送邮件的主题标题",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "body",
|
||||||
|
Label: "邮件内容 (Body)",
|
||||||
|
Type: "text",
|
||||||
|
Required: true,
|
||||||
|
Placeholder: "请输入邮件内容(支持 HTML 格式)",
|
||||||
|
Description: "发送邮件的内容主体",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**`TaskParam` 字段说明**:
|
||||||
|
|
||||||
|
| 字段 | 作用 |
|
||||||
|
|------|------|
|
||||||
|
| `Name` | 参数键名,与 Handler 中 Payload 结构体的 JSON tag 一致 |
|
||||||
|
| `Label` | 前端表单显示的标签 |
|
||||||
|
| `Type` | 前端控件类型:`string`(单行输入)、`text`(多行文本)、`number`(数字) |
|
||||||
|
| `Required` | 前端是否标为必填(仅前端提示,不做服务端校验) |
|
||||||
|
| `Placeholder` | 前端输入框占位文字 |
|
||||||
|
| `Description` | 前端显示的参数说明 |
|
||||||
|
|
||||||
|
**注意**:`TaskParam` 纯粹是前端表单元数据。服务端不基于它做参数校验——校验逻辑在 Admin dispatch handler 和 Handler Execute 中各写一份。
|
||||||
|
|
||||||
### 第 2 步:实现 TaskHandler
|
### 第 2 步:实现 TaskHandler
|
||||||
|
|
||||||
在 `internal/apps/<module>/` 下创建 `tasks.go`,定义结构体并实现 `TaskHandler` 接口:
|
在 `internal/apps/<module>/` 下创建 `tasks.go`。
|
||||||
|
|
||||||
|
**无参数的任务**(参考 `internal/apps/upload/tasks.go`):
|
||||||
|
|
||||||
```go
|
```go
|
||||||
package upload
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/linux-do/credit/internal/db"
|
|
||||||
"github.com/linux-do/credit/internal/model"
|
|
||||||
"github.com/linux-do/credit/internal/task"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MyTaskHandler 你的任务处理器
|
|
||||||
type CleanupUnusedUploadsHandler struct{}
|
type CleanupUnusedUploadsHandler struct{}
|
||||||
|
|
||||||
// Execute 实现 TaskHandler 接口
|
|
||||||
// - ctx: 已注入 OTel Span 和 taskID,传给 task.AppendLog 使用
|
|
||||||
// - payload: 下发时传入的原始字节(可为 nil)
|
|
||||||
func (h *CleanupUnusedUploadsHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) {
|
func (h *CleanupUnusedUploadsHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) {
|
||||||
task.AppendLog(ctx, "开始扫描未使用上传文件,阈值: %s", time.Now().Add(-1*time.Hour).Format(time.RFC3339))
|
task.AppendLog(ctx, "开始扫描...")
|
||||||
|
// ... 直接执行业务逻辑,忽略 payload ...
|
||||||
|
return &task.TaskResult{Message: "完成"}, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**带参数的任务**(参考 `internal/apps/user/tasks.go`):
|
||||||
|
|
||||||
|
需要定义自己的 Payload 结构体,在 `Execute` 中反序列化:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 定义载荷结构体(字段与 TaskParam.Name 对应)
|
||||||
|
type SendEmailPayload struct {
|
||||||
|
To string `json:"to"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SendEmailHandler struct{}
|
||||||
|
|
||||||
|
func (h *SendEmailHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) {
|
||||||
|
// 第一步:反序列化参数
|
||||||
|
var req SendEmailPayload
|
||||||
|
if err := json.Unmarshal(payload, &req); err != nil {
|
||||||
|
task.AppendLog(ctx, "解析参数失败: %v", err)
|
||||||
|
return nil, fmt.Errorf("解析参数失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
task.AppendLog(ctx, "开始发送邮件到: %s, 主题: %s", req.To, req.Subject)
|
||||||
|
|
||||||
// ... 业务逻辑 ...
|
// ... 业务逻辑 ...
|
||||||
// 如需处理批量数据,使用游标分页模式(参见 upload/tasks.go)
|
|
||||||
|
|
||||||
msg := fmt.Sprintf("共处理 %d 个文件,成功删除 %d 个", totalProcessed, totalDeleted)
|
msg := fmt.Sprintf("邮件成功发送至: %s", req.To)
|
||||||
task.AppendLog(ctx, "%s", msg)
|
task.AppendLog(ctx, "%s", msg)
|
||||||
return &task.TaskResult{Message: msg}, nil
|
return &task.TaskResult{Message: msg}, nil
|
||||||
}
|
}
|
||||||
@@ -107,7 +173,73 @@ func (h *CleanupUnusedUploadsHandler) Execute(ctx context.Context, payload []byt
|
|||||||
- 成功:返回 `&task.TaskResult{Message: "摘要", Detail: "可选详细JSON"}`。`ProcessTask` 会将 `Message + "\n" + Detail` 存入 `TaskExecution.Result`。
|
- 成功:返回 `&task.TaskResult{Message: "摘要", Detail: "可选详细JSON"}`。`ProcessTask` 会将 `Message + "\n" + Detail` 存入 `TaskExecution.Result`。
|
||||||
- 失败:返回 `nil, fmt.Errorf("...")`。`ProcessTask` 会将错误信息存入 `TaskExecution.ErrorMessage`,状态标记为 `failed`,并将 error 返回给 Asynq 触发自动重试。
|
- 失败:返回 `nil, fmt.Errorf("...")`。`ProcessTask` 会将错误信息存入 `TaskExecution.ErrorMessage`,状态标记为 `failed`,并将 error 返回给 Asynq 触发自动重试。
|
||||||
|
|
||||||
### 第 3 步:在 worker.go 注册
|
### 第 3 步:在 Admin dispatch handler 中添加参数校验
|
||||||
|
|
||||||
|
打开 `internal/apps/admin/task/routers.go`,在 `DispatchTask` 函数中为新任务类型添加校验逻辑。
|
||||||
|
|
||||||
|
下发请求结构体中,前端传入的参数通过 `Payload` 字段(JSON 字符串)传递:
|
||||||
|
|
||||||
|
```go
|
||||||
|
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"` // ← 前端传的参数 JSON 字符串
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
在 `DispatchTask` 函数中按 task type 分支校验:
|
||||||
|
|
||||||
|
```go
|
||||||
|
var payloadBytes []byte
|
||||||
|
if req.TaskType == task.TaskTypeSendEmail {
|
||||||
|
// 校验 payload 非空
|
||||||
|
if strings.TrimSpace(req.Payload) == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, util.Err("任务参数 Payload 不能为空"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 解析 JSON 并校验必填字段
|
||||||
|
var mailPayload struct {
|
||||||
|
To string `json:"to"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(req.Payload), &mailPayload); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, util.Err("无效的 JSON 格式: "+err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Trim + 必填校验
|
||||||
|
mailPayload.To = strings.TrimSpace(mailPayload.To)
|
||||||
|
mailPayload.Subject = strings.TrimSpace(mailPayload.Subject)
|
||||||
|
mailPayload.Body = strings.TrimSpace(mailPayload.Body)
|
||||||
|
if mailPayload.To == "" || mailPayload.Subject == "" || mailPayload.Body == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, util.Err("to、subject、body 不能为空"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 序列化回 bytes 传给 DispatchTask
|
||||||
|
payloadBytes, _ = json.Marshal(mailPayload)
|
||||||
|
} else {
|
||||||
|
// 无参数任务:直接透传 payload
|
||||||
|
if req.Payload != "" {
|
||||||
|
payloadBytes = []byte(req.Payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
taskID, err := task.DispatchTask(c.Request.Context(), req.TaskType, payloadBytes, "manual")
|
||||||
|
```
|
||||||
|
|
||||||
|
**参数传递的完整链路**:
|
||||||
|
|
||||||
|
```
|
||||||
|
前端表单(根据 TaskMeta.Params 动态渲染)
|
||||||
|
→ POST /api/v1/admin/tasks/dispatch { task_type, payload: JSON字符串 }
|
||||||
|
→ Admin DispatchTask handler 校验 + json.Marshal
|
||||||
|
→ task.DispatchTask(payloadBytes) 存入 DB + 入队 Asynq
|
||||||
|
→ ProcessTask → handler.Execute(payload) → handler 内 json.Unmarshal
|
||||||
|
```
|
||||||
|
|
||||||
|
### 第 4 步:在 worker.go 注册
|
||||||
|
|
||||||
打开 `internal/task/worker/worker.go`,做两件事:
|
打开 `internal/task/worker/worker.go`,做两件事:
|
||||||
|
|
||||||
@@ -116,8 +248,7 @@ func (h *CleanupUnusedUploadsHandler) Execute(ctx context.Context, payload []byt
|
|||||||
```go
|
```go
|
||||||
func init() {
|
func init() {
|
||||||
task.RegisterHandler(task.CleanupUnusedUploadsTask, &upload.CleanupUnusedUploadsHandler{})
|
task.RegisterHandler(task.CleanupUnusedUploadsTask, &upload.CleanupUnusedUploadsHandler{})
|
||||||
// 添加你的:
|
task.RegisterHandler(task.SendEmailTask, &user.SendEmailHandler{})
|
||||||
task.RegisterHandler(task.YourNewTask, &yourmodule.YourHandler{})
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -127,21 +258,20 @@ func init() {
|
|||||||
mux := asynq.NewServeMux()
|
mux := asynq.NewServeMux()
|
||||||
mux.Use(taskLoggingMiddleware)
|
mux.Use(taskLoggingMiddleware)
|
||||||
mux.HandleFunc(task.CleanupUnusedUploadsTask, task.ProcessTask)
|
mux.HandleFunc(task.CleanupUnusedUploadsTask, task.ProcessTask)
|
||||||
// 添加你的:
|
mux.HandleFunc(task.SendEmailTask, task.ProcessTask)
|
||||||
mux.HandleFunc(task.YourNewTask, task.ProcessTask)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
所有路由都指向同一个 `task.ProcessTask`,它内部根据 Asynq task type 查找对应的 handler 分发执行。
|
所有路由都指向同一个 `task.ProcessTask`,它内部根据 Asynq task type 查找对应的 handler 分发执行。
|
||||||
|
|
||||||
### 第 4 步(可选):添加 Cron 定时调度
|
### 第 5 步(可选):添加 Cron 定时调度
|
||||||
|
|
||||||
如果任务需要定时执行,编辑 `internal/task/scheduler/scheduler.go`,在 `StartScheduler()` 中注册:
|
如果任务需要定时执行,编辑 `internal/task/scheduler/scheduler.go`,在 `StartScheduler()` 中注册:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
if _, err = scheduler.Register(
|
if _, err = scheduler.Register(
|
||||||
config.Config.Scheduler.CleanupUnusedUploadsTaskCron, // cron 表达式
|
config.Config.Scheduler.CleanupUnusedUploadsTaskCron,
|
||||||
asynq.NewTask(task.CleanupUnusedUploadsTask, nil),
|
asynq.NewTask(task.CleanupUnusedUploadsTask, nil),
|
||||||
asynq.Unique(23*time.Hour), // 防止重复执行
|
asynq.Unique(23*time.Hour),
|
||||||
asynq.MaxRetry(3),
|
asynq.MaxRetry(3),
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return
|
return
|
||||||
@@ -178,6 +308,7 @@ scheduler:
|
|||||||
| 时机 | 示例 |
|
| 时机 | 示例 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 任务开始时 | `task.AppendLog(ctx, "开始扫描,阈值: %s", threshold)` |
|
| 任务开始时 | `task.AppendLog(ctx, "开始扫描,阈值: %s", threshold)` |
|
||||||
|
| 参数解析成功后 | `task.AppendLog(ctx, "开始发送邮件到: %s, 主题: %s", req.To, req.Subject)` |
|
||||||
| 批量处理的每一批次 | `task.AppendLog(ctx, "本批次处理 %d 条记录", len(batch))` |
|
| 批量处理的每一批次 | `task.AppendLog(ctx, "本批次处理 %d 条记录", len(batch))` |
|
||||||
| 关键中间状态 | `task.AppendLog(ctx, "已删除对象 %s", filePath)` |
|
| 关键中间状态 | `task.AppendLog(ctx, "已删除对象 %s", filePath)` |
|
||||||
| 遇到可继续的错误时 | `task.AppendLog(ctx, "清理文件失败 [ID:%d]: %v", id, err)` |
|
| 遇到可继续的错误时 | `task.AppendLog(ctx, "清理文件失败 [ID:%d]: %v", id, err)` |
|
||||||
@@ -199,6 +330,7 @@ scheduler:
|
|||||||
- **OTel 追踪**:自动创建 Span,记录任务类型、Payload 大小、TaskID
|
- **OTel 追踪**:自动创建 Span,记录任务类型、Payload 大小、TaskID
|
||||||
- **重试计数**:手动重试时自动递增 `RetryCount`,校验 `RetryCount < MaxRetry`
|
- **重试计数**:手动重试时自动递增 `RetryCount`,校验 `RetryCount < MaxRetry`
|
||||||
- **队列路由**:根据 `TaskMeta.Queue` 投递到对应优先级队列
|
- **队列路由**:根据 `TaskMeta.Queue` 投递到对应优先级队列
|
||||||
|
- **前端表单渲染**:`ListTaskTypes` API 返回 `DispatchableTasks`(含 `Params`),前端据此动态渲染参数表单
|
||||||
|
|
||||||
## 重试机制
|
## 重试机制
|
||||||
|
|
||||||
@@ -206,14 +338,10 @@ scheduler:
|
|||||||
|
|
||||||
**Asynq 自动重试**:`ProcessTask` 返回 error 时,Asynq 根据入队时设置的 `MaxRetry` 自动重试。但 `TaskExecution` 记录在第一次失败时已标记为 `failed`,后续重试会覆盖同一条记录。
|
**Asynq 自动重试**:`ProcessTask` 返回 error 时,Asynq 根据入队时设置的 `MaxRetry` 自动重试。但 `TaskExecution` 记录在第一次失败时已标记为 `failed`,后续重试会覆盖同一条记录。
|
||||||
|
|
||||||
**管理端手动重试**:通过 `POST /api/v1/admin/tasks/executions/{id}/retry` 触发。会创建一个全新的 `TaskExecution` 记录(新 TaskID `retry_{count}_{originalTaskID}`),`RetryCount` 递增。校验条件:状态必须为 `failed`、`Retryable` 为 `true`、`RetryCount < MaxRetry`。
|
**管理端手动重试**:通过 `POST /api/v1/admin/tasks/executions/{id}/retry` 触发。会创建一个全新的 `TaskExecution` 记录(新 TaskID `retry_{count}_{originalTaskID}`),`RetryCount` 递增,`Payload` 原样复制。校验条件:状态必须为 `failed`、`Retryable` 为 `true`、`RetryCount < MaxRetry`。
|
||||||
|
|
||||||
## 参考:现有任务处理器
|
## 参考:现有任务处理器
|
||||||
|
|
||||||
完整的任务处理器示例参见 `internal/apps/upload/tasks.go` 中的 `CleanupUnusedUploadsHandler`,它展示了:
|
- `internal/apps/upload/tasks.go` — `CleanupUnusedUploadsHandler`:无参数任务,展示游标分页批量处理、每批次 AppendLog、事务内操作、单条失败 continue 不终止。
|
||||||
- 游标分页批量处理
|
- `internal/apps/user/tasks.go` — `SendEmailHandler`:带参数任务,展示 Payload 结构体定义、json.Unmarshal 反序列化、参数日志记录。
|
||||||
- 每批次的 `AppendLog` 记录
|
|
||||||
- 事务内操作(DB 状态更新 + 外部调用)
|
|
||||||
- 单条失败时记录日志并 continue 而非整体终止
|
|
||||||
- 最终汇总结果通过 `TaskResult.Message` 返回
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Refreshing
|
# Wavelet
|
||||||
|
|
||||||
🚀 A modern, production-ready full-stack boilerplate for building scalable web applications
|
🚀 A modern, production-ready full-stack boilerplate for building scalable web applications
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
## 📖 Introduction
|
## 📖 Introduction
|
||||||
|
|
||||||
**Refreshing** is a generic, production-ready full-stack boilerplate built with **Go (Gin + GORM)** on the backend and **Next.js (App Router + Shadcn UI)** on the frontend. It ships with everything you need to bootstrap a modern SaaS, internal tool, or developer platform — without the boilerplate headaches.
|
**Wavelet** is a generic, production-ready full-stack boilerplate built with **Go (Gin + GORM)** on the backend and **Next.js (App Router + Shadcn UI)** on the frontend. It ships with everything you need to bootstrap a modern SaaS, internal tool, or developer platform — without the boilerplate headaches.
|
||||||
|
|
||||||
The project was designed from the ground up to be **framework-first and business-agnostic**: plug in your own domain logic while reusing the battle-tested infrastructure that comes out of the box.
|
The project was designed from the ground up to be **framework-first and business-agnostic**: plug in your own domain logic while reusing the battle-tested infrastructure that comes out of the box.
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
# Refreshing
|
# Wavelet
|
||||||
|
|
||||||
🚀 现代化、生产就绪的全栈应用脚手架
|
🚀 现代化、生产就绪的全栈应用脚手架
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
## 📖 项目简介
|
## 📖 项目简介
|
||||||
|
|
||||||
**Refreshing** 是一个通用型、生产就绪的现代全栈脚手架,后端采用 **Go(Gin + GORM)**,前端采用 **Next.js(App Router + Shadcn UI)**。项目开箱即用,内置构建现代 SaaS、内部工具或开发者平台所需的核心基础设施。
|
**Wavelet** 是一个通用型、生产就绪的现代全栈脚手架,后端采用 **Go(Gin + GORM)**,前端采用 **Next.js(App Router + Shadcn UI)**。项目开箱即用,内置构建现代 SaaS、内部工具或开发者平台所需的核心基础设施。
|
||||||
|
|
||||||
项目设计理念是 **框架优先、业务中立**:您可以在沿用经过实战检验的底层基础设施的同时,自由接入自己的业务逻辑。
|
项目设计理念是 **框架优先、业务中立**:您可以在沿用经过实战检验的底层基础设施的同时,自由接入自己的业务逻辑。
|
||||||
|
|
||||||
|
|||||||
+7
-7
@@ -1,15 +1,15 @@
|
|||||||
# Refreshing — Full-Stack Boilerplate Config
|
# Wavelet — Full-Stack Boilerplate Config
|
||||||
# Copy this file to config.yaml and fill in your values.
|
# Copy this file to config.yaml and fill in your values.
|
||||||
# Fields marked with <...> are required; others have sensible defaults.
|
# Fields marked with <...> are required; others have sensible defaults.
|
||||||
|
|
||||||
# ─── Application ────────────────────────────────────────────────────────────────
|
# ─── Application ────────────────────────────────────────────────────────────────
|
||||||
app:
|
app:
|
||||||
app_name: "refreshing"
|
app_name: "Wavelet"
|
||||||
env: "development" # development | testing | production
|
env: "development" # development | testing | production
|
||||||
addr: ":8000"
|
addr: ":8000"
|
||||||
node_id: 1 # Snowflake node ID (0-1023). Must be unique per instance.
|
node_id: 1 # Snowflake node ID (0-1023). Must be unique per instance.
|
||||||
graceful_shutdown_timeout: 30
|
graceful_shutdown_timeout: 30
|
||||||
session_cookie_name: "refreshing_session_id" # Change to something unique before deploy
|
session_cookie_name: "Wavelet_session_id" # Change to something unique before deploy
|
||||||
session_secret: "<uniq-random-string>" # Cannot be changed after first start
|
session_secret: "<uniq-random-string>" # Cannot be changed after first start
|
||||||
session_domain: "" # e.g. ".yourdomain.com"
|
session_domain: "" # e.g. ".yourdomain.com"
|
||||||
session_age: 86400 # Session lifetime in seconds (default: 24h)
|
session_age: 86400 # Session lifetime in seconds (default: 24h)
|
||||||
@@ -25,7 +25,7 @@ database:
|
|||||||
port: 5432
|
port: 5432
|
||||||
username: "postgres"
|
username: "postgres"
|
||||||
password: "postgres"
|
password: "postgres"
|
||||||
database: "refreshing"
|
database: "wavelet"
|
||||||
max_idle_conn: 16
|
max_idle_conn: 16
|
||||||
max_open_conn: 128
|
max_open_conn: 128
|
||||||
conn_max_lifetime: 1800
|
conn_max_lifetime: 1800
|
||||||
@@ -33,7 +33,7 @@ database:
|
|||||||
log_level: "info" # error | warn | info | debug | silent
|
log_level: "info" # error | warn | info | debug | silent
|
||||||
ssl_mode: "disable"
|
ssl_mode: "disable"
|
||||||
time_zone: "UTC"
|
time_zone: "UTC"
|
||||||
application_name: "refreshing-server"
|
application_name: "wavelet-server"
|
||||||
prefer_simple_protocol: false
|
prefer_simple_protocol: false
|
||||||
search_path: "public"
|
search_path: "public"
|
||||||
statement_cache_capacity: 256
|
statement_cache_capacity: 256
|
||||||
@@ -53,7 +53,7 @@ clickhouse:
|
|||||||
- "127.0.0.1:9000"
|
- "127.0.0.1:9000"
|
||||||
username: "default"
|
username: "default"
|
||||||
password: ""
|
password: ""
|
||||||
database: "refreshing"
|
database: "wavelet"
|
||||||
max_idle_conn: 10
|
max_idle_conn: 10
|
||||||
max_open_conn: 100
|
max_open_conn: 100
|
||||||
conn_max_lifetime: 3600
|
conn_max_lifetime: 3600
|
||||||
@@ -71,7 +71,7 @@ redis:
|
|||||||
db: 0 # Ignored in Cluster mode
|
db: 0 # Ignored in Cluster mode
|
||||||
cluster_mode: false # Set true to enable Cluster mode
|
cluster_mode: false # Set true to enable Cluster mode
|
||||||
master_name: "" # Set non-empty to enable Sentinel mode
|
master_name: "" # Set non-empty to enable Sentinel mode
|
||||||
key_prefix: "refreshing:"
|
key_prefix: "wavelet:"
|
||||||
pool_size: 100
|
pool_size: 100
|
||||||
min_idle_conn: 10
|
min_idle_conn: 10
|
||||||
dial_timeout: 5
|
dial_timeout: 5
|
||||||
|
|||||||
+6
-6
@@ -1,10 +1,10 @@
|
|||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
container_name: refreshing-postgres
|
container_name: Wavelet-postgres
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-refreshing}
|
POSTGRES_DB: ${POSTGRES_DB:-wavelet}
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||||
TZ: ${TZ:-Asia/Shanghai}
|
TZ: ${TZ:-Asia/Shanghai}
|
||||||
@@ -13,7 +13,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./data/postgres_data:/var/lib/postgresql/data
|
- ./data/postgres_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-refreshing}"]
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-wavelet}"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -21,7 +21,7 @@ services:
|
|||||||
#
|
#
|
||||||
# redis:
|
# redis:
|
||||||
# image: redis:7-alpine
|
# image: redis:7-alpine
|
||||||
# container_name: refreshing-redis
|
# container_name: wavelet-redis
|
||||||
# restart: unless-stopped
|
# restart: unless-stopped
|
||||||
# command: ["redis-server", "--appendonly", "yes"]
|
# command: ["redis-server", "--appendonly", "yes"]
|
||||||
# ports:
|
# ports:
|
||||||
@@ -37,12 +37,12 @@ services:
|
|||||||
#
|
#
|
||||||
# clickhouse:
|
# clickhouse:
|
||||||
# image: clickhouse/clickhouse-server:25.3-alpine
|
# image: clickhouse/clickhouse-server:25.3-alpine
|
||||||
# container_name: refreshing-clickhouse
|
# container_name: wavelet-clickhouse
|
||||||
# restart: unless-stopped
|
# restart: unless-stopped
|
||||||
# profiles:
|
# profiles:
|
||||||
# - clickhouse
|
# - clickhouse
|
||||||
# environment:
|
# environment:
|
||||||
# CLICKHOUSE_DB: ${CLICKHOUSE_DB:-refreshing}
|
# CLICKHOUSE_DB: ${CLICKHOUSE_DB:-wavelet}
|
||||||
# CLICKHOUSE_USER: ${CLICKHOUSE_USER:-default}
|
# CLICKHOUSE_USER: ${CLICKHOUSE_USER:-default}
|
||||||
# CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-}
|
# CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-}
|
||||||
# CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
|
# CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
# Refreshing 部署指南
|
# Wavelet 部署指南
|
||||||
|
|
||||||
本文档详细介绍了 **Refreshing** 脚手架系统在不同业务阶段的部署方案,涵盖从**最小化单机部署**到**最大化高可用分布式部署**的全生命周期架构。
|
本文档详细介绍了 **Wavelet** 脚手架系统在不同业务阶段的部署方案,涵盖从**最小化单机部署**到**最大化高可用分布式部署**的全生命周期架构。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {AppQueryProvider} from "@/components/providers/query-provider";
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Refreshing Platform",
|
title: "Wavelet Platform",
|
||||||
description: "通用二次开发标准范式平台",
|
description: "通用二次开发标准范式平台",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const formatNumber = (num: number | string) => {
|
|||||||
export function SystemStatusManager() {
|
export function SystemStatusManager() {
|
||||||
const [status, setStatus] = useState<SystemStatus | null>(null)
|
const [status, setStatus] = useState<SystemStatus | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [wavelet, setWavelet] = useState(false)
|
||||||
const [autoRefresh, setAutoRefresh] = useState(true)
|
const [autoRefresh, setAutoRefresh] = useState(true)
|
||||||
const [intervalTime, setIntervalTime] = useState("5000") // 默认5秒
|
const [intervalTime, setIntervalTime] = useState("5000") // 默认5秒
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ export function SystemStatusManager() {
|
|||||||
|
|
||||||
// 获取状态数据
|
// 获取状态数据
|
||||||
const fetchStatus = useCallback(async (isSilent = false) => {
|
const fetchStatus = useCallback(async (isSilent = false) => {
|
||||||
if (!isSilent) setRefreshing(true)
|
if (!isSilent) setWavelet(true)
|
||||||
try {
|
try {
|
||||||
const data = await services.admin.getSystemStatus()
|
const data = await services.admin.getSystemStatus()
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ export function SystemStatusManager() {
|
|||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setRefreshing(false)
|
setWavelet(false)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -196,9 +196,9 @@ export function SystemStatusManager() {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="h-7 text-[11px] gap-1.5"
|
className="h-7 text-[11px] gap-1.5"
|
||||||
onClick={() => fetchStatus()}
|
onClick={() => fetchStatus()}
|
||||||
disabled={refreshing}
|
disabled={wavelet}
|
||||||
>
|
>
|
||||||
<RefreshCw className={`size-3 ${refreshing ? "animate-spin" : ""}`} />
|
<RefreshCw className={`size-3 ${wavelet ? "animate-spin" : ""}`} />
|
||||||
刷新
|
刷新
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "linux-do-credit",
|
"name": "Wavelet",
|
||||||
"version": "1.3.9",
|
"version": "1.3.9",
|
||||||
"buildDate": "2026-04-22 16:00:00",
|
"buildDate": "2026-04-22 16:00:00",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user