From 15b3c83625d15e31ca105fee812f7f4b2ea1730e Mon Sep 17 00:00:00 2001 From: ryan Date: Wed, 10 Jun 2026 15:11:42 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E7=A4=BA=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agent/skills/new-api/SKILL.md | 117 +++++++++++++++ .../new-api/references/handler_example.go | 58 ++++++++ .../new-api/references/logics_example.go | 32 +++++ .../new-api/references/service_example.go | 45 ++++++ AGENTS.md | 1 + docs/docs.go | 135 +++++------------- docs/swagger.json | 135 +++++------------- docs/swagger.yaml | 82 +++-------- internal/apps/custom/routers.go | 23 +++ internal/router/custom.go | 17 +++ internal/router/router.go | 3 + 11 files changed, 378 insertions(+), 270 deletions(-) create mode 100644 .agent/skills/new-api/SKILL.md create mode 100644 .agent/skills/new-api/references/handler_example.go create mode 100644 .agent/skills/new-api/references/logics_example.go create mode 100644 .agent/skills/new-api/references/service_example.go create mode 100644 internal/apps/custom/routers.go create mode 100644 internal/router/custom.go diff --git a/.agent/skills/new-api/SKILL.md b/.agent/skills/new-api/SKILL.md new file mode 100644 index 00000000..f081b0eb --- /dev/null +++ b/.agent/skills/new-api/SKILL.md @@ -0,0 +1,117 @@ +--- +name: "new-api" +description: "Wavelet 项目专用:当新增或修改自定义业务 API、新增业务路由、新增 service 层核心逻辑时必须使用。本技能指导包职责划分、推荐文件结构、路由解耦、Swagger 文档生成与质量门禁验证。" +--- + +# 新增业务 API / 接口开发规范 + +本技能涵盖 Wavelet 的业务接口开发规范。开始开发前先阅读仓库根目录 [AGENTS.md](file:///Users/ryan/DEV/Go/Wavelet/AGENTS.md),遵守项目级核心规则。 + +为了保持核心路由入口的稳定性,**所有新增的定制业务接口路由统一注册在独立的 go 文件中,严禁直接堆叠到 `router.go`**。 + +--- + +## 包职责划分 (Package Responsibilities) + +按照 Go 语言最佳实践与 Google 的开发风格,接口开发应该进行严格的分层,以避免循环依赖和逻辑混乱。 + +| 目录/包名 | 职责定位 | 框架依赖限制 | 常见包含内容 | +| :--- | :--- | :--- | :--- | +| **`internal/router/`** | 路由分发层 | 依赖 Gin 框架 | `router.go` 核心路由、`custom.go` (自定义路由注册入口) | +| **`internal/apps/custom/`** | 应用入口层与本地逻辑层 | 依赖 Gin 框架 (路由/Handler 部分) | 接收 HTTP 请求、解析请求体(JSON/Query)、校验基础参数、提取 Session。对于**模块内闭环的简单业务逻辑**,直接在其下的 `logics.go` 或 `*_logic.go` 中实现。 | +| **`internal/service/`** | 核心跨模块业务服务层 | **禁止**依赖 Gin/HTTP 框架 | 仅存放**复杂、跨模块/跨领域交互,或被多端复用**(如同时被 Handler、后台 Asynq 任务、Cobra CLI 命令行调用)的业务核心逻辑。只接受 standard `context.Context`。 | +| **`internal/model/`** | 数据模型层 | 依赖 GORM / SQL 基础 | GORM 实体定义、表结构、主键生成、单表极简 SQL 查询方法。 | +| **`internal/db/`** | 数据存储层 | 依赖 SQL 驱动 / GORM 连接 | PostgreSQL, SQLite 等数据库连接管理与 Goose 数据库迁移文件。 | + +--- + +## 建议创建/修改的文件结构 + +当新增一套定制的业务接口(例如名为 `custom` 的业务模块)时,根据逻辑复杂度建议采用以下文件结构: + +```text +internal/ +├── router/ +│ └── custom.go # [修改/创建] 仅用于注册定制路由,将路由委托给 apps/custom +├── apps/ +│ └── custom/ +│ ├── routers.go # [新建] HTTP Handlers (Gin),负责参数绑定、校验与响应 +│ ├── logics.go # [新建] 承载模块内闭环的简单业务逻辑(保持该逻辑仅局限在当前模块) +│ └── errs.go # [新建] 仅存放业务特有的错误常量定义(可选) +└── service/ + └── custom.go # [新建/可选] 仅当出现跨模块交互、复杂多表事务或需要被 Task/CLI 复用时才创建 +``` + +--- + +## 核心开发步骤 (Step-by-Step Flow) + +### 步骤 1:如果有数据库变更,编写数据库迁移 +如果需要新表或字段,请参考 [database-migration](../database-migration/SKILL.md) 技能,在 `internal/db/migrator/goose/` 目录下编写迁移文件。在 `internal/model/` 中定义 GORM 数据模型。 + +### 步骤 2:判断业务逻辑的归属与放置 +在编写具体逻辑前,必须明确逻辑是属于**本地简单业务**还是**跨模块复杂业务**: +- **方案 A(推荐,轻量化优先)**:直接在 `internal/apps/custom/logics.go` 下定义函数。该函数虽然在 `apps` 目录下,但同样应该**保持纯 Go 参数**(不直接操作 `*gin.Context`),仅供 Handler 层直接调用。 +- **方案 B(当满足“跨模块”、“复杂事务”、“多入口调用”时)**:在 `internal/service/` 下创建独立的业务 Service 方法,以实现逻辑复用和领域解耦。 +参考示例:[service_example.go](file:///Users/ryan/DEV/Go/Wavelet/.agent/skills/new-api/references/service_example.go) + +### 步骤 3:在 `internal/apps/custom/` 下编写 HTTP Handler +创建应用路由文件 `routers.go`,定义接口的请求和响应 DTO,编写 Handler 绑定参数并调用 Service,编写 Swagger 注释。 +参考示例:[handler_example.go](file:///Users/ryan/DEV/Go/Wavelet/.agent/skills/new-api/references/handler_example.go) + +### 步骤 4:在 `internal/router/custom.go` 中注册路由 +创建路由挂载函数: +```go +package router + +import ( + "github.com/Rain-kl/Wavelet/internal/apps/custom" + "github.com/gin-gonic/gin" +) + +func registerCustomRoutes(apiV1Router *gin.RouterGroup) { + customRouter := apiV1Router.Group("/custom") + { + customRouter.POST("/action", custom.DoActionHandler) + } +} +``` +并在 [router.go](file:///Users/ryan/DEV/Go/Wavelet/internal/router/router.go) 中的 `/v1` 路由组末尾调用此函数。 + +--- + +## 质量验证与门禁 (Verification Quality Gates) + +每当新增或修改 API 接口时,必须严格执行以下验证: + +1. **生成授权许可**: + 新增 Go 文件后,运行自动添加许可证头部命令: + ```bash + make license + ``` + +2. **生成 Swagger 文档**: + 在 Handler 编写完 `@Summary` 等 Swagger 注释后,必须生成更新: + ```bash + make swagger + ``` + *注意:若 Swagger 生成失败,请仔细排查注释格式或数据类型引用是否规范。* + +3. **静态代码检查与 Linting**: + 运行 `golangci-lint` 与前端 TypeScript 门禁,确保没有代码风格和类型安全隐患: + ```bash + make code-check + ``` + +4. **编译与功能测试**: + 运行整包编译与自动化测试: + ```bash + make build-test + ``` + +--- + +## 相关 Skills +* [go-context](../go-context/SKILL.md):了解如何在 Service 层正确传递取消信号和追踪 Trace。 +* [go-error-handling](../go-error-handling/SKILL.md):了解如何优雅地将业务错误向上传递,并在 Handler 层决定响应状态码。 +* [database-migration](../database-migration/SKILL.md):当新增接口需要额外表结构或默认配置种子时配合使用。 diff --git a/.agent/skills/new-api/references/handler_example.go b/.agent/skills/new-api/references/handler_example.go new file mode 100644 index 00000000..7f1efa1f --- /dev/null +++ b/.agent/skills/new-api/references/handler_example.go @@ -0,0 +1,58 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package references + +import ( + "net/http" + + "github.com/Rain-kl/Wavelet/internal/service" + "github.com/Rain-kl/Wavelet/internal/util" + "github.com/gin-gonic/gin" +) + +// customRequest 客户端请求体 DTO +type customRequest struct { + Payload string `json:"payload" binding:"required,min=1,max=100"` +} + +// customResponse API 响应体 DTO +type customResponse struct { + Result string `json:"result"` +} + +// HandleCustomBusiness 示例 API Handler +// @Summary 示例定制业务接口 +// @Description 接收数据载荷,调用 Service 执行核心逻辑,并返回统一格式的 JSON 结果。 +// @Tags custom +// @Accept json +// @Produce json +// @Param request body customRequest true "业务请求参数" +// @Success 200 {object} util.ResponseAny{data=customResponse} "操作成功" +// @Router /api/v1/custom/business [post] +func HandleCustomBusiness(c *gin.Context) { + // 1. 参数绑定与校验 + var req customRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, util.Err("参数校验失败:载荷不能为空且在 1-100 字符内")) + return + } + + // 2. 模拟获取当前上下文与已登录用户(例如从 Session 中提取) + // 通常结合 oauth.LoginRequired() 等中间件使用 + userID := int64(9527) + + // 3. 实例化业务 Service 并调用核心逻辑 + // 注意传入 c.Request.Context() 以正确传递 OpenTelemetry Tracing 等上下文信息 + svc := service.NewCustomService() + resText, err := svc.ProcessBusinessData(c.Request.Context(), userID, req.Payload) + if err != nil { + c.JSON(http.StatusInternalServerError, util.Err(err.Error())) + return + } + + // 4. 返回符合外层形状规范 { "error_msg": "", "data": ... } 的统一成功响应 + c.JSON(http.StatusOK, util.OK(customResponse{ + Result: resText, + })) +} diff --git a/.agent/skills/new-api/references/logics_example.go b/.agent/skills/new-api/references/logics_example.go new file mode 100644 index 00000000..d72bcaab --- /dev/null +++ b/.agent/skills/new-api/references/logics_example.go @@ -0,0 +1,32 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package references + +import ( + "context" + "errors" + "fmt" + + "github.com/Rain-kl/Wavelet/internal/logger" + "go.uber.org/zap" +) + +// ProcessLocalBusiness 示例的模块内部闭环业务逻辑 +// 1. 虽然存放在 apps/custom/logics.go 下,但依然遵循纯 Go 规范,不强依赖 gin.Context,以便逻辑清晰和便于单元测试。 +// 2. 仅用于当前应用模块私有的简单业务,避免滥用全局的 internal/service 从而导致 Service 臃肿。 +func ProcessLocalBusiness(ctx context.Context, userID int64, param string) (string, error) { + if param == "" { + return "", errors.New("param cannot be empty") + } + + logger.Info(ctx, "processing local business inside apps/custom/logics", + zap.Int64("user_id", userID), + zap.String("param", param), + ) + + // 执行轻量级、无需跨模块/多入口复用的本地计算或模型操作 + result := fmt.Sprintf("Processed local logic for user %d: %s", userID, param) + + return result, nil +} diff --git a/.agent/skills/new-api/references/service_example.go b/.agent/skills/new-api/references/service_example.go new file mode 100644 index 00000000..2e551314 --- /dev/null +++ b/.agent/skills/new-api/references/service_example.go @@ -0,0 +1,45 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package references + +import ( + "context" + "errors" + "fmt" + + "github.com/Rain-kl/Wavelet/internal/logger" + "go.uber.org/zap" +) + +// CustomService 示例业务 Service 结构体 +type CustomService struct { + // 这里可以注入数据库连接、配置对象或者其他基础服务的客户端 + // 例如:db *gorm.DB +} + +// NewCustomService 创建 CustomService 实例的构造函数 +func NewCustomService() *CustomService { + return &CustomService{} +} + +// ProcessBusinessData 演示核心业务处理逻辑的 Service 方法 +// 1. 首位参数必须是 context.Context,以传播链路追踪 (OTel) 和超时控制。 +// 2. 方法签名应该只包含纯 Go 的参数与返回值,禁止导入 Gin 或与 HTTP 相关的协议依赖。 +// 3. 将可能发生的核心异常通过 error 返回给上层,而不是在这一层转换成 HTTP 状态码。 +func (s *CustomService) ProcessBusinessData(ctx context.Context, userID int64, payload string) (string, error) { + if payload == "" { + return "", errors.New("payload cannot be empty") + } + + // 模拟执行业务逻辑... + logger.Info(ctx, "processing custom business data in service", + zap.Int64("user_id", userID), + zap.String("payload", payload), + ) + + // 这里可以包含数据库读写、事务控制、或者远程 API 调用等复杂逻辑。 + result := fmt.Sprintf("Success processed data for user %d: %s", userID, payload) + + return result, nil +} diff --git a/AGENTS.md b/AGENTS.md index 71988766..a985d2fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,7 @@ specialized workflows still live in `.agent/skills/`. ## Always Read The Matching Skill +- `new-api`: use when adding or changing custom business APIs, handlers, service layer logic, or registering customized endpoints. - `new-async-task`: use when adding or changing Asynq tasks, scheduled jobs, task metadata, task payload validation, task logs, task retry behavior, or Admin task APIs. diff --git a/docs/docs.go b/docs/docs.go index 8c626301..7cd931ee 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -677,88 +677,6 @@ const docTemplate = `{ } } }, - "/api/v1/admin/db-manage/table-data": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "根据传入的数据表名称进行分页数据查询,返回表结构列名及动态行数据,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取数据表数据", - "parameters": [ - { - "type": "string", - "description": "表名称", - "name": "table", - "in": "query", - "required": true - }, - { - "type": "integer", - "description": "页码,默认 1", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "每页大小,默认 10", - "name": "pageSize", - "in": "query" - } - ], - "responses": { - "200": { - "description": "获取成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/util.ResponseAny" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/db_manage.TableDataResponse" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/util.ResponseAny" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/util.ResponseAny" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/util.ResponseAny" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/util.ResponseAny" - } - } - } - } - }, "/api/v1/admin/db-manage/tables": { "get": { "security": [ @@ -2574,6 +2492,38 @@ const docTemplate = `{ } } }, + "/api/v1/custom/hello": { + "get": { + "description": "A sample business API for customization", + "produces": [ + "application/json" + ], + "tags": [ + "custom" + ], + "summary": "Sample Hello API", + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + } + } + } + }, "/api/v1/health": { "get": { "description": "检查服务是否正常运行,可用于负载均衡存活探测", @@ -4165,27 +4115,6 @@ const docTemplate = `{ } } }, - "db_manage.TableDataResponse": { - "type": "object", - "properties": { - "columns": { - "type": "array", - "items": { - "type": "string" - } - }, - "results": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": true - } - }, - "total": { - "type": "integer" - } - } - }, "logger.LogEntry": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 4c21fb50..5d37ed68 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -670,88 +670,6 @@ } } }, - "/api/v1/admin/db-manage/table-data": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "根据传入的数据表名称进行分页数据查询,返回表结构列名及动态行数据,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取数据表数据", - "parameters": [ - { - "type": "string", - "description": "表名称", - "name": "table", - "in": "query", - "required": true - }, - { - "type": "integer", - "description": "页码,默认 1", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "description": "每页大小,默认 10", - "name": "pageSize", - "in": "query" - } - ], - "responses": { - "200": { - "description": "获取成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/util.ResponseAny" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/db_manage.TableDataResponse" - } - } - } - ] - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/util.ResponseAny" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/util.ResponseAny" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/util.ResponseAny" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/util.ResponseAny" - } - } - } - } - }, "/api/v1/admin/db-manage/tables": { "get": { "security": [ @@ -2567,6 +2485,38 @@ } } }, + "/api/v1/custom/hello": { + "get": { + "description": "A sample business API for customization", + "produces": [ + "application/json" + ], + "tags": [ + "custom" + ], + "summary": "Sample Hello API", + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + } + } + } + }, "/api/v1/health": { "get": { "description": "检查服务是否正常运行,可用于负载均衡存活探测", @@ -4158,27 +4108,6 @@ } } }, - "db_manage.TableDataResponse": { - "type": "object", - "properties": { - "columns": { - "type": "array", - "items": { - "type": "string" - } - }, - "results": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": true - } - }, - "total": { - "type": "integer" - } - } - }, "logger.LogEntry": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 6b2a98c0..57f4e7ca 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -114,20 +114,6 @@ definitions: description: '"select" 或 "exec"' type: string type: object - db_manage.TableDataResponse: - properties: - columns: - items: - type: string - type: array - results: - items: - additionalProperties: true - type: object - type: array - total: - type: integer - type: object logger.LogEntry: properties: data: @@ -1365,56 +1351,6 @@ paths: summary: 执行 SQL 查询 tags: - admin - /api/v1/admin/db-manage/table-data: - get: - description: 根据传入的数据表名称进行分页数据查询,返回表结构列名及动态行数据,需要管理员权限 - parameters: - - description: 表名称 - in: query - name: table - required: true - type: string - - description: 页码,默认 1 - in: query - name: page - type: integer - - description: 每页大小,默认 10 - in: query - name: pageSize - type: integer - produces: - - application/json - responses: - "200": - description: 获取成功 - schema: - allOf: - - $ref: '#/definitions/util.ResponseAny' - - properties: - data: - $ref: '#/definitions/db_manage.TableDataResponse' - type: object - "400": - description: 参数错误 - schema: - $ref: '#/definitions/util.ResponseAny' - "401": - description: 未登录 - schema: - $ref: '#/definitions/util.ResponseAny' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/util.ResponseAny' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/util.ResponseAny' - security: - - SessionCookie: [] - summary: 获取数据表数据 - tags: - - admin /api/v1/admin/db-manage/tables: get: description: 返回当前数据库的所有用户自定义表名称列表,需要管理员权限 @@ -2509,6 +2445,24 @@ paths: summary: 获取公共配置 tags: - config + /api/v1/custom/hello: + get: + description: A sample business API for customization + produces: + - application/json + responses: + "200": + description: 成功 + schema: + allOf: + - $ref: '#/definitions/util.ResponseAny' + - properties: + data: + type: string + type: object + summary: Sample Hello API + tags: + - custom /api/v1/health: get: description: 检查服务是否正常运行,可用于负载均衡存活探测 diff --git a/internal/apps/custom/routers.go b/internal/apps/custom/routers.go new file mode 100644 index 00000000..b7cc4de7 --- /dev/null +++ b/internal/apps/custom/routers.go @@ -0,0 +1,23 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package custom provides custom business handlers +package custom + +import ( + "net/http" + + "github.com/Rain-kl/Wavelet/internal/util" + "github.com/gin-gonic/gin" +) + +// Hello is a sample handler for custom business logic +// @Summary Sample Hello API +// @Description A sample business API for customization +// @Tags custom +// @Produce json +// @Success 200 {object} util.ResponseAny{data=string} "成功" +// @Router /api/v1/custom/hello [get] +func Hello(c *gin.Context) { + c.JSON(http.StatusOK, util.OK("Hello from custom business module!")) +} diff --git a/internal/router/custom.go b/internal/router/custom.go new file mode 100644 index 00000000..14ad5032 --- /dev/null +++ b/internal/router/custom.go @@ -0,0 +1,17 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package router + +import ( + "github.com/Rain-kl/Wavelet/internal/apps/custom" + "github.com/gin-gonic/gin" +) + +// registerCustomRoutes registers custom business routes to keep router.go clean and stable. +func registerCustomRoutes(apiV1Router *gin.RouterGroup) { + customRouter := apiV1Router.Group("/custom") + { + customRouter.GET("/hello", custom.Hello) + } +} diff --git a/internal/router/router.go b/internal/router/router.go index ea99e92e..18339feb 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -280,6 +280,9 @@ func registerRoutes(r *gin.Engine) { adminRouter.PUT("/auth-sources/:id/toggle", admin_auth_source.ToggleAuthSource) adminRouter.DELETE("/auth-sources/:id", admin_auth_source.DeleteAuthSource) } + + // Register custom business routes + registerCustomRoutes(apiV1Router) } }