From 30edfac1b5e65a749bea1542ee8f03541a39ea84 Mon Sep 17 00:00:00 2001 From: kevin Date: Sat, 19 Sep 2026 17:26:07 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=20Swagger=20API=20=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/docs.go | 1089 +++++++++++++++++++++++++++++++++++ docs/swagger.json | 1064 ++++++++++++++++++++++++++++++++++ docs/swagger.yaml | 723 +++++++++++++++++++++++ go.mod | 23 + go.sum | 98 ++++ internal/api/api.go | 37 +- internal/api/notes.go | 94 ++- internal/api/user_groups.go | 93 ++- internal/api/users.go | 114 +++- main.go | 11 + 10 files changed, 3284 insertions(+), 62 deletions(-) create mode 100644 docs/docs.go create mode 100644 docs/swagger.json create mode 100644 docs/swagger.yaml diff --git a/docs/docs.go b/docs/docs.go new file mode 100644 index 0000000..c1f0012 --- /dev/null +++ b/docs/docs.go @@ -0,0 +1,1089 @@ +// Package docs Code generated by swaggo/swag. DO NOT EDIT +package docs + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "contact": {}, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/health": { + "get": { + "description": "检查服务与数据库连接状态;数据库不可用时返回 503。", + "produces": [ + "application/json" + ], + "tags": [ + "system" + ], + "summary": "Health check", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.HealthResponse" + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/api.HealthResponse" + } + } + } + } + }, + "/notes": { + "get": { + "description": "分页查询便签列表,按 id 倒序返回。page 从 1 开始;page_size 取值 1-100,默认 20。", + "produces": [ + "application/json" + ], + "tags": [ + "notes" + ], + "summary": "List notes", + "parameters": [ + { + "type": "integer", + "example": 1, + "description": "页码,默认 1", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "example": 20, + "description": "每页数量,默认 20,最大 100", + "name": "page_size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.NoteListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + } + } + }, + "post": { + "description": "创建便签。title 必填且最长 200 字符,content 可选。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "notes" + ], + "summary": "Create a note", + "parameters": [ + { + "description": "便签内容", + "name": "note", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api.NoteRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/model.Note" + } + }, + "400": { + "description": "参数无效", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + } + } + } + }, + "/notes/{id}": { + "get": { + "description": "按 id 查询单个便签。", + "produces": [ + "application/json" + ], + "tags": [ + "notes" + ], + "summary": "Get a note", + "parameters": [ + { + "type": "integer", + "example": 1, + "description": "便签 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.Note" + } + }, + "400": { + "description": "id 无效", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + } + } + }, + "put": { + "description": "全量更新便签的 title 与 content,字段校验规则同创建。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "notes" + ], + "summary": "Update a note", + "parameters": [ + { + "type": "integer", + "example": 1, + "description": "便签 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "便签内容", + "name": "note", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api.NoteRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.Note" + } + }, + "400": { + "description": "参数无效或 id 无效", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + } + } + }, + "delete": { + "description": "按 id 删除便签,成功时返回 204 且无响应体。", + "produces": [ + "application/json" + ], + "tags": [ + "notes" + ], + "summary": "Delete a note", + "parameters": [ + { + "type": "integer", + "example": 1, + "description": "便签 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "删除成功" + }, + "400": { + "description": "id 无效", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + } + } + } + }, + "/user-groups": { + "get": { + "description": "分页查询用户组列表,按 id 升序返回。page 从 1 开始;page_size 取值 1-100,默认 20。", + "produces": [ + "application/json" + ], + "tags": [ + "user-groups" + ], + "summary": "List user groups", + "parameters": [ + { + "type": "integer", + "example": 1, + "description": "页码,默认 1", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "example": 20, + "description": "每页数量,默认 20,最大 100", + "name": "page_size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.UserGroupListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + } + } + }, + "post": { + "description": "创建用户组。name 必填且唯一(最长 50 字符),description 可选(最长 255 字符);id 由服务端分配。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user-groups" + ], + "summary": "Create a user group", + "parameters": [ + { + "description": "用户组信息", + "name": "group", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api.UserGroupRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/model.UserGroup" + } + }, + "400": { + "description": "参数无效", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "409": { + "description": "用户组名称已存在", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + } + } + } + }, + "/user-groups/{id}": { + "get": { + "description": "按 id 查询单个用户组,id 0 为内置 admin 组。", + "produces": [ + "application/json" + ], + "tags": [ + "user-groups" + ], + "summary": "Get a user group", + "parameters": [ + { + "type": "integer", + "example": 1, + "description": "用户组 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.UserGroup" + } + }, + "400": { + "description": "id 无效", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + } + } + }, + "put": { + "description": "更新用户组的 name 与 description,name 必填且唯一。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user-groups" + ], + "summary": "Update a user group", + "parameters": [ + { + "type": "integer", + "example": 1, + "description": "用户组 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "用户组信息", + "name": "group", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api.UserGroupRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.UserGroup" + } + }, + "400": { + "description": "参数无效或 id 无效", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "409": { + "description": "用户组名称已存在", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + } + } + }, + "delete": { + "description": "按 id 删除用户组,成功时返回 204 且无响应体。系统内置组或组内仍有用户时返回 409。", + "produces": [ + "application/json" + ], + "tags": [ + "user-groups" + ], + "summary": "Delete a user group", + "parameters": [ + { + "type": "integer", + "example": 2, + "description": "用户组 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "删除成功" + }, + "400": { + "description": "id 无效", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "409": { + "description": "系统内置组不可删除或用户组内仍有用户", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + } + } + } + }, + "/users": { + "get": { + "description": "分页查询用户列表(含所属用户组),按 id 倒序返回。page 从 1 开始;page_size 取值 1-100,默认 20。", + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "List users", + "parameters": [ + { + "type": "integer", + "example": 1, + "description": "页码,默认 1", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "example": 20, + "description": "每页数量,默认 20,最大 100", + "name": "page_size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.UserListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + } + } + }, + "post": { + "description": "创建用户并关联用户组。username、email 唯一,password 长度 6-72;不传 group_ids 时默认加入普通用户组(id 1)。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Create a user", + "parameters": [ + { + "description": "用户信息", + "name": "user", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api.UserCreateRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/model.User" + } + }, + "400": { + "description": "参数无效或用户组不存在", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "409": { + "description": "用户名或邮箱已存在", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + } + } + } + }, + "/users/{id}": { + "get": { + "description": "按 id 查询用户(含所属用户组)。", + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Get a user", + "parameters": [ + { + "type": "integer", + "example": 1, + "description": "用户 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.User" + } + }, + "400": { + "description": "id 无效", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + } + } + }, + "put": { + "description": "更新用户信息,仅更新请求中提供的字段。传 group_ids 会整体替换用户组;password 非空时重置密码。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Update a user", + "parameters": [ + { + "type": "integer", + "example": 1, + "description": "用户 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "待更新字段", + "name": "user", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api.UserUpdateRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.User" + } + }, + "400": { + "description": "参数无效、id 无效或用户组不存在", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + } + } + }, + "delete": { + "description": "按 id 删除用户及其用户组成员关系,成功时返回 204 且无响应体。", + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Delete a user", + "parameters": [ + { + "type": "integer", + "example": 1, + "description": "用户 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "删除成功" + }, + "400": { + "description": "id 无效", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + } + } + } + } + }, + "definitions": { + "api.ErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "记录不存在" + } + } + }, + "api.HealthResponse": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "数据库不可用" + }, + "status": { + "type": "string", + "example": "ok" + } + } + }, + "api.NoteListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/model.Note" + } + }, + "page": { + "type": "integer", + "example": 1 + }, + "page_size": { + "type": "integer", + "example": 20 + }, + "total": { + "type": "integer", + "example": 42 + } + } + }, + "api.NoteRequest": { + "type": "object", + "required": [ + "title" + ], + "properties": { + "content": { + "type": "string", + "example": "牛奶、鸡蛋" + }, + "title": { + "type": "string", + "maxLength": 200, + "example": "购物清单" + } + } + }, + "api.UserCreateRequest": { + "type": "object", + "required": [ + "email", + "password", + "username" + ], + "properties": { + "avatar": { + "type": "string", + "maxLength": 255, + "example": "https://example.com/avatar.png" + }, + "email": { + "type": "string", + "maxLength": 255, + "example": "alice@example.com" + }, + "group_ids": { + "type": "array", + "items": { + "type": "integer" + }, + "example": [ + 1 + ] + }, + "nickname": { + "type": "string", + "maxLength": 50, + "example": "Alice" + }, + "password": { + "type": "string", + "maxLength": 72, + "minLength": 6, + "example": "secret123" + }, + "status": { + "type": "integer", + "enum": [ + 0, + 1 + ], + "example": 1 + }, + "username": { + "type": "string", + "maxLength": 50, + "example": "alice" + } + } + }, + "api.UserGroupListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/model.UserGroup" + } + }, + "page": { + "type": "integer", + "example": 1 + }, + "page_size": { + "type": "integer", + "example": 20 + }, + "total": { + "type": "integer", + "example": 42 + } + } + }, + "api.UserGroupRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "description": { + "type": "string", + "maxLength": 255, + "example": "负责日常运营" + }, + "name": { + "type": "string", + "maxLength": 50, + "example": "运营组" + } + } + }, + "api.UserListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/model.User" + } + }, + "page": { + "type": "integer", + "example": 1 + }, + "page_size": { + "type": "integer", + "example": 20 + }, + "total": { + "type": "integer", + "example": 42 + } + } + }, + "api.UserUpdateRequest": { + "type": "object", + "properties": { + "avatar": { + "type": "string", + "maxLength": 255, + "example": "https://example.com/avatar.png" + }, + "group_ids": { + "type": "array", + "items": { + "type": "integer" + }, + "example": [ + 1 + ] + }, + "nickname": { + "type": "string", + "maxLength": 50, + "example": "Alice" + }, + "password": { + "type": "string", + "maxLength": 72, + "minLength": 6, + "example": "secret123" + }, + "status": { + "type": "integer", + "enum": [ + 0, + 1 + ], + "example": 1 + } + } + }, + "model.Note": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "model.User": { + "type": "object", + "properties": { + "avatar": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "groups": { + "type": "array", + "items": { + "$ref": "#/definitions/model.UserGroup" + } + }, + "id": { + "type": "integer" + }, + "nickname": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "updated_at": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "model.UserGroup": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_system": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + } + } +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "1.0", + Host: "", + BasePath: "/api", + Schemes: []string{}, + Title: "Rill API", + Description: "Rill 服务端 HTTP API 文档,所有接口以配置项 api.prefix(默认 /api)为前缀,请求与响应均为 JSON。\nSwagger 页面:{prefix}/swagger/index.html;OpenAPI JSON:{prefix}/swagger/doc.json。", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/docs/swagger.json b/docs/swagger.json new file mode 100644 index 0000000..4bc2074 --- /dev/null +++ b/docs/swagger.json @@ -0,0 +1,1064 @@ +{ + "swagger": "2.0", + "info": { + "description": "Rill 服务端 HTTP API 文档,所有接口以配置项 api.prefix(默认 /api)为前缀,请求与响应均为 JSON。\nSwagger 页面:{prefix}/swagger/index.html;OpenAPI JSON:{prefix}/swagger/doc.json。", + "title": "Rill API", + "contact": {}, + "version": "1.0" + }, + "basePath": "/api", + "paths": { + "/health": { + "get": { + "description": "检查服务与数据库连接状态;数据库不可用时返回 503。", + "produces": [ + "application/json" + ], + "tags": [ + "system" + ], + "summary": "Health check", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.HealthResponse" + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/api.HealthResponse" + } + } + } + } + }, + "/notes": { + "get": { + "description": "分页查询便签列表,按 id 倒序返回。page 从 1 开始;page_size 取值 1-100,默认 20。", + "produces": [ + "application/json" + ], + "tags": [ + "notes" + ], + "summary": "List notes", + "parameters": [ + { + "type": "integer", + "example": 1, + "description": "页码,默认 1", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "example": 20, + "description": "每页数量,默认 20,最大 100", + "name": "page_size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.NoteListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + } + } + }, + "post": { + "description": "创建便签。title 必填且最长 200 字符,content 可选。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "notes" + ], + "summary": "Create a note", + "parameters": [ + { + "description": "便签内容", + "name": "note", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api.NoteRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/model.Note" + } + }, + "400": { + "description": "参数无效", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + } + } + } + }, + "/notes/{id}": { + "get": { + "description": "按 id 查询单个便签。", + "produces": [ + "application/json" + ], + "tags": [ + "notes" + ], + "summary": "Get a note", + "parameters": [ + { + "type": "integer", + "example": 1, + "description": "便签 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.Note" + } + }, + "400": { + "description": "id 无效", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + } + } + }, + "put": { + "description": "全量更新便签的 title 与 content,字段校验规则同创建。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "notes" + ], + "summary": "Update a note", + "parameters": [ + { + "type": "integer", + "example": 1, + "description": "便签 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "便签内容", + "name": "note", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api.NoteRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.Note" + } + }, + "400": { + "description": "参数无效或 id 无效", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + } + } + }, + "delete": { + "description": "按 id 删除便签,成功时返回 204 且无响应体。", + "produces": [ + "application/json" + ], + "tags": [ + "notes" + ], + "summary": "Delete a note", + "parameters": [ + { + "type": "integer", + "example": 1, + "description": "便签 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "删除成功" + }, + "400": { + "description": "id 无效", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + } + } + } + }, + "/user-groups": { + "get": { + "description": "分页查询用户组列表,按 id 升序返回。page 从 1 开始;page_size 取值 1-100,默认 20。", + "produces": [ + "application/json" + ], + "tags": [ + "user-groups" + ], + "summary": "List user groups", + "parameters": [ + { + "type": "integer", + "example": 1, + "description": "页码,默认 1", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "example": 20, + "description": "每页数量,默认 20,最大 100", + "name": "page_size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.UserGroupListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + } + } + }, + "post": { + "description": "创建用户组。name 必填且唯一(最长 50 字符),description 可选(最长 255 字符);id 由服务端分配。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user-groups" + ], + "summary": "Create a user group", + "parameters": [ + { + "description": "用户组信息", + "name": "group", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api.UserGroupRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/model.UserGroup" + } + }, + "400": { + "description": "参数无效", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "409": { + "description": "用户组名称已存在", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + } + } + } + }, + "/user-groups/{id}": { + "get": { + "description": "按 id 查询单个用户组,id 0 为内置 admin 组。", + "produces": [ + "application/json" + ], + "tags": [ + "user-groups" + ], + "summary": "Get a user group", + "parameters": [ + { + "type": "integer", + "example": 1, + "description": "用户组 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.UserGroup" + } + }, + "400": { + "description": "id 无效", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + } + } + }, + "put": { + "description": "更新用户组的 name 与 description,name 必填且唯一。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user-groups" + ], + "summary": "Update a user group", + "parameters": [ + { + "type": "integer", + "example": 1, + "description": "用户组 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "用户组信息", + "name": "group", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api.UserGroupRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.UserGroup" + } + }, + "400": { + "description": "参数无效或 id 无效", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "409": { + "description": "用户组名称已存在", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + } + } + }, + "delete": { + "description": "按 id 删除用户组,成功时返回 204 且无响应体。系统内置组或组内仍有用户时返回 409。", + "produces": [ + "application/json" + ], + "tags": [ + "user-groups" + ], + "summary": "Delete a user group", + "parameters": [ + { + "type": "integer", + "example": 2, + "description": "用户组 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "删除成功" + }, + "400": { + "description": "id 无效", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "409": { + "description": "系统内置组不可删除或用户组内仍有用户", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + } + } + } + }, + "/users": { + "get": { + "description": "分页查询用户列表(含所属用户组),按 id 倒序返回。page 从 1 开始;page_size 取值 1-100,默认 20。", + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "List users", + "parameters": [ + { + "type": "integer", + "example": 1, + "description": "页码,默认 1", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "example": 20, + "description": "每页数量,默认 20,最大 100", + "name": "page_size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.UserListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + } + } + }, + "post": { + "description": "创建用户并关联用户组。username、email 唯一,password 长度 6-72;不传 group_ids 时默认加入普通用户组(id 1)。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Create a user", + "parameters": [ + { + "description": "用户信息", + "name": "user", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api.UserCreateRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/model.User" + } + }, + "400": { + "description": "参数无效或用户组不存在", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "409": { + "description": "用户名或邮箱已存在", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + } + } + } + }, + "/users/{id}": { + "get": { + "description": "按 id 查询用户(含所属用户组)。", + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Get a user", + "parameters": [ + { + "type": "integer", + "example": 1, + "description": "用户 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.User" + } + }, + "400": { + "description": "id 无效", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + } + } + }, + "put": { + "description": "更新用户信息,仅更新请求中提供的字段。传 group_ids 会整体替换用户组;password 非空时重置密码。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Update a user", + "parameters": [ + { + "type": "integer", + "example": 1, + "description": "用户 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "待更新字段", + "name": "user", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api.UserUpdateRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.User" + } + }, + "400": { + "description": "参数无效、id 无效或用户组不存在", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + } + } + }, + "delete": { + "description": "按 id 删除用户及其用户组成员关系,成功时返回 204 且无响应体。", + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Delete a user", + "parameters": [ + { + "type": "integer", + "example": 1, + "description": "用户 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "删除成功" + }, + "400": { + "description": "id 无效", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "404": { + "description": "记录不存在", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.ErrorResponse" + } + } + } + } + } + }, + "definitions": { + "api.ErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "记录不存在" + } + } + }, + "api.HealthResponse": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "数据库不可用" + }, + "status": { + "type": "string", + "example": "ok" + } + } + }, + "api.NoteListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/model.Note" + } + }, + "page": { + "type": "integer", + "example": 1 + }, + "page_size": { + "type": "integer", + "example": 20 + }, + "total": { + "type": "integer", + "example": 42 + } + } + }, + "api.NoteRequest": { + "type": "object", + "required": [ + "title" + ], + "properties": { + "content": { + "type": "string", + "example": "牛奶、鸡蛋" + }, + "title": { + "type": "string", + "maxLength": 200, + "example": "购物清单" + } + } + }, + "api.UserCreateRequest": { + "type": "object", + "required": [ + "email", + "password", + "username" + ], + "properties": { + "avatar": { + "type": "string", + "maxLength": 255, + "example": "https://example.com/avatar.png" + }, + "email": { + "type": "string", + "maxLength": 255, + "example": "alice@example.com" + }, + "group_ids": { + "type": "array", + "items": { + "type": "integer" + }, + "example": [ + 1 + ] + }, + "nickname": { + "type": "string", + "maxLength": 50, + "example": "Alice" + }, + "password": { + "type": "string", + "maxLength": 72, + "minLength": 6, + "example": "secret123" + }, + "status": { + "type": "integer", + "enum": [ + 0, + 1 + ], + "example": 1 + }, + "username": { + "type": "string", + "maxLength": 50, + "example": "alice" + } + } + }, + "api.UserGroupListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/model.UserGroup" + } + }, + "page": { + "type": "integer", + "example": 1 + }, + "page_size": { + "type": "integer", + "example": 20 + }, + "total": { + "type": "integer", + "example": 42 + } + } + }, + "api.UserGroupRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "description": { + "type": "string", + "maxLength": 255, + "example": "负责日常运营" + }, + "name": { + "type": "string", + "maxLength": 50, + "example": "运营组" + } + } + }, + "api.UserListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/model.User" + } + }, + "page": { + "type": "integer", + "example": 1 + }, + "page_size": { + "type": "integer", + "example": 20 + }, + "total": { + "type": "integer", + "example": 42 + } + } + }, + "api.UserUpdateRequest": { + "type": "object", + "properties": { + "avatar": { + "type": "string", + "maxLength": 255, + "example": "https://example.com/avatar.png" + }, + "group_ids": { + "type": "array", + "items": { + "type": "integer" + }, + "example": [ + 1 + ] + }, + "nickname": { + "type": "string", + "maxLength": 50, + "example": "Alice" + }, + "password": { + "type": "string", + "maxLength": 72, + "minLength": 6, + "example": "secret123" + }, + "status": { + "type": "integer", + "enum": [ + 0, + 1 + ], + "example": 1 + } + } + }, + "model.Note": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "model.User": { + "type": "object", + "properties": { + "avatar": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "groups": { + "type": "array", + "items": { + "$ref": "#/definitions/model.UserGroup" + } + }, + "id": { + "type": "integer" + }, + "nickname": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "updated_at": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "model.UserGroup": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_system": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + } + } +} \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml new file mode 100644 index 0000000..a3c151a --- /dev/null +++ b/docs/swagger.yaml @@ -0,0 +1,723 @@ +basePath: /api +definitions: + api.ErrorResponse: + properties: + error: + example: 记录不存在 + type: string + type: object + api.HealthResponse: + properties: + error: + example: 数据库不可用 + type: string + status: + example: ok + type: string + type: object + api.NoteListResponse: + properties: + items: + items: + $ref: '#/definitions/model.Note' + type: array + page: + example: 1 + type: integer + page_size: + example: 20 + type: integer + total: + example: 42 + type: integer + type: object + api.NoteRequest: + properties: + content: + example: 牛奶、鸡蛋 + type: string + title: + example: 购物清单 + maxLength: 200 + type: string + required: + - title + type: object + api.UserCreateRequest: + properties: + avatar: + example: https://example.com/avatar.png + maxLength: 255 + type: string + email: + example: alice@example.com + maxLength: 255 + type: string + group_ids: + example: + - 1 + items: + type: integer + type: array + nickname: + example: Alice + maxLength: 50 + type: string + password: + example: secret123 + maxLength: 72 + minLength: 6 + type: string + status: + enum: + - 0 + - 1 + example: 1 + type: integer + username: + example: alice + maxLength: 50 + type: string + required: + - email + - password + - username + type: object + api.UserGroupListResponse: + properties: + items: + items: + $ref: '#/definitions/model.UserGroup' + type: array + page: + example: 1 + type: integer + page_size: + example: 20 + type: integer + total: + example: 42 + type: integer + type: object + api.UserGroupRequest: + properties: + description: + example: 负责日常运营 + maxLength: 255 + type: string + name: + example: 运营组 + maxLength: 50 + type: string + required: + - name + type: object + api.UserListResponse: + properties: + items: + items: + $ref: '#/definitions/model.User' + type: array + page: + example: 1 + type: integer + page_size: + example: 20 + type: integer + total: + example: 42 + type: integer + type: object + api.UserUpdateRequest: + properties: + avatar: + example: https://example.com/avatar.png + maxLength: 255 + type: string + group_ids: + example: + - 1 + items: + type: integer + type: array + nickname: + example: Alice + maxLength: 50 + type: string + password: + example: secret123 + maxLength: 72 + minLength: 6 + type: string + status: + enum: + - 0 + - 1 + example: 1 + type: integer + type: object + model.Note: + properties: + content: + type: string + created_at: + type: string + id: + type: integer + title: + type: string + updated_at: + type: string + type: object + model.User: + properties: + avatar: + type: string + created_at: + type: string + email: + type: string + groups: + items: + $ref: '#/definitions/model.UserGroup' + type: array + id: + type: integer + nickname: + type: string + status: + type: integer + updated_at: + type: string + username: + type: string + type: object + model.UserGroup: + properties: + created_at: + type: string + description: + type: string + id: + type: integer + is_system: + type: boolean + name: + type: string + updated_at: + type: string + type: object +info: + contact: {} + description: |- + Rill 服务端 HTTP API 文档,所有接口以配置项 api.prefix(默认 /api)为前缀,请求与响应均为 JSON。 + Swagger 页面:{prefix}/swagger/index.html;OpenAPI JSON:{prefix}/swagger/doc.json。 + title: Rill API + version: "1.0" +paths: + /health: + get: + description: 检查服务与数据库连接状态;数据库不可用时返回 503。 + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.HealthResponse' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/api.HealthResponse' + summary: Health check + tags: + - system + /notes: + get: + description: 分页查询便签列表,按 id 倒序返回。page 从 1 开始;page_size 取值 1-100,默认 20。 + parameters: + - description: 页码,默认 1 + example: 1 + in: query + name: page + type: integer + - description: 每页数量,默认 20,最大 100 + example: 20 + in: query + name: page_size + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.NoteListResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.ErrorResponse' + summary: List notes + tags: + - notes + post: + consumes: + - application/json + description: 创建便签。title 必填且最长 200 字符,content 可选。 + parameters: + - description: 便签内容 + in: body + name: note + required: true + schema: + $ref: '#/definitions/api.NoteRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/model.Note' + "400": + description: 参数无效 + schema: + $ref: '#/definitions/api.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.ErrorResponse' + summary: Create a note + tags: + - notes + /notes/{id}: + delete: + description: 按 id 删除便签,成功时返回 204 且无响应体。 + parameters: + - description: 便签 ID + example: 1 + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "204": + description: 删除成功 + "400": + description: id 无效 + schema: + $ref: '#/definitions/api.ErrorResponse' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/api.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.ErrorResponse' + summary: Delete a note + tags: + - notes + get: + description: 按 id 查询单个便签。 + parameters: + - description: 便签 ID + example: 1 + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/model.Note' + "400": + description: id 无效 + schema: + $ref: '#/definitions/api.ErrorResponse' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/api.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.ErrorResponse' + summary: Get a note + tags: + - notes + put: + consumes: + - application/json + description: 全量更新便签的 title 与 content,字段校验规则同创建。 + parameters: + - description: 便签 ID + example: 1 + in: path + name: id + required: true + type: integer + - description: 便签内容 + in: body + name: note + required: true + schema: + $ref: '#/definitions/api.NoteRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/model.Note' + "400": + description: 参数无效或 id 无效 + schema: + $ref: '#/definitions/api.ErrorResponse' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/api.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.ErrorResponse' + summary: Update a note + tags: + - notes + /user-groups: + get: + description: 分页查询用户组列表,按 id 升序返回。page 从 1 开始;page_size 取值 1-100,默认 20。 + parameters: + - description: 页码,默认 1 + example: 1 + in: query + name: page + type: integer + - description: 每页数量,默认 20,最大 100 + example: 20 + in: query + name: page_size + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.UserGroupListResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.ErrorResponse' + summary: List user groups + tags: + - user-groups + post: + consumes: + - application/json + description: 创建用户组。name 必填且唯一(最长 50 字符),description 可选(最长 255 字符);id 由服务端分配。 + parameters: + - description: 用户组信息 + in: body + name: group + required: true + schema: + $ref: '#/definitions/api.UserGroupRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/model.UserGroup' + "400": + description: 参数无效 + schema: + $ref: '#/definitions/api.ErrorResponse' + "409": + description: 用户组名称已存在 + schema: + $ref: '#/definitions/api.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.ErrorResponse' + summary: Create a user group + tags: + - user-groups + /user-groups/{id}: + delete: + description: 按 id 删除用户组,成功时返回 204 且无响应体。系统内置组或组内仍有用户时返回 409。 + parameters: + - description: 用户组 ID + example: 2 + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "204": + description: 删除成功 + "400": + description: id 无效 + schema: + $ref: '#/definitions/api.ErrorResponse' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/api.ErrorResponse' + "409": + description: 系统内置组不可删除或用户组内仍有用户 + schema: + $ref: '#/definitions/api.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.ErrorResponse' + summary: Delete a user group + tags: + - user-groups + get: + description: 按 id 查询单个用户组,id 0 为内置 admin 组。 + parameters: + - description: 用户组 ID + example: 1 + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/model.UserGroup' + "400": + description: id 无效 + schema: + $ref: '#/definitions/api.ErrorResponse' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/api.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.ErrorResponse' + summary: Get a user group + tags: + - user-groups + put: + consumes: + - application/json + description: 更新用户组的 name 与 description,name 必填且唯一。 + parameters: + - description: 用户组 ID + example: 1 + in: path + name: id + required: true + type: integer + - description: 用户组信息 + in: body + name: group + required: true + schema: + $ref: '#/definitions/api.UserGroupRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/model.UserGroup' + "400": + description: 参数无效或 id 无效 + schema: + $ref: '#/definitions/api.ErrorResponse' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/api.ErrorResponse' + "409": + description: 用户组名称已存在 + schema: + $ref: '#/definitions/api.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.ErrorResponse' + summary: Update a user group + tags: + - user-groups + /users: + get: + description: 分页查询用户列表(含所属用户组),按 id 倒序返回。page 从 1 开始;page_size 取值 1-100,默认 20。 + parameters: + - description: 页码,默认 1 + example: 1 + in: query + name: page + type: integer + - description: 每页数量,默认 20,最大 100 + example: 20 + in: query + name: page_size + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.UserListResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.ErrorResponse' + summary: List users + tags: + - users + post: + consumes: + - application/json + description: 创建用户并关联用户组。username、email 唯一,password 长度 6-72;不传 group_ids 时默认加入普通用户组(id + 1)。 + parameters: + - description: 用户信息 + in: body + name: user + required: true + schema: + $ref: '#/definitions/api.UserCreateRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/model.User' + "400": + description: 参数无效或用户组不存在 + schema: + $ref: '#/definitions/api.ErrorResponse' + "409": + description: 用户名或邮箱已存在 + schema: + $ref: '#/definitions/api.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.ErrorResponse' + summary: Create a user + tags: + - users + /users/{id}: + delete: + description: 按 id 删除用户及其用户组成员关系,成功时返回 204 且无响应体。 + parameters: + - description: 用户 ID + example: 1 + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "204": + description: 删除成功 + "400": + description: id 无效 + schema: + $ref: '#/definitions/api.ErrorResponse' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/api.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.ErrorResponse' + summary: Delete a user + tags: + - users + get: + description: 按 id 查询用户(含所属用户组)。 + parameters: + - description: 用户 ID + example: 1 + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/model.User' + "400": + description: id 无效 + schema: + $ref: '#/definitions/api.ErrorResponse' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/api.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.ErrorResponse' + summary: Get a user + tags: + - users + put: + consumes: + - application/json + description: 更新用户信息,仅更新请求中提供的字段。传 group_ids 会整体替换用户组;password 非空时重置密码。 + parameters: + - description: 用户 ID + example: 1 + in: path + name: id + required: true + type: integer + - description: 待更新字段 + in: body + name: user + required: true + schema: + $ref: '#/definitions/api.UserUpdateRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/model.User' + "400": + description: 参数无效、id 无效或用户组不存在 + schema: + $ref: '#/definitions/api.ErrorResponse' + "404": + description: 记录不存在 + schema: + $ref: '#/definitions/api.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.ErrorResponse' + summary: Update a user + tags: + - users +swagger: "2.0" diff --git a/go.mod b/go.mod index 5f81053..9f53530 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,9 @@ require ( github.com/gin-gonic/gin v1.12.0 github.com/glebarez/sqlite v1.11.0 github.com/goccy/go-yaml v1.19.2 + github.com/swaggo/files v1.0.1 + github.com/swaggo/gin-swagger v1.6.1 + github.com/swaggo/swag v1.16.6 golang.org/x/crypto v0.55.0 gorm.io/driver/mysql v1.6.0 gorm.io/gorm v1.31.2 @@ -14,14 +17,22 @@ require ( require ( filippo.io/edwards25519 v1.1.0 // indirect + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/PuerkitoBio/purell v1.1.1 // indirect + github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect github.com/bytedance/gopkg v0.1.4 // indirect github.com/bytedance/sonic v1.15.2 // indirect github.com/bytedance/sonic/loader v0.5.1 // indirect github.com/cloudwego/base64x v0.1.7 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gin-contrib/sse v1.1.1 // indirect github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/go-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/jsonreference v0.19.6 // indirect + github.com/go-openapi/spec v0.20.4 // indirect + github.com/go-openapi/swag v0.19.15 // indirect 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 @@ -30,9 +41,11 @@ require ( github.com/google/uuid v1.3.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect + github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/mailru/easyjson v0.7.6 // indirect github.com/mattn/go-isatty v0.0.23 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect @@ -40,16 +53,26 @@ require ( github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.60.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/russross/blackfriday/v2 v2.0.1 // indirect + github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect + github.com/urfave/cli/v2 v2.3.0 // indirect go.mongodb.org/mongo-driver/v2 v2.8.0 // indirect golang.org/x/arch v0.29.0 // indirect + golang.org/x/mod v0.38.0 // indirect golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.41.0 // indirect + golang.org/x/tools v0.48.0 // indirect google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect modernc.org/libc v1.22.5 // indirect modernc.org/mathutil v1.5.0 // indirect modernc.org/memory v1.5.0 // indirect modernc.org/sqlite v1.23.1 // indirect + sigs.k8s.io/yaml v1.3.0 // indirect ) + +tool github.com/swaggo/swag/cmd/swag diff --git a/go.sum b/go.sum index 0a1cb46..6cb1fc4 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,12 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= 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= @@ -8,6 +15,9 @@ github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NI github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI= github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -17,6 +27,8 @@ github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9 github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/cors v1.7.8 h1:U0jjyXlWXMEx27hFE6hmdg/TOZviYgsOGGT+kLmUZrc= github.com/gin-contrib/cors v1.7.8/go.mod h1:u3nLI2pP1IlJn7tbvL7iiubDO9quBZ8FXP9LxmqpsPI= +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/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= @@ -25,6 +37,16 @@ github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9g github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= 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-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs= +github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= +github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M= +github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= 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= @@ -50,12 +72,25 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD 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/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 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.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +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.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ= github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= @@ -65,6 +100,7 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -78,39 +114,99 @@ github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2v github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 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/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 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/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/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.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/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.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= +github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 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.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= 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.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +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-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= +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/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-20210420072515-93ed5bcd2bfe/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.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.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +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= 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-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/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/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.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +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-20200615113413-eeeca48fe776/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/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= @@ -127,3 +223,5 @@ modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= +sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= +sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= diff --git a/internal/api/api.go b/internal/api/api.go index 439f9a7..bb70d9b 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -4,9 +4,12 @@ package api import ( "errors" "net/http" + "path" "strconv" "github.com/gin-gonic/gin" + swaggerFiles "github.com/swaggo/files" + ginSwagger "github.com/swaggo/gin-swagger" "gorm.io/gorm" "rill/internal/database" @@ -16,6 +19,14 @@ import ( func RegisterRoutes(rg *gin.RouterGroup, db *gorm.DB) { rg.GET("/health", health(db)) + swagger := rg.Group("/swagger") + { + swagger.GET("", func(c *gin.Context) { + c.Redirect(http.StatusFound, path.Join("/", rg.BasePath(), "swagger", "index.html")) + }) + swagger.GET("/*any", ginSwagger.WrapHandler(swaggerFiles.Handler, ginSwagger.URL("doc.json"))) + } + notes := rg.Group("/notes") { notes.GET("", listNotes(db)) @@ -59,7 +70,7 @@ func parsePagination(c *gin.Context) (int, int) { // respondGetError 查询类错误:记录不存在返回 404,其余按数据库错误处理。 func respondGetError(c *gin.Context, err error) { if errors.Is(err, gorm.ErrRecordNotFound) { - c.JSON(http.StatusNotFound, gin.H{"error": "记录不存在"}) + c.JSON(http.StatusNotFound, ErrorResponse{Error: "记录不存在"}) return } respondDBError(c, err) @@ -68,18 +79,36 @@ func respondGetError(c *gin.Context, err error) { // respondDuplicateOrDBError 写入类错误:唯一约束冲突返回 409,其余按数据库错误处理。 func respondDuplicateOrDBError(c *gin.Context, err error, duplicateMsg string) { if errors.Is(err, gorm.ErrDuplicatedKey) { - c.JSON(http.StatusConflict, gin.H{"error": duplicateMsg}) + c.JSON(http.StatusConflict, ErrorResponse{Error: duplicateMsg}) return } respondDBError(c, err) } +// ErrorResponse 统一错误响应。 +type ErrorResponse struct { + Error string `json:"error" example:"记录不存在"` +} + +// HealthResponse 健康检查响应。 +type HealthResponse struct { + Status string `json:"status" example:"ok"` + Error string `json:"error,omitempty" example:"数据库不可用"` +} + +// @Summary Health check +// @Description 检查服务与数据库连接状态;数据库不可用时返回 503。 +// @Tags system +// @Produce json +// @Success 200 {object} api.HealthResponse +// @Failure 503 {object} api.HealthResponse +// @Router /health [get] func health(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { if err := database.Ping(c.Request.Context(), db); err != nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"status": "error", "error": "数据库不可用"}) + c.JSON(http.StatusServiceUnavailable, HealthResponse{Status: "error", Error: "数据库不可用"}) return } - c.JSON(http.StatusOK, gin.H{"status": "ok"}) + c.JSON(http.StatusOK, HealthResponse{Status: "ok"}) } } diff --git a/internal/api/notes.go b/internal/api/notes.go index 51e309c..81ae2c5 100644 --- a/internal/api/notes.go +++ b/internal/api/notes.go @@ -17,11 +17,29 @@ const ( maxPageSize = 100 ) -type noteRequest struct { - Title string `json:"title" binding:"required,max=200"` - Content string `json:"content"` +// NoteRequest 创建/更新便签请求。 +type NoteRequest struct { + Title string `json:"title" binding:"required,max=200" example:"购物清单"` + Content string `json:"content" example:"牛奶、鸡蛋"` } +// NoteListResponse 便签分页列表响应。 +type NoteListResponse struct { + Items []model.Note `json:"items"` + Total int64 `json:"total" example:"42"` + Page int `json:"page" example:"1"` + PageSize int `json:"page_size" example:"20"` +} + +// @Summary List notes +// @Description 分页查询便签列表,按 id 倒序返回。page 从 1 开始;page_size 取值 1-100,默认 20。 +// @Tags notes +// @Produce json +// @Param page query int false "页码,默认 1" example(1) +// @Param page_size query int false "每页数量,默认 20,最大 100" example(20) +// @Success 200 {object} api.NoteListResponse +// @Failure 500 {object} api.ErrorResponse +// @Router /notes [get] func listNotes(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { page, pageSize := parsePagination(c) @@ -43,20 +61,30 @@ func listNotes(db *gorm.DB) gin.HandlerFunc { return } - c.JSON(http.StatusOK, gin.H{ - "items": notes, - "total": total, - "page": page, - "page_size": pageSize, + c.JSON(http.StatusOK, NoteListResponse{ + Items: notes, + Total: total, + Page: page, + PageSize: pageSize, }) } } +// @Summary Create a note +// @Description 创建便签。title 必填且最长 200 字符,content 可选。 +// @Tags notes +// @Accept json +// @Produce json +// @Param note body api.NoteRequest true "便签内容" +// @Success 201 {object} model.Note +// @Failure 400 {object} api.ErrorResponse "参数无效" +// @Failure 500 {object} api.ErrorResponse +// @Router /notes [post] func createNote(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { - var req noteRequest + var req NoteRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效: " + err.Error()}) + c.JSON(http.StatusBadRequest, ErrorResponse{Error: "参数无效: " + err.Error()}) return } @@ -69,6 +97,16 @@ func createNote(db *gorm.DB) gin.HandlerFunc { } } +// @Summary Get a note +// @Description 按 id 查询单个便签。 +// @Tags notes +// @Produce json +// @Param id path int true "便签 ID" example(1) +// @Success 200 {object} model.Note +// @Failure 400 {object} api.ErrorResponse "id 无效" +// @Failure 404 {object} api.ErrorResponse "记录不存在" +// @Failure 500 {object} api.ErrorResponse +// @Router /notes/{id} [get] func getNote(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { id, ok := parseID(c) @@ -79,7 +117,7 @@ func getNote(db *gorm.DB) gin.HandlerFunc { var note model.Note if err := db.WithContext(c.Request.Context()).First(¬e, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - c.JSON(http.StatusNotFound, gin.H{"error": "记录不存在"}) + c.JSON(http.StatusNotFound, ErrorResponse{Error: "记录不存在"}) return } respondDBError(c, err) @@ -89,6 +127,18 @@ func getNote(db *gorm.DB) gin.HandlerFunc { } } +// @Summary Update a note +// @Description 全量更新便签的 title 与 content,字段校验规则同创建。 +// @Tags notes +// @Accept json +// @Produce json +// @Param id path int true "便签 ID" example(1) +// @Param note body api.NoteRequest true "便签内容" +// @Success 200 {object} model.Note +// @Failure 400 {object} api.ErrorResponse "参数无效或 id 无效" +// @Failure 404 {object} api.ErrorResponse "记录不存在" +// @Failure 500 {object} api.ErrorResponse +// @Router /notes/{id} [put] func updateNote(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { id, ok := parseID(c) @@ -96,9 +146,9 @@ func updateNote(db *gorm.DB) gin.HandlerFunc { return } - var req noteRequest + var req NoteRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效: " + err.Error()}) + c.JSON(http.StatusBadRequest, ErrorResponse{Error: "参数无效: " + err.Error()}) return } @@ -106,7 +156,7 @@ func updateNote(db *gorm.DB) gin.HandlerFunc { var note model.Note if err := db.WithContext(ctx).First(¬e, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - c.JSON(http.StatusNotFound, gin.H{"error": "记录不存在"}) + c.JSON(http.StatusNotFound, ErrorResponse{Error: "记录不存在"}) return } respondDBError(c, err) @@ -123,6 +173,16 @@ func updateNote(db *gorm.DB) gin.HandlerFunc { } } +// @Summary Delete a note +// @Description 按 id 删除便签,成功时返回 204 且无响应体。 +// @Tags notes +// @Produce json +// @Param id path int true "便签 ID" example(1) +// @Success 204 "删除成功" +// @Failure 400 {object} api.ErrorResponse "id 无效" +// @Failure 404 {object} api.ErrorResponse "记录不存在" +// @Failure 500 {object} api.ErrorResponse +// @Router /notes/{id} [delete] func deleteNote(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { id, ok := parseID(c) @@ -136,7 +196,7 @@ func deleteNote(db *gorm.DB) gin.HandlerFunc { return } if result.RowsAffected == 0 { - c.JSON(http.StatusNotFound, gin.H{"error": "记录不存在"}) + c.JSON(http.StatusNotFound, ErrorResponse{Error: "记录不存在"}) return } c.Status(http.StatusNoContent) @@ -146,7 +206,7 @@ func deleteNote(db *gorm.DB) gin.HandlerFunc { func parseID(c *gin.Context) (uint, bool) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil || id == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "id 无效"}) + c.JSON(http.StatusBadRequest, ErrorResponse{Error: "id 无效"}) return 0, false } return uint(id), true @@ -154,5 +214,5 @@ func parseID(c *gin.Context) (uint, bool) { func respondDBError(c *gin.Context, err error) { slog.ErrorContext(c.Request.Context(), "数据库操作失败", "err", err, "path", c.Request.URL.Path) - c.JSON(http.StatusInternalServerError, gin.H{"error": "服务器内部错误"}) + c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "服务器内部错误"}) } diff --git a/internal/api/user_groups.go b/internal/api/user_groups.go index abc2c34..e2ea0fa 100644 --- a/internal/api/user_groups.go +++ b/internal/api/user_groups.go @@ -11,11 +11,29 @@ import ( "rill/internal/model" ) -type userGroupRequest struct { - Name string `json:"name" binding:"required,max=50"` - Description string `json:"description" binding:"max=255"` +// UserGroupRequest 创建/更新用户组请求。 +type UserGroupRequest struct { + Name string `json:"name" binding:"required,max=50" example:"运营组"` + Description string `json:"description" binding:"max=255" example:"负责日常运营"` } +// UserGroupListResponse 用户组分页列表响应。 +type UserGroupListResponse struct { + Items []model.UserGroup `json:"items"` + Total int64 `json:"total" example:"42"` + Page int `json:"page" example:"1"` + PageSize int `json:"page_size" example:"20"` +} + +// @Summary List user groups +// @Description 分页查询用户组列表,按 id 升序返回。page 从 1 开始;page_size 取值 1-100,默认 20。 +// @Tags user-groups +// @Produce json +// @Param page query int false "页码,默认 1" example(1) +// @Param page_size query int false "每页数量,默认 20,最大 100" example(20) +// @Success 200 {object} api.UserGroupListResponse +// @Failure 500 {object} api.ErrorResponse +// @Router /user-groups [get] func listUserGroups(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { page, pageSize := parsePagination(c) @@ -37,20 +55,31 @@ func listUserGroups(db *gorm.DB) gin.HandlerFunc { return } - c.JSON(http.StatusOK, gin.H{ - "items": groups, - "total": total, - "page": page, - "page_size": pageSize, + c.JSON(http.StatusOK, UserGroupListResponse{ + Items: groups, + Total: total, + Page: page, + PageSize: pageSize, }) } } +// @Summary Create a user group +// @Description 创建用户组。name 必填且唯一(最长 50 字符),description 可选(最长 255 字符);id 由服务端分配。 +// @Tags user-groups +// @Accept json +// @Produce json +// @Param group body api.UserGroupRequest true "用户组信息" +// @Success 201 {object} model.UserGroup +// @Failure 400 {object} api.ErrorResponse "参数无效" +// @Failure 409 {object} api.ErrorResponse "用户组名称已存在" +// @Failure 500 {object} api.ErrorResponse +// @Router /user-groups [post] func createUserGroup(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { - var req userGroupRequest + var req UserGroupRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效: " + err.Error()}) + c.JSON(http.StatusBadRequest, ErrorResponse{Error: "参数无效: " + err.Error()}) return } @@ -76,6 +105,16 @@ func createUserGroup(db *gorm.DB) gin.HandlerFunc { } } +// @Summary Get a user group +// @Description 按 id 查询单个用户组,id 0 为内置 admin 组。 +// @Tags user-groups +// @Produce json +// @Param id path int true "用户组 ID" example(1) +// @Success 200 {object} model.UserGroup +// @Failure 400 {object} api.ErrorResponse "id 无效" +// @Failure 404 {object} api.ErrorResponse "记录不存在" +// @Failure 500 {object} api.ErrorResponse +// @Router /user-groups/{id} [get] func getUserGroup(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { id, ok := parseGroupID(c) @@ -92,6 +131,19 @@ func getUserGroup(db *gorm.DB) gin.HandlerFunc { } } +// @Summary Update a user group +// @Description 更新用户组的 name 与 description,name 必填且唯一。 +// @Tags user-groups +// @Accept json +// @Produce json +// @Param id path int true "用户组 ID" example(1) +// @Param group body api.UserGroupRequest true "用户组信息" +// @Success 200 {object} model.UserGroup +// @Failure 400 {object} api.ErrorResponse "参数无效或 id 无效" +// @Failure 404 {object} api.ErrorResponse "记录不存在" +// @Failure 409 {object} api.ErrorResponse "用户组名称已存在" +// @Failure 500 {object} api.ErrorResponse +// @Router /user-groups/{id} [put] func updateUserGroup(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { id, ok := parseGroupID(c) @@ -99,9 +151,9 @@ func updateUserGroup(db *gorm.DB) gin.HandlerFunc { return } - var req userGroupRequest + var req UserGroupRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效: " + err.Error()}) + c.JSON(http.StatusBadRequest, ErrorResponse{Error: "参数无效: " + err.Error()}) return } @@ -122,6 +174,17 @@ func updateUserGroup(db *gorm.DB) gin.HandlerFunc { } } +// @Summary Delete a user group +// @Description 按 id 删除用户组,成功时返回 204 且无响应体。系统内置组或组内仍有用户时返回 409。 +// @Tags user-groups +// @Produce json +// @Param id path int true "用户组 ID" example(2) +// @Success 204 "删除成功" +// @Failure 400 {object} api.ErrorResponse "id 无效" +// @Failure 404 {object} api.ErrorResponse "记录不存在" +// @Failure 409 {object} api.ErrorResponse "系统内置组不可删除或用户组内仍有用户" +// @Failure 500 {object} api.ErrorResponse +// @Router /user-groups/{id} [delete] func deleteUserGroup(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { id, ok := parseGroupID(c) @@ -136,7 +199,7 @@ func deleteUserGroup(db *gorm.DB) gin.HandlerFunc { return } if group.IsSystem { - c.JSON(http.StatusConflict, gin.H{"error": "系统内置组不可删除"}) + c.JSON(http.StatusConflict, ErrorResponse{Error: "系统内置组不可删除"}) return } @@ -148,7 +211,7 @@ func deleteUserGroup(db *gorm.DB) gin.HandlerFunc { return } if members > 0 { - c.JSON(http.StatusConflict, gin.H{"error": "用户组内仍有用户,无法删除"}) + c.JSON(http.StatusConflict, ErrorResponse{Error: "用户组内仍有用户,无法删除"}) return } @@ -164,7 +227,7 @@ func deleteUserGroup(db *gorm.DB) gin.HandlerFunc { func parseGroupID(c *gin.Context) (uint, bool) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "id 无效"}) + c.JSON(http.StatusBadRequest, ErrorResponse{Error: "id 无效"}) return 0, false } return uint(id), true diff --git a/internal/api/users.go b/internal/api/users.go index 21410bb..dddc6af 100644 --- a/internal/api/users.go +++ b/internal/api/users.go @@ -15,24 +15,43 @@ import ( const userGroupMembersTable = "user_group_members" -type userCreateRequest struct { - Username string `json:"username" binding:"required,max=50"` - Email string `json:"email" binding:"required,email,max=255"` - Password string `json:"password" binding:"required,min=6,max=72"` - Nickname string `json:"nickname" binding:"max=50"` - Avatar string `json:"avatar" binding:"max=255"` - Status *int8 `json:"status" binding:"omitempty,oneof=0 1"` - GroupIDs []uint `json:"group_ids"` +// UserCreateRequest 创建用户请求。 +type UserCreateRequest struct { + Username string `json:"username" binding:"required,max=50" example:"alice"` + Email string `json:"email" binding:"required,email,max=255" example:"alice@example.com"` + Password string `json:"password" binding:"required,min=6,max=72" example:"secret123"` + Nickname string `json:"nickname" binding:"max=50" example:"Alice"` + Avatar string `json:"avatar" binding:"max=255" example:"https://example.com/avatar.png"` + Status *int8 `json:"status" binding:"omitempty,oneof=0 1" example:"1"` + GroupIDs []uint `json:"group_ids" example:"1"` } -type userUpdateRequest struct { - Nickname string `json:"nickname" binding:"max=50"` - Avatar string `json:"avatar" binding:"max=255"` - Status *int8 `json:"status" binding:"omitempty,oneof=0 1"` - Password string `json:"password" binding:"omitempty,min=6,max=72"` - GroupIDs *[]uint `json:"group_ids"` +// UserUpdateRequest 更新用户请求,仅更新请求中提供的字段。 +type UserUpdateRequest struct { + Nickname string `json:"nickname" binding:"max=50" example:"Alice"` + Avatar string `json:"avatar" binding:"max=255" example:"https://example.com/avatar.png"` + Status *int8 `json:"status" binding:"omitempty,oneof=0 1" example:"1"` + Password string `json:"password" binding:"omitempty,min=6,max=72" example:"secret123"` + GroupIDs *[]uint `json:"group_ids" example:"1"` } +// UserListResponse 用户分页列表响应。 +type UserListResponse struct { + Items []model.User `json:"items"` + Total int64 `json:"total" example:"42"` + Page int `json:"page" example:"1"` + PageSize int `json:"page_size" example:"20"` +} + +// @Summary List users +// @Description 分页查询用户列表(含所属用户组),按 id 倒序返回。page 从 1 开始;page_size 取值 1-100,默认 20。 +// @Tags users +// @Produce json +// @Param page query int false "页码,默认 1" example(1) +// @Param page_size query int false "每页数量,默认 20,最大 100" example(20) +// @Success 200 {object} api.UserListResponse +// @Failure 500 {object} api.ErrorResponse +// @Router /users [get] func listUsers(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { page, pageSize := parsePagination(c) @@ -58,20 +77,31 @@ func listUsers(db *gorm.DB) gin.HandlerFunc { return } - c.JSON(http.StatusOK, gin.H{ - "items": users, - "total": total, - "page": page, - "page_size": pageSize, + c.JSON(http.StatusOK, UserListResponse{ + Items: users, + Total: total, + Page: page, + PageSize: pageSize, }) } } +// @Summary Create a user +// @Description 创建用户并关联用户组。username、email 唯一,password 长度 6-72;不传 group_ids 时默认加入普通用户组(id 1)。 +// @Tags users +// @Accept json +// @Produce json +// @Param user body api.UserCreateRequest true "用户信息" +// @Success 201 {object} model.User +// @Failure 400 {object} api.ErrorResponse "参数无效或用户组不存在" +// @Failure 409 {object} api.ErrorResponse "用户名或邮箱已存在" +// @Failure 500 {object} api.ErrorResponse +// @Router /users [post] func createUser(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { - var req userCreateRequest + var req UserCreateRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效: " + err.Error()}) + c.JSON(http.StatusBadRequest, ErrorResponse{Error: "参数无效: " + err.Error()}) return } @@ -94,7 +124,7 @@ func createUser(db *gorm.DB) gin.HandlerFunc { groups, err := findGroups(ctx, db, groupIDs) if err != nil { if errors.Is(err, errGroupsNotFound) { - c.JSON(http.StatusBadRequest, gin.H{"error": errGroupsNotFound.Error()}) + c.JSON(http.StatusBadRequest, ErrorResponse{Error: errGroupsNotFound.Error()}) return } respondDBError(c, err) @@ -124,6 +154,16 @@ func createUser(db *gorm.DB) gin.HandlerFunc { } } +// @Summary Get a user +// @Description 按 id 查询用户(含所属用户组)。 +// @Tags users +// @Produce json +// @Param id path int true "用户 ID" example(1) +// @Success 200 {object} model.User +// @Failure 400 {object} api.ErrorResponse "id 无效" +// @Failure 404 {object} api.ErrorResponse "记录不存在" +// @Failure 500 {object} api.ErrorResponse +// @Router /users/{id} [get] func getUser(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { id, ok := parseID(c) @@ -148,6 +188,18 @@ func getUser(db *gorm.DB) gin.HandlerFunc { } } +// @Summary Update a user +// @Description 更新用户信息,仅更新请求中提供的字段。传 group_ids 会整体替换用户组;password 非空时重置密码。 +// @Tags users +// @Accept json +// @Produce json +// @Param id path int true "用户 ID" example(1) +// @Param user body api.UserUpdateRequest true "待更新字段" +// @Success 200 {object} model.User +// @Failure 400 {object} api.ErrorResponse "参数无效、id 无效或用户组不存在" +// @Failure 404 {object} api.ErrorResponse "记录不存在" +// @Failure 500 {object} api.ErrorResponse +// @Router /users/{id} [put] func updateUser(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { id, ok := parseID(c) @@ -155,9 +207,9 @@ func updateUser(db *gorm.DB) gin.HandlerFunc { return } - var req userUpdateRequest + var req UserUpdateRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效: " + err.Error()}) + c.JSON(http.StatusBadRequest, ErrorResponse{Error: "参数无效: " + err.Error()}) return } @@ -171,7 +223,7 @@ func updateUser(db *gorm.DB) gin.HandlerFunc { if req.GroupIDs != nil { if _, err := findGroups(ctx, db, *req.GroupIDs); err != nil { if errors.Is(err, errGroupsNotFound) { - c.JSON(http.StatusBadRequest, gin.H{"error": errGroupsNotFound.Error()}) + c.JSON(http.StatusBadRequest, ErrorResponse{Error: errGroupsNotFound.Error()}) return } respondDBError(c, err) @@ -225,6 +277,16 @@ func updateUser(db *gorm.DB) gin.HandlerFunc { } } +// @Summary Delete a user +// @Description 按 id 删除用户及其用户组成员关系,成功时返回 204 且无响应体。 +// @Tags users +// @Produce json +// @Param id path int true "用户 ID" example(1) +// @Success 204 "删除成功" +// @Failure 400 {object} api.ErrorResponse "id 无效" +// @Failure 404 {object} api.ErrorResponse "记录不存在" +// @Failure 500 {object} api.ErrorResponse +// @Router /users/{id} [delete] func deleteUser(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { id, ok := parseID(c) @@ -373,5 +435,5 @@ func hashPassword(password string) (string, error) { func respondHashError(c *gin.Context, err error) { slog.ErrorContext(c.Request.Context(), "生成密码哈希失败", "err", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "服务器内部错误"}) + c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "服务器内部错误"}) } diff --git a/main.go b/main.go index 3c3fa44..710f725 100644 --- a/main.go +++ b/main.go @@ -19,11 +19,19 @@ import ( "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" + "rill/docs" "rill/internal/api" "rill/internal/config" "rill/internal/database" ) +//go:generate go tool swag init -g main.go -o docs --parseInternal + +// @title Rill API +// @version 1.0 +// @description Rill 服务端 HTTP API 文档,所有接口以配置项 api.prefix(默认 /api)为前缀,请求与响应均为 JSON。 +// @description Swagger 页面:{prefix}/swagger/index.html;OpenAPI JSON:{prefix}/swagger/doc.json。 +// @BasePath /api func main() { configPath := flag.String("c", "data/config.yaml", "配置文件路径(不存在时自动生成)") flag.Parse() @@ -35,6 +43,9 @@ func main() { } slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: cfg.LogLevel()}))) + // 接口文档路径跟随 API 前缀 + docs.SwaggerInfo.BasePath = cfg.API.Prefix + //设置gin运行模式 gin.SetMode(cfg.Server.Mode)