From b683fb293ef555b9d5a9150f648a0701f3400512 Mon Sep 17 00:00:00 2001 From: kevin Date: Sun, 20 Sep 2026 02:03:06 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=B3=A8=E5=86=8C=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E4=B8=8E=E9=89=B4=E6=9D=83=EF=BC=8C=E5=B9=B6=E6=8C=89?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E6=8B=86=E5=88=86=20internal=20=E6=A8=A1?= =?UTF-8?q?=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 /api/auth/register、/api/auth/login,JWT 签发与 Bearer 鉴权中间件 - notes 需登录,users/user-groups 仅管理员;auth 配置项随版本 1→2 自动补全 - internal/api 仅保留路由装配,拆分为 auth/user/usergroup/note/httpx/testutil - 同步更新 Swagger 文档与前端注册接口路径 --- docs/docs.go | 859 +++++++++++++----- docs/swagger.json | 859 +++++++++++++----- docs/swagger.yaml | 610 +++++++++---- frontend/src/api/users.ts | 2 +- go.mod | 1 + go.sum | 2 + internal/api/api.go | 96 +- internal/api/api_test.go | 26 + internal/api/notes_test.go | 182 ---- internal/auth/auth.go | 311 +++++++ internal/auth/auth_test.go | 248 +++++ internal/config/config.default.yaml | 7 +- internal/config/config.go | 18 + internal/config/config_test.go | 11 +- internal/config/upgrade.go | 2 +- internal/httpx/httpx.go | 80 ++ internal/{api/notes.go => note/note.go} | 133 ++- internal/note/note_test.go | 103 +++ internal/testutil/testutil.go | 129 +++ internal/{api/users.go => user/user.go} | 213 +++-- .../{api/users_test.go => user/user_test.go} | 49 +- .../user_group.go} | 116 +-- .../user_group_test.go} | 54 +- main.go | 7 +- 24 files changed, 3002 insertions(+), 1116 deletions(-) create mode 100644 internal/api/api_test.go delete mode 100644 internal/api/notes_test.go create mode 100644 internal/auth/auth.go create mode 100644 internal/auth/auth_test.go create mode 100644 internal/httpx/httpx.go rename internal/{api/notes.go => note/note.go} (53%) create mode 100644 internal/note/note_test.go create mode 100644 internal/testutil/testutil.go rename internal/{api/users.go => user/user.go} (60%) rename internal/{api/users_test.go => user/user_test.go} (78%) rename internal/{api/user_groups.go => usergroup/user_group.go} (55%) rename internal/{api/user_groups_test.go => usergroup/user_group_test.go} (70%) diff --git a/docs/docs.go b/docs/docs.go index c1f0012..e833928 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -15,6 +15,116 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/auth/login": { + "post": { + "description": "使用用户名或邮箱登录,成功返回 JWT(有效期见 auth.token_ttl)与用户信息。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Login", + "parameters": [ + { + "description": "登录凭证", + "name": "credentials", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth.LoginRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/auth.LoginResponse" + } + }, + "400": { + "description": "参数无效", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "401": { + "description": "账号或密码错误", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "账号已被禁用", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + } + } + } + }, + "/auth/register": { + "post": { + "description": "公开注册,创建普通用户并默认加入普通用户组(id 1)。username 长度 3-50 且唯一,email 唯一,password 长度 6-72。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Register", + "parameters": [ + { + "description": "注册信息", + "name": "user", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth.RegisterRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/model.User" + } + }, + "400": { + "description": "参数无效", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "409": { + "description": "用户名或邮箱已存在", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + } + } + } + }, "/health": { "get": { "description": "检查服务与数据库连接状态;数据库不可用时返回 503。", @@ -43,6 +153,11 @@ const docTemplate = `{ }, "/notes": { "get": { + "security": [ + { + "BearerAuth": [] + } + ], "description": "分页查询便签列表,按 id 倒序返回。page 从 1 开始;page_size 取值 1-100,默认 20。", "produces": [ "application/json" @@ -71,18 +186,35 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/api.NoteListResponse" + "$ref": "#/definitions/note.ListResponse" + } + }, + "401": { + "description": "未登录或登录已过期", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "账号已被禁用", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } } } }, "post": { + "security": [ + { + "BearerAuth": [] + } + ], "description": "创建便签。title 必填且最长 200 字符,content 可选。", "consumes": [ "application/json" @@ -101,7 +233,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/api.NoteRequest" + "$ref": "#/definitions/note.Request" } } ], @@ -115,13 +247,25 @@ const docTemplate = `{ "400": { "description": "参数无效", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "401": { + "description": "未登录或登录已过期", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "账号已被禁用", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } } } @@ -129,6 +273,11 @@ const docTemplate = `{ }, "/notes/{id}": { "get": { + "security": [ + { + "BearerAuth": [] + } + ], "description": "按 id 查询单个便签。", "produces": [ "application/json" @@ -157,24 +306,41 @@ const docTemplate = `{ "400": { "description": "id 无效", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "401": { + "description": "未登录或登录已过期", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "账号已被禁用", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" } }, "404": { "description": "记录不存在", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } } } }, "put": { + "security": [ + { + "BearerAuth": [] + } + ], "description": "全量更新便签的 title 与 content,字段校验规则同创建。", "consumes": [ "application/json" @@ -201,7 +367,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/api.NoteRequest" + "$ref": "#/definitions/note.Request" } } ], @@ -215,24 +381,41 @@ const docTemplate = `{ "400": { "description": "参数无效或 id 无效", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "401": { + "description": "未登录或登录已过期", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "账号已被禁用", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" } }, "404": { "description": "记录不存在", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } } } }, "delete": { + "security": [ + { + "BearerAuth": [] + } + ], "description": "按 id 删除便签,成功时返回 204 且无响应体。", "produces": [ "application/json" @@ -258,19 +441,31 @@ const docTemplate = `{ "400": { "description": "id 无效", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "401": { + "description": "未登录或登录已过期", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "账号已被禁用", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" } }, "404": { "description": "记录不存在", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } } } @@ -278,6 +473,11 @@ const docTemplate = `{ }, "/user-groups": { "get": { + "security": [ + { + "BearerAuth": [] + } + ], "description": "分页查询用户组列表,按 id 升序返回。page 从 1 开始;page_size 取值 1-100,默认 20。", "produces": [ "application/json" @@ -306,18 +506,35 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/api.UserGroupListResponse" + "$ref": "#/definitions/usergroup.ListResponse" + } + }, + "401": { + "description": "未登录或登录已过期", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "需要管理员权限或账号已被禁用", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } } } }, "post": { + "security": [ + { + "BearerAuth": [] + } + ], "description": "创建用户组。name 必填且唯一(最长 50 字符),description 可选(最长 255 字符);id 由服务端分配。", "consumes": [ "application/json" @@ -336,7 +553,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/api.UserGroupRequest" + "$ref": "#/definitions/usergroup.Request" } } ], @@ -350,19 +567,31 @@ const docTemplate = `{ "400": { "description": "参数无效", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "401": { + "description": "未登录或登录已过期", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "需要管理员权限或账号已被禁用", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" } }, "409": { "description": "用户组名称已存在", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } } } @@ -370,6 +599,11 @@ const docTemplate = `{ }, "/user-groups/{id}": { "get": { + "security": [ + { + "BearerAuth": [] + } + ], "description": "按 id 查询单个用户组,id 0 为内置 admin 组。", "produces": [ "application/json" @@ -398,24 +632,41 @@ const docTemplate = `{ "400": { "description": "id 无效", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "401": { + "description": "未登录或登录已过期", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "需要管理员权限或账号已被禁用", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" } }, "404": { "description": "记录不存在", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } } } }, "put": { + "security": [ + { + "BearerAuth": [] + } + ], "description": "更新用户组的 name 与 description,name 必填且唯一。", "consumes": [ "application/json" @@ -442,7 +693,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/api.UserGroupRequest" + "$ref": "#/definitions/usergroup.Request" } } ], @@ -456,30 +707,47 @@ const docTemplate = `{ "400": { "description": "参数无效或 id 无效", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "401": { + "description": "未登录或登录已过期", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "需要管理员权限或账号已被禁用", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" } }, "404": { "description": "记录不存在", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } }, "409": { "description": "用户组名称已存在", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } } } }, "delete": { + "security": [ + { + "BearerAuth": [] + } + ], "description": "按 id 删除用户组,成功时返回 204 且无响应体。系统内置组或组内仍有用户时返回 409。", "produces": [ "application/json" @@ -505,25 +773,37 @@ const docTemplate = `{ "400": { "description": "id 无效", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "401": { + "description": "未登录或登录已过期", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "需要管理员权限或账号已被禁用", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" } }, "404": { "description": "记录不存在", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } }, "409": { "description": "系统内置组不可删除或用户组内仍有用户", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } } } @@ -531,6 +811,11 @@ const docTemplate = `{ }, "/users": { "get": { + "security": [ + { + "BearerAuth": [] + } + ], "description": "分页查询用户列表(含所属用户组),按 id 倒序返回。page 从 1 开始;page_size 取值 1-100,默认 20。", "produces": [ "application/json" @@ -559,18 +844,35 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/api.UserListResponse" + "$ref": "#/definitions/user.ListResponse" + } + }, + "401": { + "description": "未登录或登录已过期", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "需要管理员权限或账号已被禁用", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } } } }, "post": { + "security": [ + { + "BearerAuth": [] + } + ], "description": "创建用户并关联用户组。username、email 唯一,password 长度 6-72;不传 group_ids 时默认加入普通用户组(id 1)。", "consumes": [ "application/json" @@ -589,7 +891,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/api.UserCreateRequest" + "$ref": "#/definitions/user.CreateRequest" } } ], @@ -603,19 +905,31 @@ const docTemplate = `{ "400": { "description": "参数无效或用户组不存在", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "401": { + "description": "未登录或登录已过期", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "需要管理员权限或账号已被禁用", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" } }, "409": { "description": "用户名或邮箱已存在", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } } } @@ -623,6 +937,11 @@ const docTemplate = `{ }, "/users/{id}": { "get": { + "security": [ + { + "BearerAuth": [] + } + ], "description": "按 id 查询用户(含所属用户组)。", "produces": [ "application/json" @@ -651,24 +970,41 @@ const docTemplate = `{ "400": { "description": "id 无效", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "401": { + "description": "未登录或登录已过期", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "需要管理员权限或账号已被禁用", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" } }, "404": { "description": "记录不存在", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } } } }, "put": { + "security": [ + { + "BearerAuth": [] + } + ], "description": "更新用户信息,仅更新请求中提供的字段。传 group_ids 会整体替换用户组;password 非空时重置密码。", "consumes": [ "application/json" @@ -695,7 +1031,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/api.UserUpdateRequest" + "$ref": "#/definitions/user.UpdateRequest" } } ], @@ -709,24 +1045,41 @@ const docTemplate = `{ "400": { "description": "参数无效、id 无效或用户组不存在", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "401": { + "description": "未登录或登录已过期", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "需要管理员权限或账号已被禁用", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" } }, "404": { "description": "记录不存在", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } } } }, "delete": { + "security": [ + { + "BearerAuth": [] + } + ], "description": "按 id 删除用户及其用户组成员关系,成功时返回 204 且无响应体。", "produces": [ "application/json" @@ -752,19 +1105,31 @@ const docTemplate = `{ "400": { "description": "id 无效", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "401": { + "description": "未登录或登录已过期", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "需要管理员权限或账号已被禁用", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" } }, "404": { "description": "记录不存在", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } } } @@ -772,15 +1137,6 @@ const docTemplate = `{ } }, "definitions": { - "api.ErrorResponse": { - "type": "object", - "properties": { - "error": { - "type": "string", - "example": "记录不存在" - } - } - }, "api.HealthResponse": { "type": "object", "properties": { @@ -794,47 +1150,40 @@ const docTemplate = `{ } } }, - "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": { + "auth.LoginRequest": { "type": "object", "required": [ - "title" + "account", + "password" ], "properties": { - "content": { + "account": { "type": "string", - "example": "牛奶、鸡蛋" + "example": "alice" }, - "title": { + "password": { "type": "string", - "maxLength": 200, - "example": "购物清单" + "example": "secret123" } } }, - "api.UserCreateRequest": { + "auth.LoginResponse": { + "type": "object", + "properties": { + "expires_at": { + "type": "string", + "example": "2026-09-21T10:00:00+08:00" + }, + "token": { + "type": "string", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + }, + "user": { + "$ref": "#/definitions/model.User" + } + } + }, + "auth.RegisterRequest": { "type": "object", "required": [ "email", @@ -842,150 +1191,31 @@ const docTemplate = `{ "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, + "minLength": 3, "example": "alice" } } }, - "api.UserGroupListResponse": { + "httpx.ErrorResponse": { "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": { + "error": { "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 + "example": "记录不存在" } } }, @@ -1066,6 +1296,209 @@ const docTemplate = `{ "type": "string" } } + }, + "note.ListResponse": { + "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 + } + } + }, + "note.Request": { + "type": "object", + "required": [ + "title" + ], + "properties": { + "content": { + "type": "string", + "example": "牛奶、鸡蛋" + }, + "title": { + "type": "string", + "maxLength": 200, + "example": "购物清单" + } + } + }, + "user.CreateRequest": { + "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" + } + } + }, + "user.ListResponse": { + "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 + } + } + }, + "user.UpdateRequest": { + "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 + } + } + }, + "usergroup.ListResponse": { + "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 + } + } + }, + "usergroup.Request": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "description": { + "type": "string", + "maxLength": 255, + "example": "负责日常运营" + }, + "name": { + "type": "string", + "maxLength": 50, + "example": "运营组" + } + } + } + }, + "securityDefinitions": { + "BearerAuth": { + "description": "Bearer JWT,格式:Bearer {token},通过 /auth/login 获取", + "type": "apiKey", + "name": "Authorization", + "in": "header" } } }` @@ -1077,7 +1510,7 @@ var SwaggerInfo = &swag.Spec{ 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。", + Description: "Rill 服务端 HTTP API 文档,所有接口以配置项 api.prefix(默认 /api)为前缀,请求与响应均为 JSON。\nSwagger 页面:{prefix}/swagger/index.html;OpenAPI JSON:{prefix}/swagger/doc.json。\n除 health、swagger、auth 外,其余接口需要 Bearer JWT:先调用 /auth/login 获取 token,再在请求头携带 Authorization: Bearer {token}。", InfoInstanceName: "swagger", SwaggerTemplate: docTemplate, LeftDelim: "{{", diff --git a/docs/swagger.json b/docs/swagger.json index 4bc2074..43907cb 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1,13 +1,123 @@ { "swagger": "2.0", "info": { - "description": "Rill 服务端 HTTP API 文档,所有接口以配置项 api.prefix(默认 /api)为前缀,请求与响应均为 JSON。\nSwagger 页面:{prefix}/swagger/index.html;OpenAPI JSON:{prefix}/swagger/doc.json。", + "description": "Rill 服务端 HTTP API 文档,所有接口以配置项 api.prefix(默认 /api)为前缀,请求与响应均为 JSON。\nSwagger 页面:{prefix}/swagger/index.html;OpenAPI JSON:{prefix}/swagger/doc.json。\n除 health、swagger、auth 外,其余接口需要 Bearer JWT:先调用 /auth/login 获取 token,再在请求头携带 Authorization: Bearer {token}。", "title": "Rill API", "contact": {}, "version": "1.0" }, "basePath": "/api", "paths": { + "/auth/login": { + "post": { + "description": "使用用户名或邮箱登录,成功返回 JWT(有效期见 auth.token_ttl)与用户信息。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Login", + "parameters": [ + { + "description": "登录凭证", + "name": "credentials", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth.LoginRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/auth.LoginResponse" + } + }, + "400": { + "description": "参数无效", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "401": { + "description": "账号或密码错误", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "账号已被禁用", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + } + } + } + }, + "/auth/register": { + "post": { + "description": "公开注册,创建普通用户并默认加入普通用户组(id 1)。username 长度 3-50 且唯一,email 唯一,password 长度 6-72。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Register", + "parameters": [ + { + "description": "注册信息", + "name": "user", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth.RegisterRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/model.User" + } + }, + "400": { + "description": "参数无效", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "409": { + "description": "用户名或邮箱已存在", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + } + } + } + }, "/health": { "get": { "description": "检查服务与数据库连接状态;数据库不可用时返回 503。", @@ -36,6 +146,11 @@ }, "/notes": { "get": { + "security": [ + { + "BearerAuth": [] + } + ], "description": "分页查询便签列表,按 id 倒序返回。page 从 1 开始;page_size 取值 1-100,默认 20。", "produces": [ "application/json" @@ -64,18 +179,35 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/api.NoteListResponse" + "$ref": "#/definitions/note.ListResponse" + } + }, + "401": { + "description": "未登录或登录已过期", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "账号已被禁用", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } } } }, "post": { + "security": [ + { + "BearerAuth": [] + } + ], "description": "创建便签。title 必填且最长 200 字符,content 可选。", "consumes": [ "application/json" @@ -94,7 +226,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/api.NoteRequest" + "$ref": "#/definitions/note.Request" } } ], @@ -108,13 +240,25 @@ "400": { "description": "参数无效", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "401": { + "description": "未登录或登录已过期", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "账号已被禁用", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } } } @@ -122,6 +266,11 @@ }, "/notes/{id}": { "get": { + "security": [ + { + "BearerAuth": [] + } + ], "description": "按 id 查询单个便签。", "produces": [ "application/json" @@ -150,24 +299,41 @@ "400": { "description": "id 无效", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "401": { + "description": "未登录或登录已过期", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "账号已被禁用", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" } }, "404": { "description": "记录不存在", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } } } }, "put": { + "security": [ + { + "BearerAuth": [] + } + ], "description": "全量更新便签的 title 与 content,字段校验规则同创建。", "consumes": [ "application/json" @@ -194,7 +360,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/api.NoteRequest" + "$ref": "#/definitions/note.Request" } } ], @@ -208,24 +374,41 @@ "400": { "description": "参数无效或 id 无效", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "401": { + "description": "未登录或登录已过期", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "账号已被禁用", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" } }, "404": { "description": "记录不存在", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } } } }, "delete": { + "security": [ + { + "BearerAuth": [] + } + ], "description": "按 id 删除便签,成功时返回 204 且无响应体。", "produces": [ "application/json" @@ -251,19 +434,31 @@ "400": { "description": "id 无效", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "401": { + "description": "未登录或登录已过期", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "账号已被禁用", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" } }, "404": { "description": "记录不存在", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } } } @@ -271,6 +466,11 @@ }, "/user-groups": { "get": { + "security": [ + { + "BearerAuth": [] + } + ], "description": "分页查询用户组列表,按 id 升序返回。page 从 1 开始;page_size 取值 1-100,默认 20。", "produces": [ "application/json" @@ -299,18 +499,35 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/api.UserGroupListResponse" + "$ref": "#/definitions/usergroup.ListResponse" + } + }, + "401": { + "description": "未登录或登录已过期", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "需要管理员权限或账号已被禁用", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } } } }, "post": { + "security": [ + { + "BearerAuth": [] + } + ], "description": "创建用户组。name 必填且唯一(最长 50 字符),description 可选(最长 255 字符);id 由服务端分配。", "consumes": [ "application/json" @@ -329,7 +546,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/api.UserGroupRequest" + "$ref": "#/definitions/usergroup.Request" } } ], @@ -343,19 +560,31 @@ "400": { "description": "参数无效", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "401": { + "description": "未登录或登录已过期", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "需要管理员权限或账号已被禁用", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" } }, "409": { "description": "用户组名称已存在", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } } } @@ -363,6 +592,11 @@ }, "/user-groups/{id}": { "get": { + "security": [ + { + "BearerAuth": [] + } + ], "description": "按 id 查询单个用户组,id 0 为内置 admin 组。", "produces": [ "application/json" @@ -391,24 +625,41 @@ "400": { "description": "id 无效", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "401": { + "description": "未登录或登录已过期", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "需要管理员权限或账号已被禁用", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" } }, "404": { "description": "记录不存在", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } } } }, "put": { + "security": [ + { + "BearerAuth": [] + } + ], "description": "更新用户组的 name 与 description,name 必填且唯一。", "consumes": [ "application/json" @@ -435,7 +686,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/api.UserGroupRequest" + "$ref": "#/definitions/usergroup.Request" } } ], @@ -449,30 +700,47 @@ "400": { "description": "参数无效或 id 无效", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "401": { + "description": "未登录或登录已过期", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "需要管理员权限或账号已被禁用", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" } }, "404": { "description": "记录不存在", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } }, "409": { "description": "用户组名称已存在", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } } } }, "delete": { + "security": [ + { + "BearerAuth": [] + } + ], "description": "按 id 删除用户组,成功时返回 204 且无响应体。系统内置组或组内仍有用户时返回 409。", "produces": [ "application/json" @@ -498,25 +766,37 @@ "400": { "description": "id 无效", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "401": { + "description": "未登录或登录已过期", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "需要管理员权限或账号已被禁用", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" } }, "404": { "description": "记录不存在", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } }, "409": { "description": "系统内置组不可删除或用户组内仍有用户", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } } } @@ -524,6 +804,11 @@ }, "/users": { "get": { + "security": [ + { + "BearerAuth": [] + } + ], "description": "分页查询用户列表(含所属用户组),按 id 倒序返回。page 从 1 开始;page_size 取值 1-100,默认 20。", "produces": [ "application/json" @@ -552,18 +837,35 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/api.UserListResponse" + "$ref": "#/definitions/user.ListResponse" + } + }, + "401": { + "description": "未登录或登录已过期", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "需要管理员权限或账号已被禁用", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } } } }, "post": { + "security": [ + { + "BearerAuth": [] + } + ], "description": "创建用户并关联用户组。username、email 唯一,password 长度 6-72;不传 group_ids 时默认加入普通用户组(id 1)。", "consumes": [ "application/json" @@ -582,7 +884,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/api.UserCreateRequest" + "$ref": "#/definitions/user.CreateRequest" } } ], @@ -596,19 +898,31 @@ "400": { "description": "参数无效或用户组不存在", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "401": { + "description": "未登录或登录已过期", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "需要管理员权限或账号已被禁用", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" } }, "409": { "description": "用户名或邮箱已存在", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } } } @@ -616,6 +930,11 @@ }, "/users/{id}": { "get": { + "security": [ + { + "BearerAuth": [] + } + ], "description": "按 id 查询用户(含所属用户组)。", "produces": [ "application/json" @@ -644,24 +963,41 @@ "400": { "description": "id 无效", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "401": { + "description": "未登录或登录已过期", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "需要管理员权限或账号已被禁用", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" } }, "404": { "description": "记录不存在", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } } } }, "put": { + "security": [ + { + "BearerAuth": [] + } + ], "description": "更新用户信息,仅更新请求中提供的字段。传 group_ids 会整体替换用户组;password 非空时重置密码。", "consumes": [ "application/json" @@ -688,7 +1024,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/api.UserUpdateRequest" + "$ref": "#/definitions/user.UpdateRequest" } } ], @@ -702,24 +1038,41 @@ "400": { "description": "参数无效、id 无效或用户组不存在", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "401": { + "description": "未登录或登录已过期", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "需要管理员权限或账号已被禁用", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" } }, "404": { "description": "记录不存在", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } } } }, "delete": { + "security": [ + { + "BearerAuth": [] + } + ], "description": "按 id 删除用户及其用户组成员关系,成功时返回 204 且无响应体。", "produces": [ "application/json" @@ -745,19 +1098,31 @@ "400": { "description": "id 无效", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "401": { + "description": "未登录或登录已过期", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "需要管理员权限或账号已被禁用", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" } }, "404": { "description": "记录不存在", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } }, "500": { "description": "Internal Server Error", "schema": { - "$ref": "#/definitions/api.ErrorResponse" + "$ref": "#/definitions/httpx.ErrorResponse" } } } @@ -765,15 +1130,6 @@ } }, "definitions": { - "api.ErrorResponse": { - "type": "object", - "properties": { - "error": { - "type": "string", - "example": "记录不存在" - } - } - }, "api.HealthResponse": { "type": "object", "properties": { @@ -787,47 +1143,40 @@ } } }, - "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": { + "auth.LoginRequest": { "type": "object", "required": [ - "title" + "account", + "password" ], "properties": { - "content": { + "account": { "type": "string", - "example": "牛奶、鸡蛋" + "example": "alice" }, - "title": { + "password": { "type": "string", - "maxLength": 200, - "example": "购物清单" + "example": "secret123" } } }, - "api.UserCreateRequest": { + "auth.LoginResponse": { + "type": "object", + "properties": { + "expires_at": { + "type": "string", + "example": "2026-09-21T10:00:00+08:00" + }, + "token": { + "type": "string", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + }, + "user": { + "$ref": "#/definitions/model.User" + } + } + }, + "auth.RegisterRequest": { "type": "object", "required": [ "email", @@ -835,150 +1184,31 @@ "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, + "minLength": 3, "example": "alice" } } }, - "api.UserGroupListResponse": { + "httpx.ErrorResponse": { "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": { + "error": { "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 + "example": "记录不存在" } } }, @@ -1059,6 +1289,209 @@ "type": "string" } } + }, + "note.ListResponse": { + "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 + } + } + }, + "note.Request": { + "type": "object", + "required": [ + "title" + ], + "properties": { + "content": { + "type": "string", + "example": "牛奶、鸡蛋" + }, + "title": { + "type": "string", + "maxLength": 200, + "example": "购物清单" + } + } + }, + "user.CreateRequest": { + "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" + } + } + }, + "user.ListResponse": { + "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 + } + } + }, + "user.UpdateRequest": { + "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 + } + } + }, + "usergroup.ListResponse": { + "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 + } + } + }, + "usergroup.Request": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "description": { + "type": "string", + "maxLength": 255, + "example": "负责日常运营" + }, + "name": { + "type": "string", + "maxLength": 50, + "example": "运营组" + } + } + } + }, + "securityDefinitions": { + "BearerAuth": { + "description": "Bearer JWT,格式:Bearer {token},通过 /auth/login 获取", + "type": "apiKey", + "name": "Authorization", + "in": "header" } } } \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml index a3c151a..1a9d535 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,11 +1,5 @@ basePath: /api definitions: - api.ErrorResponse: - properties: - error: - example: 记录不存在 - type: string - type: object api.HealthResponse: properties: error: @@ -15,146 +9,55 @@ definitions: example: ok type: string type: object - api.NoteListResponse: + auth.LoginRequest: 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: 牛奶、鸡蛋 + account: + example: alice type: string - title: - example: 购物清单 - maxLength: 200 + password: + example: secret123 type: string required: - - title + - account + - password type: object - api.UserCreateRequest: + auth.LoginResponse: properties: - avatar: - example: https://example.com/avatar.png - maxLength: 255 + expires_at: + example: "2026-09-21T10:00:00+08:00" type: string + token: + example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... + type: string + user: + $ref: '#/definitions/model.User' + type: object + auth.RegisterRequest: + properties: 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 + minLength: 3 type: string required: - email - password - username type: object - api.UserGroupListResponse: + httpx.ErrorResponse: 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 + error: + example: 记录不存在 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: @@ -207,14 +110,229 @@ definitions: updated_at: type: string type: object + note.ListResponse: + 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 + note.Request: + properties: + content: + example: 牛奶、鸡蛋 + type: string + title: + example: 购物清单 + maxLength: 200 + type: string + required: + - title + type: object + user.CreateRequest: + 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 + user.ListResponse: + 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 + user.UpdateRequest: + 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 + usergroup.ListResponse: + 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 + usergroup.Request: + properties: + description: + example: 负责日常运营 + maxLength: 255 + type: string + name: + example: 运营组 + maxLength: 50 + type: string + required: + - name + type: object info: contact: {} description: |- Rill 服务端 HTTP API 文档,所有接口以配置项 api.prefix(默认 /api)为前缀,请求与响应均为 JSON。 Swagger 页面:{prefix}/swagger/index.html;OpenAPI JSON:{prefix}/swagger/doc.json。 + 除 health、swagger、auth 外,其余接口需要 Bearer JWT:先调用 /auth/login 获取 token,再在请求头携带 Authorization: Bearer {token}。 title: Rill API version: "1.0" paths: + /auth/login: + post: + consumes: + - application/json + description: 使用用户名或邮箱登录,成功返回 JWT(有效期见 auth.token_ttl)与用户信息。 + parameters: + - description: 登录凭证 + in: body + name: credentials + required: true + schema: + $ref: '#/definitions/auth.LoginRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/auth.LoginResponse' + "400": + description: 参数无效 + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "401": + description: 账号或密码错误 + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "403": + description: 账号已被禁用 + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httpx.ErrorResponse' + summary: Login + tags: + - auth + /auth/register: + post: + consumes: + - application/json + description: 公开注册,创建普通用户并默认加入普通用户组(id 1)。username 长度 3-50 且唯一,email 唯一,password + 长度 6-72。 + parameters: + - description: 注册信息 + in: body + name: user + required: true + schema: + $ref: '#/definitions/auth.RegisterRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/model.User' + "400": + description: 参数无效 + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "409": + description: 用户名或邮箱已存在 + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httpx.ErrorResponse' + summary: Register + tags: + - auth /health: get: description: 检查服务与数据库连接状态;数据库不可用时返回 503。 @@ -252,11 +370,21 @@ paths: "200": description: OK schema: - $ref: '#/definitions/api.NoteListResponse' + $ref: '#/definitions/note.ListResponse' + "401": + description: 未登录或登录已过期 + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "403": + description: 账号已被禁用 + schema: + $ref: '#/definitions/httpx.ErrorResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/api.ErrorResponse' + $ref: '#/definitions/httpx.ErrorResponse' + security: + - BearerAuth: [] summary: List notes tags: - notes @@ -270,7 +398,7 @@ paths: name: note required: true schema: - $ref: '#/definitions/api.NoteRequest' + $ref: '#/definitions/note.Request' produces: - application/json responses: @@ -281,11 +409,21 @@ paths: "400": description: 参数无效 schema: - $ref: '#/definitions/api.ErrorResponse' + $ref: '#/definitions/httpx.ErrorResponse' + "401": + description: 未登录或登录已过期 + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "403": + description: 账号已被禁用 + schema: + $ref: '#/definitions/httpx.ErrorResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/api.ErrorResponse' + $ref: '#/definitions/httpx.ErrorResponse' + security: + - BearerAuth: [] summary: Create a note tags: - notes @@ -307,15 +445,25 @@ paths: "400": description: id 无效 schema: - $ref: '#/definitions/api.ErrorResponse' + $ref: '#/definitions/httpx.ErrorResponse' + "401": + description: 未登录或登录已过期 + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "403": + description: 账号已被禁用 + schema: + $ref: '#/definitions/httpx.ErrorResponse' "404": description: 记录不存在 schema: - $ref: '#/definitions/api.ErrorResponse' + $ref: '#/definitions/httpx.ErrorResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/api.ErrorResponse' + $ref: '#/definitions/httpx.ErrorResponse' + security: + - BearerAuth: [] summary: Delete a note tags: - notes @@ -338,15 +486,25 @@ paths: "400": description: id 无效 schema: - $ref: '#/definitions/api.ErrorResponse' + $ref: '#/definitions/httpx.ErrorResponse' + "401": + description: 未登录或登录已过期 + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "403": + description: 账号已被禁用 + schema: + $ref: '#/definitions/httpx.ErrorResponse' "404": description: 记录不存在 schema: - $ref: '#/definitions/api.ErrorResponse' + $ref: '#/definitions/httpx.ErrorResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/api.ErrorResponse' + $ref: '#/definitions/httpx.ErrorResponse' + security: + - BearerAuth: [] summary: Get a note tags: - notes @@ -366,7 +524,7 @@ paths: name: note required: true schema: - $ref: '#/definitions/api.NoteRequest' + $ref: '#/definitions/note.Request' produces: - application/json responses: @@ -377,15 +535,25 @@ paths: "400": description: 参数无效或 id 无效 schema: - $ref: '#/definitions/api.ErrorResponse' + $ref: '#/definitions/httpx.ErrorResponse' + "401": + description: 未登录或登录已过期 + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "403": + description: 账号已被禁用 + schema: + $ref: '#/definitions/httpx.ErrorResponse' "404": description: 记录不存在 schema: - $ref: '#/definitions/api.ErrorResponse' + $ref: '#/definitions/httpx.ErrorResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/api.ErrorResponse' + $ref: '#/definitions/httpx.ErrorResponse' + security: + - BearerAuth: [] summary: Update a note tags: - notes @@ -409,11 +577,21 @@ paths: "200": description: OK schema: - $ref: '#/definitions/api.UserGroupListResponse' + $ref: '#/definitions/usergroup.ListResponse' + "401": + description: 未登录或登录已过期 + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "403": + description: 需要管理员权限或账号已被禁用 + schema: + $ref: '#/definitions/httpx.ErrorResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/api.ErrorResponse' + $ref: '#/definitions/httpx.ErrorResponse' + security: + - BearerAuth: [] summary: List user groups tags: - user-groups @@ -427,7 +605,7 @@ paths: name: group required: true schema: - $ref: '#/definitions/api.UserGroupRequest' + $ref: '#/definitions/usergroup.Request' produces: - application/json responses: @@ -438,15 +616,25 @@ paths: "400": description: 参数无效 schema: - $ref: '#/definitions/api.ErrorResponse' + $ref: '#/definitions/httpx.ErrorResponse' + "401": + description: 未登录或登录已过期 + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "403": + description: 需要管理员权限或账号已被禁用 + schema: + $ref: '#/definitions/httpx.ErrorResponse' "409": description: 用户组名称已存在 schema: - $ref: '#/definitions/api.ErrorResponse' + $ref: '#/definitions/httpx.ErrorResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/api.ErrorResponse' + $ref: '#/definitions/httpx.ErrorResponse' + security: + - BearerAuth: [] summary: Create a user group tags: - user-groups @@ -468,19 +656,29 @@ paths: "400": description: id 无效 schema: - $ref: '#/definitions/api.ErrorResponse' + $ref: '#/definitions/httpx.ErrorResponse' + "401": + description: 未登录或登录已过期 + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "403": + description: 需要管理员权限或账号已被禁用 + schema: + $ref: '#/definitions/httpx.ErrorResponse' "404": description: 记录不存在 schema: - $ref: '#/definitions/api.ErrorResponse' + $ref: '#/definitions/httpx.ErrorResponse' "409": description: 系统内置组不可删除或用户组内仍有用户 schema: - $ref: '#/definitions/api.ErrorResponse' + $ref: '#/definitions/httpx.ErrorResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/api.ErrorResponse' + $ref: '#/definitions/httpx.ErrorResponse' + security: + - BearerAuth: [] summary: Delete a user group tags: - user-groups @@ -503,15 +701,25 @@ paths: "400": description: id 无效 schema: - $ref: '#/definitions/api.ErrorResponse' + $ref: '#/definitions/httpx.ErrorResponse' + "401": + description: 未登录或登录已过期 + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "403": + description: 需要管理员权限或账号已被禁用 + schema: + $ref: '#/definitions/httpx.ErrorResponse' "404": description: 记录不存在 schema: - $ref: '#/definitions/api.ErrorResponse' + $ref: '#/definitions/httpx.ErrorResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/api.ErrorResponse' + $ref: '#/definitions/httpx.ErrorResponse' + security: + - BearerAuth: [] summary: Get a user group tags: - user-groups @@ -531,7 +739,7 @@ paths: name: group required: true schema: - $ref: '#/definitions/api.UserGroupRequest' + $ref: '#/definitions/usergroup.Request' produces: - application/json responses: @@ -542,19 +750,29 @@ paths: "400": description: 参数无效或 id 无效 schema: - $ref: '#/definitions/api.ErrorResponse' + $ref: '#/definitions/httpx.ErrorResponse' + "401": + description: 未登录或登录已过期 + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "403": + description: 需要管理员权限或账号已被禁用 + schema: + $ref: '#/definitions/httpx.ErrorResponse' "404": description: 记录不存在 schema: - $ref: '#/definitions/api.ErrorResponse' + $ref: '#/definitions/httpx.ErrorResponse' "409": description: 用户组名称已存在 schema: - $ref: '#/definitions/api.ErrorResponse' + $ref: '#/definitions/httpx.ErrorResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/api.ErrorResponse' + $ref: '#/definitions/httpx.ErrorResponse' + security: + - BearerAuth: [] summary: Update a user group tags: - user-groups @@ -578,11 +796,21 @@ paths: "200": description: OK schema: - $ref: '#/definitions/api.UserListResponse' + $ref: '#/definitions/user.ListResponse' + "401": + description: 未登录或登录已过期 + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "403": + description: 需要管理员权限或账号已被禁用 + schema: + $ref: '#/definitions/httpx.ErrorResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/api.ErrorResponse' + $ref: '#/definitions/httpx.ErrorResponse' + security: + - BearerAuth: [] summary: List users tags: - users @@ -597,7 +825,7 @@ paths: name: user required: true schema: - $ref: '#/definitions/api.UserCreateRequest' + $ref: '#/definitions/user.CreateRequest' produces: - application/json responses: @@ -608,15 +836,25 @@ paths: "400": description: 参数无效或用户组不存在 schema: - $ref: '#/definitions/api.ErrorResponse' + $ref: '#/definitions/httpx.ErrorResponse' + "401": + description: 未登录或登录已过期 + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "403": + description: 需要管理员权限或账号已被禁用 + schema: + $ref: '#/definitions/httpx.ErrorResponse' "409": description: 用户名或邮箱已存在 schema: - $ref: '#/definitions/api.ErrorResponse' + $ref: '#/definitions/httpx.ErrorResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/api.ErrorResponse' + $ref: '#/definitions/httpx.ErrorResponse' + security: + - BearerAuth: [] summary: Create a user tags: - users @@ -638,15 +876,25 @@ paths: "400": description: id 无效 schema: - $ref: '#/definitions/api.ErrorResponse' + $ref: '#/definitions/httpx.ErrorResponse' + "401": + description: 未登录或登录已过期 + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "403": + description: 需要管理员权限或账号已被禁用 + schema: + $ref: '#/definitions/httpx.ErrorResponse' "404": description: 记录不存在 schema: - $ref: '#/definitions/api.ErrorResponse' + $ref: '#/definitions/httpx.ErrorResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/api.ErrorResponse' + $ref: '#/definitions/httpx.ErrorResponse' + security: + - BearerAuth: [] summary: Delete a user tags: - users @@ -669,15 +917,25 @@ paths: "400": description: id 无效 schema: - $ref: '#/definitions/api.ErrorResponse' + $ref: '#/definitions/httpx.ErrorResponse' + "401": + description: 未登录或登录已过期 + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "403": + description: 需要管理员权限或账号已被禁用 + schema: + $ref: '#/definitions/httpx.ErrorResponse' "404": description: 记录不存在 schema: - $ref: '#/definitions/api.ErrorResponse' + $ref: '#/definitions/httpx.ErrorResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/api.ErrorResponse' + $ref: '#/definitions/httpx.ErrorResponse' + security: + - BearerAuth: [] summary: Get a user tags: - users @@ -697,7 +955,7 @@ paths: name: user required: true schema: - $ref: '#/definitions/api.UserUpdateRequest' + $ref: '#/definitions/user.UpdateRequest' produces: - application/json responses: @@ -708,16 +966,32 @@ paths: "400": description: 参数无效、id 无效或用户组不存在 schema: - $ref: '#/definitions/api.ErrorResponse' + $ref: '#/definitions/httpx.ErrorResponse' + "401": + description: 未登录或登录已过期 + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "403": + description: 需要管理员权限或账号已被禁用 + schema: + $ref: '#/definitions/httpx.ErrorResponse' "404": description: 记录不存在 schema: - $ref: '#/definitions/api.ErrorResponse' + $ref: '#/definitions/httpx.ErrorResponse' "500": description: Internal Server Error schema: - $ref: '#/definitions/api.ErrorResponse' + $ref: '#/definitions/httpx.ErrorResponse' + security: + - BearerAuth: [] summary: Update a user tags: - users +securityDefinitions: + BearerAuth: + description: Bearer JWT,格式:Bearer {token},通过 /auth/login 获取 + in: header + name: Authorization + type: apiKey swagger: "2.0" diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts index 367204c..eb2f86f 100644 --- a/frontend/src/api/users.ts +++ b/frontend/src/api/users.ts @@ -16,7 +16,7 @@ export interface User { } export function registerUser(payload: RegisterPayload): Promise { - return request('/users', { + return request('/auth/register', { method: 'POST', body: JSON.stringify(payload), }) diff --git a/go.mod b/go.mod index 9f53530..2d4cd21 100644 --- a/go.mod +++ b/go.mod @@ -38,6 +38,7 @@ require ( github.com/go-playground/validator/v10 v10.30.3 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/goccy/go-json v0.10.6 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/uuid v1.3.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect diff --git a/go.sum b/go.sum index 6cb1fc4..e8fab7f 100644 --- a/go.sum +++ b/go.sum @@ -61,6 +61,8 @@ github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= diff --git a/internal/api/api.go b/internal/api/api.go index bb70d9b..b326b6d 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -1,22 +1,27 @@ -// Package api 提供 HTTP 接口路由。 +// Package api 负责 HTTP 接口路由装配。 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/auth" + "rill/internal/config" "rill/internal/database" + "rill/internal/note" + "rill/internal/user" + "rill/internal/usergroup" ) -// RegisterRoutes 注册 API 路由。 -func RegisterRoutes(rg *gin.RouterGroup, db *gorm.DB) { +// RegisterRoutes 注册 API 路由。health、swagger、auth 公开;notes 需登录;用户与用户组管理仅限管理员。 +func RegisterRoutes(rg *gin.RouterGroup, db *gorm.DB, cfg *config.Config) { + authn := auth.NewAuthenticator(cfg) + rg.GET("/health", health(db)) swagger := rg.Group("/swagger") @@ -27,67 +32,44 @@ func RegisterRoutes(rg *gin.RouterGroup, db *gorm.DB) { swagger.GET("/*any", ginSwagger.WrapHandler(swaggerFiles.Handler, ginSwagger.URL("doc.json"))) } - notes := rg.Group("/notes") + authGroup := rg.Group("/auth") { - notes.GET("", listNotes(db)) - notes.POST("", createNote(db)) - notes.GET("/:id", getNote(db)) - notes.PUT("/:id", updateNote(db)) - notes.DELETE("/:id", deleteNote(db)) + authGroup.POST("/register", auth.Register(db)) + authGroup.POST("/login", auth.Login(db, authn)) } - users := rg.Group("/users") + authed := rg.Group("", authn.RequireAuth(db)) { - users.GET("", listUsers(db)) - users.POST("", createUser(db)) - users.GET("/:id", getUser(db)) - users.PUT("/:id", updateUser(db)) - users.DELETE("/:id", deleteUser(db)) + notes := authed.Group("/notes") + { + notes.GET("", note.List(db)) + notes.POST("", note.Create(db)) + notes.GET("/:id", note.Get(db)) + notes.PUT("/:id", note.Update(db)) + notes.DELETE("/:id", note.Delete(db)) + } } - userGroups := rg.Group("/user-groups") + admin := rg.Group("", authn.RequireAuth(db), auth.RequireAdmin()) { - userGroups.GET("", listUserGroups(db)) - userGroups.POST("", createUserGroup(db)) - userGroups.GET("/:id", getUserGroup(db)) - userGroups.PUT("/:id", updateUserGroup(db)) - userGroups.DELETE("/:id", deleteUserGroup(db)) - } -} + users := admin.Group("/users") + { + users.GET("", user.List(db)) + users.POST("", user.Create(db)) + users.GET("/:id", user.Get(db)) + users.PUT("/:id", user.Update(db)) + users.DELETE("/:id", user.Delete(db)) + } -func parsePagination(c *gin.Context) (int, int) { - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", strconv.Itoa(defaultPageSize))) - if page < 1 { - page = 1 + groups := admin.Group("/user-groups") + { + groups.GET("", usergroup.List(db)) + groups.POST("", usergroup.Create(db)) + groups.GET("/:id", usergroup.Get(db)) + groups.PUT("/:id", usergroup.Update(db)) + groups.DELETE("/:id", usergroup.Delete(db)) + } } - if pageSize < 1 || pageSize > maxPageSize { - pageSize = defaultPageSize - } - return page, pageSize -} - -// respondGetError 查询类错误:记录不存在返回 404,其余按数据库错误处理。 -func respondGetError(c *gin.Context, err error) { - if errors.Is(err, gorm.ErrRecordNotFound) { - c.JSON(http.StatusNotFound, ErrorResponse{Error: "记录不存在"}) - return - } - respondDBError(c, err) -} - -// respondDuplicateOrDBError 写入类错误:唯一约束冲突返回 409,其余按数据库错误处理。 -func respondDuplicateOrDBError(c *gin.Context, err error, duplicateMsg string) { - if errors.Is(err, gorm.ErrDuplicatedKey) { - c.JSON(http.StatusConflict, ErrorResponse{Error: duplicateMsg}) - return - } - respondDBError(c, err) -} - -// ErrorResponse 统一错误响应。 -type ErrorResponse struct { - Error string `json:"error" example:"记录不存在"` } // HealthResponse 健康检查响应。 diff --git a/internal/api/api_test.go b/internal/api/api_test.go new file mode 100644 index 0000000..d46566c --- /dev/null +++ b/internal/api/api_test.go @@ -0,0 +1,26 @@ +package api_test + +import ( + "encoding/json" + "net/http" + "testing" + + "rill/internal/testutil" +) + +func TestHealth(t *testing.T) { + env := testutil.Setup(t) + + w := testutil.Call(t, env.Router(""), http.MethodGet, "/api/health", nil) + if w.Code != http.StatusOK { + t.Fatalf("状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String()) + } + + var resp map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("解析响应失败: %v", err) + } + if resp["status"] != "ok" { + t.Errorf("status = %q, 期望 ok", resp["status"]) + } +} diff --git a/internal/api/notes_test.go b/internal/api/notes_test.go deleted file mode 100644 index a9bbf02..0000000 --- a/internal/api/notes_test.go +++ /dev/null @@ -1,182 +0,0 @@ -package api - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "path/filepath" - "testing" - - "github.com/gin-gonic/gin" - "gorm.io/gorm" - - "rill/internal/config" - "rill/internal/database" - "rill/internal/model" -) - -func setupRouter(t *testing.T) *gin.Engine { - t.Helper() - r, _ := setupRouterWithDB(t) - return r -} - -func setupRouterWithDB(t *testing.T) (*gin.Engine, *gorm.DB) { - t.Helper() - gin.SetMode(gin.TestMode) - - cfg := &config.Config{ - Database: config.DatabaseConfig{ - Driver: "sqlite3", - ConnectTimeout: "5s", - SQLite: config.SQLiteConfig{Path: filepath.Join(t.TempDir(), "test.db")}, - }, - } - db, err := database.Open(cfg) - if err != nil { - t.Fatalf("打开测试数据库失败: %v", err) - } - t.Cleanup(func() { - if err := database.Close(db); err != nil { - t.Errorf("关闭测试数据库失败: %v", err) - } - }) - - if err := database.Migrate(context.Background(), db); err != nil { - t.Fatalf("执行测试迁移失败: %v", err) - } - - r := gin.New() - RegisterRoutes(r.Group("/api"), db) - return r, db -} - -func call(t *testing.T, r http.Handler, method, path string, body any) *httptest.ResponseRecorder { - t.Helper() - - var payload []byte - if body != nil { - var err error - payload, err = json.Marshal(body) - if err != nil { - t.Fatalf("序列化请求体失败: %v", err) - } - } - - req := httptest.NewRequest(method, path, bytes.NewReader(payload)) - if body != nil { - req.Header.Set("Content-Type", "application/json") - } - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - return w -} - -func decodeNote(t *testing.T, w *httptest.ResponseRecorder) model.Note { - t.Helper() - var note model.Note - if err := json.Unmarshal(w.Body.Bytes(), ¬e); err != nil { - t.Fatalf("解析响应失败: %v, body=%s", err, w.Body.String()) - } - return note -} - -func TestHealth(t *testing.T) { - r := setupRouter(t) - - w := call(t, r, http.MethodGet, "/api/health", nil) - if w.Code != http.StatusOK { - t.Fatalf("状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String()) - } - - var resp map[string]string - if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { - t.Fatalf("解析响应失败: %v", err) - } - if resp["status"] != "ok" { - t.Errorf("status = %q, 期望 ok", resp["status"]) - } -} - -func TestNoteCRUD(t *testing.T) { - r := setupRouter(t) - - w := call(t, r, http.MethodPost, "/api/notes", map[string]string{"title": "第一条", "content": "内容"}) - if w.Code != http.StatusCreated { - t.Fatalf("创建状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusCreated, w.Body.String()) - } - created := decodeNote(t, w) - if created.ID == 0 || created.Title != "第一条" || created.Content != "内容" { - t.Fatalf("创建结果异常: %+v", created) - } - - detailPath := fmt.Sprintf("/api/notes/%d", created.ID) - w = call(t, r, http.MethodGet, detailPath, nil) - if w.Code != http.StatusOK { - t.Fatalf("详情状态码 = %d, 期望 %d", w.Code, http.StatusOK) - } - if got := decodeNote(t, w); got.ID != created.ID { - t.Errorf("详情 ID = %d, 期望 %d", got.ID, created.ID) - } - - w = call(t, r, http.MethodPut, detailPath, map[string]string{"title": "已更新", "content": "新内容"}) - if w.Code != http.StatusOK { - t.Fatalf("更新状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String()) - } - updated := decodeNote(t, w) - if updated.Title != "已更新" || updated.Content != "新内容" { - t.Errorf("更新结果异常: %+v", updated) - } - - w = call(t, r, http.MethodGet, "/api/notes?page=1&page_size=10", nil) - if w.Code != http.StatusOK { - t.Fatalf("列表状态码 = %d, 期望 %d", w.Code, http.StatusOK) - } - var list struct { - Items []model.Note `json:"items"` - Total int64 `json:"total"` - Page int `json:"page"` - PageSize int `json:"page_size"` - } - if err := json.Unmarshal(w.Body.Bytes(), &list); err != nil { - t.Fatalf("解析列表响应失败: %v", err) - } - if list.Total != 1 || len(list.Items) != 1 { - t.Fatalf("列表结果异常: total=%d, items=%d", list.Total, len(list.Items)) - } - if list.Items[0].Title != "已更新" { - t.Errorf("列表项标题 = %q, 期望 已更新", list.Items[0].Title) - } - - w = call(t, r, http.MethodDelete, detailPath, nil) - if w.Code != http.StatusNoContent { - t.Fatalf("删除状态码 = %d, 期望 %d", w.Code, http.StatusNoContent) - } - - w = call(t, r, http.MethodGet, detailPath, nil) - if w.Code != http.StatusNotFound { - t.Errorf("删除后详情状态码 = %d, 期望 %d", w.Code, http.StatusNotFound) - } -} - -func TestNoteValidation(t *testing.T) { - r := setupRouter(t) - - w := call(t, r, http.MethodPost, "/api/notes", map[string]string{"content": "缺少标题"}) - if w.Code != http.StatusBadRequest { - t.Errorf("缺少标题状态码 = %d, 期望 %d", w.Code, http.StatusBadRequest) - } - - w = call(t, r, http.MethodGet, "/api/notes/abc", nil) - if w.Code != http.StatusBadRequest { - t.Errorf("非法 id 状态码 = %d, 期望 %d", w.Code, http.StatusBadRequest) - } - - w = call(t, r, http.MethodGet, "/api/notes/9999", nil) - if w.Code != http.StatusNotFound { - t.Errorf("不存在记录状态码 = %d, 期望 %d", w.Code, http.StatusNotFound) - } -} diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..385317f --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,311 @@ +// Package auth 提供注册、登录、JWT 签发与鉴权中间件。 +package auth + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "log/slog" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" + + "rill/internal/config" + "rill/internal/httpx" + "rill/internal/model" + "rill/internal/user" +) + +const ( + tokenIssuer = "rill" + authUserKey = "auth_user" + defaultTokenTTL = 24 * time.Hour + bearerPrefix = "Bearer " +) + +// dummyPasswordHash 账号不存在时仍执行一次 bcrypt 比对,避免通过响应时间枚举账号。 +var dummyPasswordHash, _ = bcrypt.GenerateFromPassword([]byte("rill-dummy-password"), bcrypt.DefaultCost) + +// RegisterRequest 注册请求。 +type RegisterRequest struct { + Username string `json:"username" binding:"required,min=3,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"` +} + +// LoginRequest 登录请求,account 可填用户名或邮箱。 +type LoginRequest struct { + Account string `json:"account" binding:"required" example:"alice"` + Password string `json:"password" binding:"required" example:"secret123"` +} + +// LoginResponse 登录成功响应。 +type LoginResponse struct { + Token string `json:"token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."` + ExpiresAt time.Time `json:"expires_at" example:"2026-09-21T10:00:00+08:00"` + User model.User `json:"user"` +} + +// @Summary Register +// @Description 公开注册,创建普通用户并默认加入普通用户组(id 1)。username 长度 3-50 且唯一,email 唯一,password 长度 6-72。 +// @Tags auth +// @Accept json +// @Produce json +// @Param user body auth.RegisterRequest true "注册信息" +// @Success 201 {object} model.User +// @Failure 400 {object} httpx.ErrorResponse "参数无效" +// @Failure 409 {object} httpx.ErrorResponse "用户名或邮箱已存在" +// @Failure 500 {object} httpx.ErrorResponse +// @Router /auth/register [post] +func Register(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + var req RegisterRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "参数无效: " + err.Error()}) + return + } + + ctx := c.Request.Context() + hash, err := user.HashPassword(req.Password) + if err != nil { + httpx.RespondServerError(c, err, "生成密码哈希失败") + return + } + + newUser := model.User{ + Username: req.Username, + Email: req.Email, + PasswordHash: string(hash), + Status: 1, + } + err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&newUser).Error; err != nil { + return err + } + return user.ReplaceGroups(tx, newUser.ID, []uint{model.GroupIDUser}) + }) + if err != nil { + httpx.RespondDuplicateOrDBError(c, err, "用户名或邮箱已存在") + return + } + + groups, err := user.LoadGroups(ctx, db, newUser.ID) + if err != nil { + httpx.RespondDBError(c, err) + return + } + newUser.Groups = groups + c.JSON(http.StatusCreated, newUser) + } +} + +// @Summary Login +// @Description 使用用户名或邮箱登录,成功返回 JWT(有效期见 auth.token_ttl)与用户信息。 +// @Tags auth +// @Accept json +// @Produce json +// @Param credentials body auth.LoginRequest true "登录凭证" +// @Success 200 {object} auth.LoginResponse +// @Failure 400 {object} httpx.ErrorResponse "参数无效" +// @Failure 401 {object} httpx.ErrorResponse "账号或密码错误" +// @Failure 403 {object} httpx.ErrorResponse "账号已被禁用" +// @Failure 500 {object} httpx.ErrorResponse +// @Router /auth/login [post] +func Login(db *gorm.DB, authn *Authenticator) gin.HandlerFunc { + return func(c *gin.Context) { + var req LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "参数无效: " + err.Error()}) + return + } + + ctx := c.Request.Context() + account := strings.TrimSpace(req.Account) + var loginUser model.User + err := db.WithContext(ctx). + Where("username = ? OR email = ?", account, account). + First(&loginUser).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + _ = bcrypt.CompareHashAndPassword(dummyPasswordHash, []byte(req.Password)) + c.JSON(http.StatusUnauthorized, httpx.ErrorResponse{Error: "账号或密码错误"}) + return + } + httpx.RespondDBError(c, err) + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(loginUser.PasswordHash), []byte(req.Password)); err != nil { + c.JSON(http.StatusUnauthorized, httpx.ErrorResponse{Error: "账号或密码错误"}) + return + } + if loginUser.Status != 1 { + c.JSON(http.StatusForbidden, httpx.ErrorResponse{Error: "账号已被禁用"}) + return + } + + groups, err := user.LoadGroups(ctx, db, loginUser.ID) + if err != nil { + httpx.RespondDBError(c, err) + return + } + loginUser.Groups = groups + + token, expiresAt, err := authn.Sign(loginUser.ID) + if err != nil { + slog.ErrorContext(ctx, "签发登录凭证失败", "err", err) + c.JSON(http.StatusInternalServerError, httpx.ErrorResponse{Error: "服务器内部错误"}) + return + } + c.JSON(http.StatusOK, LoginResponse{Token: token, ExpiresAt: expiresAt, User: loginUser}) + } +} + +// Authenticator 负责 JWT 的签发与校验。 +type Authenticator struct { + secret []byte + ttl time.Duration +} + +// NewAuthenticator 依据配置构造认证器;secret 留空时生成临时密钥并告警。 +func NewAuthenticator(cfg *config.Config) *Authenticator { + secret := strings.TrimSpace(cfg.Auth.Secret) + if secret == "" { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + slog.Error("生成临时 JWT 密钥失败", "err", err) + } + secret = hex.EncodeToString(buf) + slog.Warn("auth.secret 未配置,已生成临时密钥,重启后登录状态将失效") + } + + ttl := cfg.TokenTTLDuration() + if ttl <= 0 { + ttl = defaultTokenTTL + } + return &Authenticator{secret: []byte(secret), ttl: ttl} +} + +// Sign 为用户签发登录凭证,返回 token 与过期时间。 +func (a *Authenticator) Sign(userID uint) (string, time.Time, error) { + now := time.Now() + expiresAt := now.Add(a.ttl) + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + Issuer: tokenIssuer, + Subject: strconv.FormatUint(uint64(userID), 10), + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(expiresAt), + }) + signed, err := token.SignedString(a.secret) + if err != nil { + return "", time.Time{}, err + } + return signed, expiresAt, nil +} + +// Parse 校验登录凭证并返回用户 ID。 +func (a *Authenticator) Parse(tokenString string) (uint, error) { + claims := &jwt.RegisteredClaims{} + token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (any, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("非预期的签名算法: %v", t.Header["alg"]) + } + return a.secret, nil + }, + jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}), + jwt.WithIssuer(tokenIssuer), + jwt.WithExpirationRequired(), + ) + if err != nil || !token.Valid { + return 0, errors.New("登录凭证无效") + } + + id, err := strconv.ParseUint(claims.Subject, 10, 64) + if err != nil || id == 0 { + return 0, errors.New("登录凭证无效") + } + return uint(id), nil +} + +// RequireAuth 校验 Bearer 凭证并加载当前用户到上下文。 +func (a *Authenticator) RequireAuth(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + token, ok := bearerToken(c) + if !ok { + httpx.RespondUnauthorized(c) + return + } + userID, err := a.Parse(token) + if err != nil { + httpx.RespondUnauthorized(c) + return + } + + ctx := c.Request.Context() + var current model.User + if err := db.WithContext(ctx).First(¤t, userID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + httpx.RespondUnauthorized(c) + return + } + httpx.RespondDBError(c, err) + return + } + if current.Status != 1 { + c.AbortWithStatusJSON(http.StatusForbidden, httpx.ErrorResponse{Error: "账号已被禁用"}) + return + } + + groups, err := user.LoadGroups(ctx, db, current.ID) + if err != nil { + httpx.RespondDBError(c, err) + return + } + current.Groups = groups + c.Set(authUserKey, current) + c.Next() + } +} + +// RequireAdmin 要求当前用户属于 admin 组,需在 RequireAuth 之后使用。 +func RequireAdmin() gin.HandlerFunc { + return func(c *gin.Context) { + current, ok := currentUser(c) + if !ok { + httpx.RespondUnauthorized(c) + return + } + for _, group := range current.Groups { + if group.ID == model.GroupIDAdmin { + c.Next() + return + } + } + c.AbortWithStatusJSON(http.StatusForbidden, httpx.ErrorResponse{Error: "需要管理员权限"}) + } +} + +func currentUser(c *gin.Context) (model.User, bool) { + value, ok := c.Get(authUserKey) + if !ok { + return model.User{}, false + } + current, ok := value.(model.User) + return current, ok +} + +func bearerToken(c *gin.Context) (string, bool) { + header := c.GetHeader("Authorization") + if len(header) <= len(bearerPrefix) || !strings.EqualFold(header[:len(bearerPrefix)], bearerPrefix) { + return "", false + } + token := strings.TrimSpace(header[len(bearerPrefix):]) + return token, token != "" +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..de69cbf --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,248 @@ +package auth_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "golang.org/x/crypto/bcrypt" + + "rill/internal/auth" + "rill/internal/model" + "rill/internal/testutil" +) + +func registerViaAPI(t *testing.T, r http.Handler, username, email, password string, extra map[string]any) model.User { + t.Helper() + body := map[string]any{"username": username, "email": email, "password": password} + for k, v := range extra { + body[k] = v + } + w := testutil.Call(t, r, http.MethodPost, "/api/auth/register", body) + if w.Code != http.StatusCreated { + t.Fatalf("注册状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusCreated, w.Body.String()) + } + return testutil.DecodeUser(t, w) +} + +func decodeLogin(t *testing.T, w *httptest.ResponseRecorder) auth.LoginResponse { + t.Helper() + var resp auth.LoginResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("解析响应失败: %v, body=%s", err, w.Body.String()) + } + return resp +} + +func TestRegister(t *testing.T) { + env := testutil.Setup(t) + r := env.Router("") + + // 即使显式传 group_ids 也不能提权,公开注册固定进入普通用户组。 + user := registerViaAPI(t, r, "alice", "alice@example.com", "secret123", map[string]any{"group_ids": []uint{model.GroupIDAdmin}}) + if user.ID == 0 || user.Status != 1 { + t.Fatalf("注册结果异常: %+v", user) + } + if len(user.Groups) != 1 || user.Groups[0].ID != model.GroupIDUser { + t.Fatalf("注册用户组异常: %+v", user.Groups) + } + + w := testutil.Call(t, r, http.MethodPost, "/api/auth/register", map[string]string{ + "username": "alice2", "email": "alice2@example.com", "password": "secret123", + }) + if strings.Contains(w.Body.String(), "password") { + t.Errorf("注册响应泄露密码字段: %s", w.Body.String()) + } + + var stored model.User + if err := env.DB.First(&stored, user.ID).Error; err != nil { + t.Fatalf("查询数据库失败: %v", err) + } + if stored.PasswordHash == "secret123" { + t.Fatal("密码未加密存储") + } + if err := bcrypt.CompareHashAndPassword([]byte(stored.PasswordHash), []byte("secret123")); err != nil { + t.Fatalf("密码哈希校验失败: %v", err) + } + + w = testutil.Call(t, r, http.MethodPost, "/api/auth/register", map[string]string{ + "username": "alice", "email": "other@example.com", "password": "secret123", + }) + if w.Code != http.StatusConflict { + t.Errorf("重复用户名状态码 = %d, 期望 %d", w.Code, http.StatusConflict) + } + + w = testutil.Call(t, r, http.MethodPost, "/api/auth/register", map[string]string{ + "username": "alice3", "email": "alice@example.com", "password": "secret123", + }) + if w.Code != http.StatusConflict { + t.Errorf("重复邮箱状态码 = %d, 期望 %d", w.Code, http.StatusConflict) + } +} + +func TestRegisterValidation(t *testing.T) { + env := testutil.Setup(t) + r := env.Router("") + + cases := []struct { + name string + body map[string]string + }{ + {"用户名过短", map[string]string{"username": "ab", "email": "a@example.com", "password": "secret123"}}, + {"缺少邮箱", map[string]string{"username": "alice", "password": "secret123"}}, + {"邮箱格式非法", map[string]string{"username": "alice", "email": "not-email", "password": "secret123"}}, + {"密码过短", map[string]string{"username": "alice", "email": "a@example.com", "password": "12345"}}, + } + for _, tc := range cases { + w := testutil.Call(t, r, http.MethodPost, "/api/auth/register", tc.body) + if w.Code != http.StatusBadRequest { + t.Errorf("%s 状态码 = %d, 期望 %d, body=%s", tc.name, w.Code, http.StatusBadRequest, w.Body.String()) + } + } +} + +func TestLogin(t *testing.T) { + env := testutil.Setup(t) + r := env.Router("") + user := registerViaAPI(t, r, "alice", "alice@example.com", "secret123", nil) + + w := testutil.Call(t, r, http.MethodPost, "/api/auth/login", map[string]string{ + "account": "alice", "password": "secret123", + }) + if w.Code != http.StatusOK { + t.Fatalf("用户名登录状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String()) + } + resp := decodeLogin(t, w) + if resp.Token == "" || resp.User.ID != user.ID { + t.Fatalf("登录响应异常: %+v", resp) + } + if !resp.ExpiresAt.After(time.Now()) { + t.Errorf("expires_at 应晚于当前时间: %v", resp.ExpiresAt) + } + if len(resp.User.Groups) != 1 || resp.User.Groups[0].ID != model.GroupIDUser { + t.Errorf("登录响应用户组异常: %+v", resp.User.Groups) + } + + w = testutil.Call(t, r, http.MethodPost, "/api/auth/login", map[string]string{ + "account": "alice@example.com", "password": "secret123", + }) + if w.Code != http.StatusOK { + t.Fatalf("邮箱登录状态码 = %d, 期望 %d", w.Code, http.StatusOK) + } + emailResp := decodeLogin(t, w) + + authed := env.Router(emailResp.Token) + if w := testutil.Call(t, authed, http.MethodGet, "/api/notes", nil); w.Code != http.StatusOK { + t.Errorf("登录凭证访问 notes 状态码 = %d, 期望 %d", w.Code, http.StatusOK) + } + if w := testutil.Call(t, authed, http.MethodGet, "/api/users", nil); w.Code != http.StatusForbidden { + t.Errorf("普通用户访问 users 状态码 = %d, 期望 %d", w.Code, http.StatusForbidden) + } + + w = testutil.Call(t, r, http.MethodPost, "/api/auth/login", map[string]string{ + "account": "alice", "password": "wrong-password", + }) + if w.Code != http.StatusUnauthorized { + t.Errorf("密码错误状态码 = %d, 期望 %d", w.Code, http.StatusUnauthorized) + } + + w = testutil.Call(t, r, http.MethodPost, "/api/auth/login", map[string]string{ + "account": "nobody", "password": "secret123", + }) + if w.Code != http.StatusUnauthorized { + t.Errorf("账号不存在状态码 = %d, 期望 %d", w.Code, http.StatusUnauthorized) + } +} + +func TestLoginDisabledUser(t *testing.T) { + env := testutil.Setup(t) + r := env.Router("") + user := registerViaAPI(t, r, "alice", "alice@example.com", "secret123", nil) + + if err := env.DB.Model(&model.User{}).Where("id = ?", user.ID).Update("status", 0).Error; err != nil { + t.Fatalf("禁用用户失败: %v", err) + } + + w := testutil.Call(t, r, http.MethodPost, "/api/auth/login", map[string]string{ + "account": "alice", "password": "secret123", + }) + if w.Code != http.StatusForbidden { + t.Fatalf("禁用账号登录状态码 = %d, 期望 %d", w.Code, http.StatusForbidden) + } + + authed := env.Router(env.Sign(user.ID)) + if w := testutil.Call(t, authed, http.MethodGet, "/api/notes", nil); w.Code != http.StatusForbidden { + t.Errorf("禁用账号访问 notes 状态码 = %d, 期望 %d", w.Code, http.StatusForbidden) + } +} + +func TestLoginSoftDeletedUser(t *testing.T) { + env := testutil.Setup(t) + r := env.Router("") + user := registerViaAPI(t, r, "alice", "alice@example.com", "secret123", nil) + + if err := env.DB.Delete(&model.User{}, user.ID).Error; err != nil { + t.Fatalf("删除用户失败: %v", err) + } + + w := testutil.Call(t, r, http.MethodPost, "/api/auth/login", map[string]string{ + "account": "alice", "password": "secret123", + }) + if w.Code != http.StatusUnauthorized { + t.Fatalf("已删除账号登录状态码 = %d, 期望 %d", w.Code, http.StatusUnauthorized) + } + + authed := env.Router(env.Sign(user.ID)) + if w := testutil.Call(t, authed, http.MethodGet, "/api/notes", nil); w.Code != http.StatusUnauthorized { + t.Errorf("已删除账号访问 notes 状态码 = %d, 期望 %d", w.Code, http.StatusUnauthorized) + } +} + +func TestAuthMiddleware(t *testing.T) { + env := testutil.Setup(t) + + if w := testutil.Call(t, env.Router(""), http.MethodGet, "/api/health", nil); w.Code != http.StatusOK { + t.Errorf("health 应公开访问, 状态码 = %d", w.Code) + } + + anonymous := env.Router("") + if w := testutil.Call(t, anonymous, http.MethodGet, "/api/notes", nil); w.Code != http.StatusUnauthorized { + t.Errorf("匿名访问 notes 状态码 = %d, 期望 %d", w.Code, http.StatusUnauthorized) + } + + invalid := env.Router("not-a-token") + if w := testutil.Call(t, invalid, http.MethodGet, "/api/notes", nil); w.Code != http.StatusUnauthorized { + t.Errorf("非法凭证状态码 = %d, 期望 %d", w.Code, http.StatusUnauthorized) + } + + expiredCfg := *env.Cfg + expiredCfg.Auth.TokenTTL = "1ns" + expiredAuthn := auth.NewAuthenticator(&expiredCfg) + expiredToken, _, err := expiredAuthn.Sign(env.Admin.ID) + if err != nil { + t.Fatalf("签发过期凭证失败: %v", err) + } + expired := env.Router(expiredToken) + if w := testutil.Call(t, expired, http.MethodGet, "/api/notes", nil); w.Code != http.StatusUnauthorized { + t.Errorf("过期凭证状态码 = %d, 期望 %d", w.Code, http.StatusUnauthorized) + } + + wrongCfg := *env.Cfg + wrongCfg.Auth.Secret = "other-secret" + wrongAuthn := auth.NewAuthenticator(&wrongCfg) + wrongToken, _, err := wrongAuthn.Sign(env.Admin.ID) + if err != nil { + t.Fatalf("签发错误密钥凭证失败: %v", err) + } + wrong := env.Router(wrongToken) + if w := testutil.Call(t, wrong, http.MethodGet, "/api/notes", nil); w.Code != http.StatusUnauthorized { + t.Errorf("错误签名凭证状态码 = %d, 期望 %d", w.Code, http.StatusUnauthorized) + } + + if w := testutil.Call(t, env.AdminRouter(), http.MethodGet, "/api/users", nil); w.Code != http.StatusOK { + t.Errorf("管理员访问 users 状态码 = %d, 期望 %d", w.Code, http.StatusOK) + } +} diff --git a/internal/config/config.default.yaml b/internal/config/config.default.yaml index b2f10d4..812ed9b 100644 --- a/internal/config/config.default.yaml +++ b/internal/config/config.default.yaml @@ -1,5 +1,5 @@ # rill 服务端配置 -version: 1 # 配置版本,用于启动时自动补全缺失项,请勿手动修改 +version: 2 # 配置版本,用于启动时自动补全缺失项,请勿手动修改 server: host: "0.0.0.0" # 监听地址,0.0.0.0 表示所有网卡 @@ -26,6 +26,11 @@ api: allow_credentials: false max_age: "12h" +# 认证 +auth: + secret: "" # JWT 签名密钥,留空则每次启动生成临时密钥(重启后 token 失效),生产环境请务必配置 + token_ttl: "24h" # 登录凭证有效期 + # 数据库 database: driver: sqlite3 # sqlite3 / mysql diff --git a/internal/config/config.go b/internal/config/config.go index 3617040..06134cf 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -23,6 +23,7 @@ type Config struct { Log LogConfig `yaml:"log"` Static StaticConfig `yaml:"static"` API APIConfig `yaml:"api"` + Auth AuthConfig `yaml:"auth"` Database DatabaseConfig `yaml:"database"` } @@ -66,6 +67,11 @@ type CORSConfig struct { MaxAge string `yaml:"max_age"` } +type AuthConfig struct { + Secret string `yaml:"secret"` + TokenTTL string `yaml:"token_ttl"` +} + type DatabaseConfig struct { Driver string `yaml:"driver"` // sqlite3 / mysql ConnectTimeout string `yaml:"connect_timeout"` // 建立连接超时 @@ -115,6 +121,9 @@ func defaultConfig() *Config { MaxAge: "12h", }, }, + Auth: AuthConfig{ + TokenTTL: "24h", + }, Database: DatabaseConfig{ Driver: "sqlite3", ConnectTimeout: "10s", @@ -194,6 +203,9 @@ func (c *Config) validate() error { if _, err := time.ParseDuration(c.API.CORS.MaxAge); err != nil { return fmt.Errorf("api.cors.max_age 无效: %w", err) } + if _, err := time.ParseDuration(c.Auth.TokenTTL); err != nil { + return fmt.Errorf("auth.token_ttl 无效: %w", err) + } if err := c.validateDatabase(); err != nil { return err } @@ -243,6 +255,12 @@ func (c *Config) CORSMaxAge() time.Duration { return maxAge } +// TokenTTLDuration 登录凭证有效期,配置无效时返回 0。 +func (c *Config) TokenTTLDuration() time.Duration { + ttl, _ := time.ParseDuration(c.Auth.TokenTTL) + return ttl +} + func parseLogLevel(level string) (slog.Level, error) { switch level { case "debug": diff --git a/internal/config/config_test.go b/internal/config/config_test.go index da30875..6bd5da2 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -2,6 +2,7 @@ package config import ( "bytes" + "fmt" "os" "path/filepath" "strings" @@ -30,7 +31,7 @@ custom: out := string(result.Data) for _, want := range []string{ - "version: 1", + fmt.Sprintf("version: %d", ConfigVersion), `host: "127.0.0.1"`, "# 自定义监听地址", "sock:", @@ -41,6 +42,8 @@ custom: "keep: true", "api:", `prefix: "/api"`, + "auth:", + `token_ttl: "24h"`, } { if !strings.Contains(out, want) { t.Errorf("补全结果缺少 %q\n---\n%s", want, out) @@ -80,7 +83,7 @@ func TestUpgradeConfigIdempotent(t *testing.T) { } func TestUpgradeConfigSkipsCurrentVersion(t *testing.T) { - input := "version: 1\nserver:\n host: \"0.0.0.0\"\n" + input := fmt.Sprintf("version: %d\nserver:\n host: \"0.0.0.0\"\n", ConfigVersion) result, err := upgradeConfig([]byte(input)) if err != nil { t.Fatalf("upgradeConfig 失败: %v", err) @@ -144,7 +147,7 @@ func TestLoadConfigUpgradeWritesFile(t *testing.T) { if err != nil { t.Fatal(err) } - if !strings.Contains(string(updated), "version: 1") { + if !strings.Contains(string(updated), fmt.Sprintf("version: %d", ConfigVersion)) { t.Errorf("磁盘配置未补全 version:\n%s", updated) } @@ -190,7 +193,7 @@ func TestLoadConfigGeneratesDefault(t *testing.T) { if err != nil { t.Fatalf("默认配置文件未生成: %v", err) } - if !strings.Contains(string(data), "version: 1") { + if !strings.Contains(string(data), fmt.Sprintf("version: %d", ConfigVersion)) { t.Errorf("默认配置文件缺少 version:\n%s", data) } } diff --git a/internal/config/upgrade.go b/internal/config/upgrade.go index 9b95a40..508b4ea 100644 --- a/internal/config/upgrade.go +++ b/internal/config/upgrade.go @@ -15,7 +15,7 @@ import ( ) // ConfigVersion 当前配置结构版本,新增配置项时递增。 -const ConfigVersion = 1 +const ConfigVersion = 2 // upgradeResult 描述一次配置自动补全的结果。 type upgradeResult struct { diff --git a/internal/httpx/httpx.go b/internal/httpx/httpx.go new file mode 100644 index 0000000..2971f0c --- /dev/null +++ b/internal/httpx/httpx.go @@ -0,0 +1,80 @@ +// Package httpx 提供 HTTP 接口的公共辅助:统一错误响应、分页参数与路径参数解析。 +package httpx + +import ( + "errors" + "log/slog" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +const ( + defaultPageSize = 20 + maxPageSize = 100 +) + +// ErrorResponse 统一错误响应。 +type ErrorResponse struct { + Error string `json:"error" example:"记录不存在"` +} + +// ParsePagination 解析 page/page_size,非法值回落默认。 +func ParsePagination(c *gin.Context) (int, int) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", strconv.Itoa(defaultPageSize))) + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > maxPageSize { + pageSize = defaultPageSize + } + return page, pageSize +} + +// ParseID 解析路径参数 id,0 或非法值返回 false 并写入 400。 +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, ErrorResponse{Error: "id 无效"}) + return 0, false + } + return uint(id), true +} + +// RespondDBError 记录数据库错误并返回 500。 +func RespondDBError(c *gin.Context, err error) { + slog.ErrorContext(c.Request.Context(), "数据库操作失败", "err", err, "path", c.Request.URL.Path) + c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "服务器内部错误"}) +} + +// RespondServerError 记录业务错误并返回 500。 +func RespondServerError(c *gin.Context, err error, msg string) { + slog.ErrorContext(c.Request.Context(), msg, "err", err, "path", c.Request.URL.Path) + c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "服务器内部错误"}) +} + +// RespondGetError 查询类错误:记录不存在返回 404,其余按数据库错误处理。 +func RespondGetError(c *gin.Context, err error) { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, ErrorResponse{Error: "记录不存在"}) + return + } + RespondDBError(c, err) +} + +// RespondDuplicateOrDBError 写入类错误:唯一约束冲突返回 409,其余按数据库错误处理。 +func RespondDuplicateOrDBError(c *gin.Context, err error, duplicateMsg string) { + if errors.Is(err, gorm.ErrDuplicatedKey) { + c.JSON(http.StatusConflict, ErrorResponse{Error: duplicateMsg}) + return + } + RespondDBError(c, err) +} + +// RespondUnauthorized 中止请求并返回 401。 +func RespondUnauthorized(c *gin.Context) { + c.AbortWithStatusJSON(http.StatusUnauthorized, ErrorResponse{Error: "未登录或登录已过期"}) +} diff --git a/internal/api/notes.go b/internal/note/note.go similarity index 53% rename from internal/api/notes.go rename to internal/note/note.go index 81ae2c5..c7a1b7a 100644 --- a/internal/api/notes.go +++ b/internal/note/note.go @@ -1,30 +1,24 @@ -package api +// Package note 提供便签接口。 +package note import ( - "errors" - "log/slog" "net/http" - "strconv" "github.com/gin-gonic/gin" "gorm.io/gorm" + "rill/internal/httpx" "rill/internal/model" ) -const ( - defaultPageSize = 20 - maxPageSize = 100 -) - -// NoteRequest 创建/更新便签请求。 -type NoteRequest struct { +// Request 创建/更新便签请求。 +type Request struct { Title string `json:"title" binding:"required,max=200" example:"购物清单"` Content string `json:"content" example:"牛奶、鸡蛋"` } -// NoteListResponse 便签分页列表响应。 -type NoteListResponse struct { +// ListResponse 便签分页列表响应。 +type ListResponse struct { Items []model.Note `json:"items"` Total int64 `json:"total" example:"42"` Page int `json:"page" example:"1"` @@ -37,17 +31,20 @@ type NoteListResponse struct { // @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 +// @Success 200 {object} note.ListResponse +// @Failure 500 {object} httpx.ErrorResponse +// @Security BearerAuth +// @Failure 401 {object} httpx.ErrorResponse "未登录或登录已过期" +// @Failure 403 {object} httpx.ErrorResponse "账号已被禁用" // @Router /notes [get] -func listNotes(db *gorm.DB) gin.HandlerFunc { +func List(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { - page, pageSize := parsePagination(c) + page, pageSize := httpx.ParsePagination(c) ctx := c.Request.Context() var total int64 if err := db.WithContext(ctx).Model(&model.Note{}).Count(&total).Error; err != nil { - respondDBError(c, err) + httpx.RespondDBError(c, err) return } @@ -57,11 +54,11 @@ func listNotes(db *gorm.DB) gin.HandlerFunc { Offset((page - 1) * pageSize). Limit(pageSize). Find(¬es).Error; err != nil { - respondDBError(c, err) + httpx.RespondDBError(c, err) return } - c.JSON(http.StatusOK, NoteListResponse{ + c.JSON(http.StatusOK, ListResponse{ Items: notes, Total: total, Page: page, @@ -75,22 +72,25 @@ func listNotes(db *gorm.DB) gin.HandlerFunc { // @Tags notes // @Accept json // @Produce json -// @Param note body api.NoteRequest true "便签内容" +// @Param note body note.Request true "便签内容" // @Success 201 {object} model.Note -// @Failure 400 {object} api.ErrorResponse "参数无效" -// @Failure 500 {object} api.ErrorResponse +// @Failure 400 {object} httpx.ErrorResponse "参数无效" +// @Failure 500 {object} httpx.ErrorResponse +// @Security BearerAuth +// @Failure 401 {object} httpx.ErrorResponse "未登录或登录已过期" +// @Failure 403 {object} httpx.ErrorResponse "账号已被禁用" // @Router /notes [post] -func createNote(db *gorm.DB) gin.HandlerFunc { +func Create(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { - var req NoteRequest + var req Request if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, ErrorResponse{Error: "参数无效: " + err.Error()}) + c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "参数无效: " + err.Error()}) return } note := model.Note{Title: req.Title, Content: req.Content} if err := db.WithContext(c.Request.Context()).Create(¬e).Error; err != nil { - respondDBError(c, err) + httpx.RespondDBError(c, err) return } c.JSON(http.StatusCreated, note) @@ -103,24 +103,23 @@ func createNote(db *gorm.DB) gin.HandlerFunc { // @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 +// @Failure 400 {object} httpx.ErrorResponse "id 无效" +// @Failure 404 {object} httpx.ErrorResponse "记录不存在" +// @Failure 500 {object} httpx.ErrorResponse +// @Security BearerAuth +// @Failure 401 {object} httpx.ErrorResponse "未登录或登录已过期" +// @Failure 403 {object} httpx.ErrorResponse "账号已被禁用" // @Router /notes/{id} [get] -func getNote(db *gorm.DB) gin.HandlerFunc { +func Get(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { - id, ok := parseID(c) + id, ok := httpx.ParseID(c) if !ok { return } 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, ErrorResponse{Error: "记录不存在"}) - return - } - respondDBError(c, err) + httpx.RespondGetError(c, err) return } c.JSON(http.StatusOK, note) @@ -132,41 +131,40 @@ func getNote(db *gorm.DB) gin.HandlerFunc { // @Tags notes // @Accept json // @Produce json -// @Param id path int true "便签 ID" example(1) -// @Param note body api.NoteRequest true "便签内容" +// @Param id path int true "便签 ID" example(1) +// @Param note body note.Request true "便签内容" // @Success 200 {object} model.Note -// @Failure 400 {object} api.ErrorResponse "参数无效或 id 无效" -// @Failure 404 {object} api.ErrorResponse "记录不存在" -// @Failure 500 {object} api.ErrorResponse +// @Failure 400 {object} httpx.ErrorResponse "参数无效或 id 无效" +// @Failure 404 {object} httpx.ErrorResponse "记录不存在" +// @Failure 500 {object} httpx.ErrorResponse +// @Security BearerAuth +// @Failure 401 {object} httpx.ErrorResponse "未登录或登录已过期" +// @Failure 403 {object} httpx.ErrorResponse "账号已被禁用" // @Router /notes/{id} [put] -func updateNote(db *gorm.DB) gin.HandlerFunc { +func Update(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { - id, ok := parseID(c) + id, ok := httpx.ParseID(c) if !ok { return } - var req NoteRequest + var req Request if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, ErrorResponse{Error: "参数无效: " + err.Error()}) + c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "参数无效: " + err.Error()}) return } ctx := c.Request.Context() 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, ErrorResponse{Error: "记录不存在"}) - return - } - respondDBError(c, err) + httpx.RespondGetError(c, err) return } note.Title = req.Title note.Content = req.Content if err := db.WithContext(ctx).Save(¬e).Error; err != nil { - respondDBError(c, err) + httpx.RespondDBError(c, err) return } c.JSON(http.StatusOK, note) @@ -179,40 +177,29 @@ func updateNote(db *gorm.DB) gin.HandlerFunc { // @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 +// @Failure 400 {object} httpx.ErrorResponse "id 无效" +// @Failure 404 {object} httpx.ErrorResponse "记录不存在" +// @Failure 500 {object} httpx.ErrorResponse +// @Security BearerAuth +// @Failure 401 {object} httpx.ErrorResponse "未登录或登录已过期" +// @Failure 403 {object} httpx.ErrorResponse "账号已被禁用" // @Router /notes/{id} [delete] -func deleteNote(db *gorm.DB) gin.HandlerFunc { +func Delete(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { - id, ok := parseID(c) + id, ok := httpx.ParseID(c) if !ok { return } result := db.WithContext(c.Request.Context()).Delete(&model.Note{}, id) if result.Error != nil { - respondDBError(c, result.Error) + httpx.RespondDBError(c, result.Error) return } if result.RowsAffected == 0 { - c.JSON(http.StatusNotFound, ErrorResponse{Error: "记录不存在"}) + c.JSON(http.StatusNotFound, httpx.ErrorResponse{Error: "记录不存在"}) return } c.Status(http.StatusNoContent) } } - -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, ErrorResponse{Error: "id 无效"}) - return 0, false - } - return uint(id), true -} - -func respondDBError(c *gin.Context, err error) { - slog.ErrorContext(c.Request.Context(), "数据库操作失败", "err", err, "path", c.Request.URL.Path) - c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "服务器内部错误"}) -} diff --git a/internal/note/note_test.go b/internal/note/note_test.go new file mode 100644 index 0000000..b19c94d --- /dev/null +++ b/internal/note/note_test.go @@ -0,0 +1,103 @@ +package note_test + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "rill/internal/model" + "rill/internal/testutil" +) + +func decodeNote(t *testing.T, w *httptest.ResponseRecorder) model.Note { + t.Helper() + var note model.Note + if err := json.Unmarshal(w.Body.Bytes(), ¬e); err != nil { + t.Fatalf("解析响应失败: %v, body=%s", err, w.Body.String()) + } + return note +} + +func TestNoteCRUD(t *testing.T) { + env := testutil.Setup(t) + r := env.AdminRouter() + + w := testutil.Call(t, r, http.MethodPost, "/api/notes", map[string]string{"title": "第一条", "content": "内容"}) + if w.Code != http.StatusCreated { + t.Fatalf("创建状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusCreated, w.Body.String()) + } + created := decodeNote(t, w) + if created.ID == 0 || created.Title != "第一条" || created.Content != "内容" { + t.Fatalf("创建结果异常: %+v", created) + } + + detailPath := fmt.Sprintf("/api/notes/%d", created.ID) + w = testutil.Call(t, r, http.MethodGet, detailPath, nil) + if w.Code != http.StatusOK { + t.Fatalf("详情状态码 = %d, 期望 %d", w.Code, http.StatusOK) + } + if got := decodeNote(t, w); got.ID != created.ID { + t.Errorf("详情 ID = %d, 期望 %d", got.ID, created.ID) + } + + w = testutil.Call(t, r, http.MethodPut, detailPath, map[string]string{"title": "已更新", "content": "新内容"}) + if w.Code != http.StatusOK { + t.Fatalf("更新状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String()) + } + updated := decodeNote(t, w) + if updated.Title != "已更新" || updated.Content != "新内容" { + t.Errorf("更新结果异常: %+v", updated) + } + + w = testutil.Call(t, r, http.MethodGet, "/api/notes?page=1&page_size=10", nil) + if w.Code != http.StatusOK { + t.Fatalf("列表状态码 = %d, 期望 %d", w.Code, http.StatusOK) + } + var list struct { + Items []model.Note `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + } + if err := json.Unmarshal(w.Body.Bytes(), &list); err != nil { + t.Fatalf("解析列表响应失败: %v", err) + } + if list.Total != 1 || len(list.Items) != 1 { + t.Fatalf("列表结果异常: total=%d, items=%d", list.Total, len(list.Items)) + } + if list.Items[0].Title != "已更新" { + t.Errorf("列表项标题 = %q, 期望 已更新", list.Items[0].Title) + } + + w = testutil.Call(t, r, http.MethodDelete, detailPath, nil) + if w.Code != http.StatusNoContent { + t.Fatalf("删除状态码 = %d, 期望 %d", w.Code, http.StatusNoContent) + } + + w = testutil.Call(t, r, http.MethodGet, detailPath, nil) + if w.Code != http.StatusNotFound { + t.Errorf("删除后详情状态码 = %d, 期望 %d", w.Code, http.StatusNotFound) + } +} + +func TestNoteValidation(t *testing.T) { + env := testutil.Setup(t) + r := env.AdminRouter() + + w := testutil.Call(t, r, http.MethodPost, "/api/notes", map[string]string{"content": "缺少标题"}) + if w.Code != http.StatusBadRequest { + t.Errorf("缺少标题状态码 = %d, 期望 %d", w.Code, http.StatusBadRequest) + } + + w = testutil.Call(t, r, http.MethodGet, "/api/notes/abc", nil) + if w.Code != http.StatusBadRequest { + t.Errorf("非法 id 状态码 = %d, 期望 %d", w.Code, http.StatusBadRequest) + } + + w = testutil.Call(t, r, http.MethodGet, "/api/notes/9999", nil) + if w.Code != http.StatusNotFound { + t.Errorf("不存在记录状态码 = %d, 期望 %d", w.Code, http.StatusNotFound) + } +} diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go new file mode 100644 index 0000000..2978b61 --- /dev/null +++ b/internal/testutil/testutil.go @@ -0,0 +1,129 @@ +// Package testutil 提供 HTTP 接口测试的公共环境:临时数据库、路由与凭证。 +package testutil + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "rill/internal/api" + "rill/internal/auth" + "rill/internal/config" + "rill/internal/database" + "rill/internal/model" +) + +// Env 测试环境:临时数据库、配置、认证器与初始管理员。 +type Env struct { + T *testing.T + DB *gorm.DB + Cfg *config.Config + Authn *auth.Authenticator + Admin model.User +} + +// Setup 创建临时 SQLite 数据库并执行全部迁移。 +func Setup(t *testing.T) *Env { + t.Helper() + gin.SetMode(gin.TestMode) + + cfg := &config.Config{ + Database: config.DatabaseConfig{ + Driver: "sqlite3", + ConnectTimeout: "5s", + SQLite: config.SQLiteConfig{Path: filepath.Join(t.TempDir(), "test.db")}, + }, + Auth: config.AuthConfig{ + Secret: "test-secret", + TokenTTL: "1h", + }, + } + db, err := database.Open(cfg) + if err != nil { + t.Fatalf("打开测试数据库失败: %v", err) + } + t.Cleanup(func() { + if err := database.Close(db); err != nil { + t.Errorf("关闭测试数据库失败: %v", err) + } + }) + + if err := database.Migrate(context.Background(), db); err != nil { + t.Fatalf("执行测试迁移失败: %v", err) + } + + env := &Env{T: t, DB: db, Cfg: cfg, Authn: auth.NewAuthenticator(cfg)} + if err := db.Where("username = ?", "admin").First(&env.Admin).Error; err != nil { + t.Fatalf("查询初始管理员失败: %v", err) + } + return env +} + +// Router 返回注入了 Authorization 头的路由;token 为空表示匿名访问。 +func (e *Env) Router(token string) *gin.Engine { + e.T.Helper() + r := gin.New() + if token != "" { + r.Use(func(c *gin.Context) { + c.Request.Header.Set("Authorization", "Bearer "+token) + c.Next() + }) + } + api.RegisterRoutes(r.Group("/api"), e.DB, e.Cfg) + return r +} + +// Sign 使用测试密钥为指定用户签发凭证。 +func (e *Env) Sign(userID uint) string { + e.T.Helper() + token, _, err := e.Authn.Sign(userID) + if err != nil { + e.T.Fatalf("签发测试凭证失败: %v", err) + } + return token +} + +// AdminRouter 返回以初始管理员身份访问的路由。 +func (e *Env) AdminRouter() *gin.Engine { + e.T.Helper() + return e.Router(e.Sign(e.Admin.ID)) +} + +// Call 发送 JSON 请求并返回响应记录器。 +func Call(t *testing.T, r http.Handler, method, path string, body any) *httptest.ResponseRecorder { + t.Helper() + + var payload []byte + if body != nil { + var err error + payload, err = json.Marshal(body) + if err != nil { + t.Fatalf("序列化请求体失败: %v", err) + } + } + + req := httptest.NewRequest(method, path, bytes.NewReader(payload)) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w +} + +// DecodeUser 解析用户响应。 +func DecodeUser(t *testing.T, w *httptest.ResponseRecorder) model.User { + t.Helper() + var user model.User + if err := json.Unmarshal(w.Body.Bytes(), &user); err != nil { + t.Fatalf("解析用户响应失败: %v, body=%s", err, w.Body.String()) + } + return user +} diff --git a/internal/api/users.go b/internal/user/user.go similarity index 60% rename from internal/api/users.go rename to internal/user/user.go index dddc6af..3391e1f 100644 --- a/internal/api/users.go +++ b/internal/user/user.go @@ -1,22 +1,26 @@ -package api +// Package user 提供用户接口及密码、组成员关系等公共能力。 +package user import ( "context" "errors" - "log/slog" "net/http" "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" + "rill/internal/httpx" "rill/internal/model" ) -const userGroupMembersTable = "user_group_members" +const groupMembersTable = "user_group_members" -// UserCreateRequest 创建用户请求。 -type UserCreateRequest struct { +// ErrGroupsNotFound 用户组不存在。 +var ErrGroupsNotFound = errors.New("用户组不存在") + +// CreateRequest 创建用户请求。 +type CreateRequest 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"` @@ -26,8 +30,8 @@ type UserCreateRequest struct { GroupIDs []uint `json:"group_ids" example:"1"` } -// UserUpdateRequest 更新用户请求,仅更新请求中提供的字段。 -type UserUpdateRequest struct { +// UpdateRequest 更新用户请求,仅更新请求中提供的字段。 +type UpdateRequest 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"` @@ -35,8 +39,8 @@ type UserUpdateRequest struct { GroupIDs *[]uint `json:"group_ids" example:"1"` } -// UserListResponse 用户分页列表响应。 -type UserListResponse struct { +// ListResponse 用户分页列表响应。 +type ListResponse struct { Items []model.User `json:"items"` Total int64 `json:"total" example:"42"` Page int `json:"page" example:"1"` @@ -49,17 +53,20 @@ type UserListResponse struct { // @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 +// @Success 200 {object} user.ListResponse +// @Failure 500 {object} httpx.ErrorResponse +// @Security BearerAuth +// @Failure 401 {object} httpx.ErrorResponse "未登录或登录已过期" +// @Failure 403 {object} httpx.ErrorResponse "需要管理员权限或账号已被禁用" // @Router /users [get] -func listUsers(db *gorm.DB) gin.HandlerFunc { +func List(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { - page, pageSize := parsePagination(c) + page, pageSize := httpx.ParsePagination(c) ctx := c.Request.Context() var total int64 if err := db.WithContext(ctx).Model(&model.User{}).Count(&total).Error; err != nil { - respondDBError(c, err) + httpx.RespondDBError(c, err) return } @@ -69,15 +76,15 @@ func listUsers(db *gorm.DB) gin.HandlerFunc { Offset((page - 1) * pageSize). Limit(pageSize). Find(&users).Error; err != nil { - respondDBError(c, err) + httpx.RespondDBError(c, err) return } - if err := attachUserGroups(ctx, db, users); err != nil { - respondDBError(c, err) + if err := AttachGroups(ctx, db, users); err != nil { + httpx.RespondDBError(c, err) return } - c.JSON(http.StatusOK, UserListResponse{ + c.JSON(http.StatusOK, ListResponse{ Items: users, Total: total, Page: page, @@ -91,24 +98,27 @@ func listUsers(db *gorm.DB) gin.HandlerFunc { // @Tags users // @Accept json // @Produce json -// @Param user body api.UserCreateRequest true "用户信息" +// @Param user body user.CreateRequest true "用户信息" // @Success 201 {object} model.User -// @Failure 400 {object} api.ErrorResponse "参数无效或用户组不存在" -// @Failure 409 {object} api.ErrorResponse "用户名或邮箱已存在" -// @Failure 500 {object} api.ErrorResponse +// @Failure 400 {object} httpx.ErrorResponse "参数无效或用户组不存在" +// @Failure 409 {object} httpx.ErrorResponse "用户名或邮箱已存在" +// @Failure 500 {object} httpx.ErrorResponse +// @Security BearerAuth +// @Failure 401 {object} httpx.ErrorResponse "未登录或登录已过期" +// @Failure 403 {object} httpx.ErrorResponse "需要管理员权限或账号已被禁用" // @Router /users [post] -func createUser(db *gorm.DB) gin.HandlerFunc { +func Create(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { - var req UserCreateRequest + var req CreateRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, ErrorResponse{Error: "参数无效: " + err.Error()}) + c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "参数无效: " + err.Error()}) return } ctx := c.Request.Context() - hash, err := hashPassword(req.Password) + hash, err := HashPassword(req.Password) if err != nil { - respondHashError(c, err) + httpx.RespondServerError(c, err, "生成密码哈希失败") return } @@ -123,11 +133,11 @@ 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, ErrorResponse{Error: errGroupsNotFound.Error()}) + if errors.Is(err, ErrGroupsNotFound) { + c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: ErrGroupsNotFound.Error()}) return } - respondDBError(c, err) + httpx.RespondDBError(c, err) return } @@ -143,10 +153,10 @@ func createUser(db *gorm.DB) gin.HandlerFunc { if err := tx.Create(&user).Error; err != nil { return err } - return replaceUserGroups(tx, user.ID, groupIDs) + return ReplaceGroups(tx, user.ID, groupIDs) }) if err != nil { - respondDuplicateOrDBError(c, err, "用户名或邮箱已存在") + httpx.RespondDuplicateOrDBError(c, err, "用户名或邮箱已存在") return } user.Groups = groups @@ -160,13 +170,16 @@ func createUser(db *gorm.DB) gin.HandlerFunc { // @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 +// @Failure 400 {object} httpx.ErrorResponse "id 无效" +// @Failure 404 {object} httpx.ErrorResponse "记录不存在" +// @Failure 500 {object} httpx.ErrorResponse +// @Security BearerAuth +// @Failure 401 {object} httpx.ErrorResponse "未登录或登录已过期" +// @Failure 403 {object} httpx.ErrorResponse "需要管理员权限或账号已被禁用" // @Router /users/{id} [get] -func getUser(db *gorm.DB) gin.HandlerFunc { +func Get(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { - id, ok := parseID(c) + id, ok := httpx.ParseID(c) if !ok { return } @@ -174,13 +187,13 @@ func getUser(db *gorm.DB) gin.HandlerFunc { ctx := c.Request.Context() var user model.User if err := db.WithContext(ctx).First(&user, id).Error; err != nil { - respondGetError(c, err) + httpx.RespondGetError(c, err) return } - groups, err := loadUserGroups(ctx, db, user.ID) + groups, err := LoadGroups(ctx, db, user.ID) if err != nil { - respondDBError(c, err) + httpx.RespondDBError(c, err) return } user.Groups = groups @@ -193,40 +206,43 @@ func getUser(db *gorm.DB) gin.HandlerFunc { // @Tags users // @Accept json // @Produce json -// @Param id path int true "用户 ID" example(1) -// @Param user body api.UserUpdateRequest true "待更新字段" +// @Param id path int true "用户 ID" example(1) +// @Param user body user.UpdateRequest true "待更新字段" // @Success 200 {object} model.User -// @Failure 400 {object} api.ErrorResponse "参数无效、id 无效或用户组不存在" -// @Failure 404 {object} api.ErrorResponse "记录不存在" -// @Failure 500 {object} api.ErrorResponse +// @Failure 400 {object} httpx.ErrorResponse "参数无效、id 无效或用户组不存在" +// @Failure 404 {object} httpx.ErrorResponse "记录不存在" +// @Failure 500 {object} httpx.ErrorResponse +// @Security BearerAuth +// @Failure 401 {object} httpx.ErrorResponse "未登录或登录已过期" +// @Failure 403 {object} httpx.ErrorResponse "需要管理员权限或账号已被禁用" // @Router /users/{id} [put] -func updateUser(db *gorm.DB) gin.HandlerFunc { +func Update(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { - id, ok := parseID(c) + id, ok := httpx.ParseID(c) if !ok { return } - var req UserUpdateRequest + var req UpdateRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, ErrorResponse{Error: "参数无效: " + err.Error()}) + c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "参数无效: " + err.Error()}) return } ctx := c.Request.Context() var user model.User if err := db.WithContext(ctx).First(&user, id).Error; err != nil { - respondGetError(c, err) + httpx.RespondGetError(c, err) return } if req.GroupIDs != nil { if _, err := findGroups(ctx, db, *req.GroupIDs); err != nil { - if errors.Is(err, errGroupsNotFound) { - c.JSON(http.StatusBadRequest, ErrorResponse{Error: errGroupsNotFound.Error()}) + if errors.Is(err, ErrGroupsNotFound) { + c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: ErrGroupsNotFound.Error()}) return } - respondDBError(c, err) + httpx.RespondDBError(c, err) return } } @@ -239,9 +255,9 @@ func updateUser(db *gorm.DB) gin.HandlerFunc { updates["status"] = *req.Status } if req.Password != "" { - hash, err := hashPassword(req.Password) + hash, err := HashPassword(req.Password) if err != nil { - respondHashError(c, err) + httpx.RespondServerError(c, err, "生成密码哈希失败") return } updates["password_hash"] = string(hash) @@ -252,18 +268,18 @@ func updateUser(db *gorm.DB) gin.HandlerFunc { return err } if req.GroupIDs != nil { - return replaceUserGroups(tx, user.ID, *req.GroupIDs) + return ReplaceGroups(tx, user.ID, *req.GroupIDs) } return nil }) if err != nil { - respondDBError(c, err) + httpx.RespondDBError(c, err) return } - groups, err := loadUserGroups(ctx, db, user.ID) + groups, err := LoadGroups(ctx, db, user.ID) if err != nil { - respondDBError(c, err) + httpx.RespondDBError(c, err) return } @@ -283,13 +299,16 @@ func updateUser(db *gorm.DB) gin.HandlerFunc { // @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 +// @Failure 400 {object} httpx.ErrorResponse "id 无效" +// @Failure 404 {object} httpx.ErrorResponse "记录不存在" +// @Failure 500 {object} httpx.ErrorResponse +// @Security BearerAuth +// @Failure 401 {object} httpx.ErrorResponse "未登录或登录已过期" +// @Failure 403 {object} httpx.ErrorResponse "需要管理员权限或账号已被禁用" // @Router /users/{id} [delete] -func deleteUser(db *gorm.DB) gin.HandlerFunc { +func Delete(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { - id, ok := parseID(c) + id, ok := httpx.ParseID(c) if !ok { return } @@ -297,44 +316,37 @@ func deleteUser(db *gorm.DB) gin.HandlerFunc { ctx := c.Request.Context() var user model.User if err := db.WithContext(ctx).First(&user, id).Error; err != nil { - respondGetError(c, err) + httpx.RespondGetError(c, err) return } err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Exec("DELETE FROM "+userGroupMembersTable+" WHERE user_id = ?", id).Error; err != nil { + if err := tx.Exec("DELETE FROM "+groupMembersTable+" WHERE user_id = ?", id).Error; err != nil { return err } return tx.Delete(&user).Error }) if err != nil { - respondDBError(c, err) + httpx.RespondDBError(c, err) return } c.Status(http.StatusNoContent) } } -// replaceUserGroups 以给定的组 ID 整体替换用户的组成员关系。 -func replaceUserGroups(tx *gorm.DB, userID uint, groupIDs []uint) error { - if err := tx.Exec("DELETE FROM "+userGroupMembersTable+" WHERE user_id = ?", userID).Error; err != nil { - return err +// HashPassword 生成 bcrypt 密码哈希。 +func HashPassword(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", err } - for _, groupID := range dedupeIDs(groupIDs) { - if err := tx.Exec( - "INSERT INTO "+userGroupMembersTable+" (user_id, user_group_id) VALUES (?, ?)", - userID, groupID, - ).Error; err != nil { - return err - } - } - return nil + return string(hash), nil } -// loadUserGroups 查询单个用户所属的用户组。 -func loadUserGroups(ctx context.Context, db *gorm.DB, userID uint) ([]model.UserGroup, error) { +// LoadGroups 查询单个用户所属的用户组。 +func LoadGroups(ctx context.Context, db *gorm.DB, userID uint) ([]model.UserGroup, error) { var groupIDs []uint - if err := db.WithContext(ctx).Table(userGroupMembersTable). + if err := db.WithContext(ctx).Table(groupMembersTable). Where("user_id = ?", userID). Pluck("user_group_id", &groupIDs).Error; err != nil { return nil, err @@ -350,8 +362,8 @@ func loadUserGroups(ctx context.Context, db *gorm.DB, userID uint) ([]model.User return groups, nil } -// attachUserGroups 为一批用户批量填充所属用户组,避免逐条查询。 -func attachUserGroups(ctx context.Context, db *gorm.DB, users []model.User) error { +// AttachGroups 为一批用户批量填充所属用户组,避免逐条查询。 +func AttachGroups(ctx context.Context, db *gorm.DB, users []model.User) error { for i := range users { users[i].Groups = make([]model.UserGroup, 0) } @@ -399,6 +411,22 @@ func attachUserGroups(ctx context.Context, db *gorm.DB, users []model.User) erro return nil } +// ReplaceGroups 以给定的组 ID 整体替换用户的组成员关系,需在事务中调用。 +func ReplaceGroups(tx *gorm.DB, userID uint, groupIDs []uint) error { + if err := tx.Exec("DELETE FROM "+groupMembersTable+" WHERE user_id = ?", userID).Error; err != nil { + return err + } + for _, groupID := range dedupeIDs(groupIDs) { + if err := tx.Exec( + "INSERT INTO "+groupMembersTable+" (user_id, user_group_id) VALUES (?, ?)", + userID, groupID, + ).Error; err != nil { + return err + } + } + return nil +} + // findGroups 查询用户组并校验全部存在。 func findGroups(ctx context.Context, db *gorm.DB, ids []uint) ([]model.UserGroup, error) { unique := dedupeIDs(ids) @@ -407,7 +435,7 @@ func findGroups(ctx context.Context, db *gorm.DB, ids []uint) ([]model.UserGroup return nil, err } if len(groups) != len(unique) { - return nil, errGroupsNotFound + return nil, ErrGroupsNotFound } return groups, nil } @@ -424,16 +452,3 @@ func dedupeIDs(ids []uint) []uint { } return unique } - -func hashPassword(password string) (string, error) { - hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - if err != nil { - return "", err - } - return string(hash), nil -} - -func respondHashError(c *gin.Context, err error) { - slog.ErrorContext(c.Request.Context(), "生成密码哈希失败", "err", err) - c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "服务器内部错误"}) -} diff --git a/internal/api/users_test.go b/internal/user/user_test.go similarity index 78% rename from internal/api/users_test.go rename to internal/user/user_test.go index b1b6b89..156b4e2 100644 --- a/internal/api/users_test.go +++ b/internal/user/user_test.go @@ -1,27 +1,18 @@ -package api +package user_test import ( "encoding/json" "fmt" "net/http" - "net/http/httptest" "strings" "testing" "golang.org/x/crypto/bcrypt" "rill/internal/model" + "rill/internal/testutil" ) -func decodeUser(t *testing.T, w *httptest.ResponseRecorder) model.User { - t.Helper() - var user model.User - if err := json.Unmarshal(w.Body.Bytes(), &user); err != nil { - t.Fatalf("解析响应失败: %v, body=%s", err, w.Body.String()) - } - return user -} - func hasGroup(user model.User, groupID uint) bool { for _, group := range user.Groups { if group.ID == groupID { @@ -32,9 +23,10 @@ func hasGroup(user model.User, groupID uint) bool { } func TestUserCRUD(t *testing.T) { - r, db := setupRouterWithDB(t) + env := testutil.Setup(t) + r := env.AdminRouter() - w := call(t, r, http.MethodPost, "/api/users", map[string]any{ + w := testutil.Call(t, r, http.MethodPost, "/api/users", map[string]any{ "username": "alice", "email": "alice@example.com", "password": "secret123", @@ -45,7 +37,7 @@ func TestUserCRUD(t *testing.T) { if body := w.Body.String(); strings.Contains(body, "password") || strings.Contains(body, "secret123") { t.Fatalf("响应泄露密码信息: %s", body) } - created := decodeUser(t, w) + created := testutil.DecodeUser(t, w) if created.ID == 0 || created.Username != "alice" || created.Status != 1 { t.Fatalf("创建结果异常: %+v", created) } @@ -54,7 +46,7 @@ func TestUserCRUD(t *testing.T) { } var stored model.User - if err := db.First(&stored, created.ID).Error; err != nil { + if err := env.DB.First(&stored, created.ID).Error; err != nil { t.Fatalf("查询数据库失败: %v", err) } if stored.PasswordHash == "secret123" { @@ -65,15 +57,15 @@ func TestUserCRUD(t *testing.T) { } detailPath := fmt.Sprintf("/api/users/%d", created.ID) - w = call(t, r, http.MethodGet, detailPath, nil) + w = testutil.Call(t, r, http.MethodGet, detailPath, nil) if w.Code != http.StatusOK { t.Fatalf("详情状态码 = %d, 期望 %d", w.Code, http.StatusOK) } - if got := decodeUser(t, w); got.ID != created.ID { + if got := testutil.DecodeUser(t, w); got.ID != created.ID { t.Errorf("详情 ID = %d, 期望 %d", got.ID, created.ID) } - w = call(t, r, http.MethodPut, detailPath, map[string]any{ + w = testutil.Call(t, r, http.MethodPut, detailPath, map[string]any{ "nickname": "Alice", "status": 0, "group_ids": []uint{model.GroupIDAdmin}, @@ -81,7 +73,7 @@ func TestUserCRUD(t *testing.T) { if w.Code != http.StatusOK { t.Fatalf("更新状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String()) } - updated := decodeUser(t, w) + updated := testutil.DecodeUser(t, w) if updated.Nickname != "Alice" || updated.Status != 0 { t.Errorf("更新结果异常: %+v", updated) } @@ -89,7 +81,7 @@ func TestUserCRUD(t *testing.T) { t.Errorf("组替换异常: %+v", updated.Groups) } - w = call(t, r, http.MethodGet, "/api/users?page=1&page_size=10", nil) + w = testutil.Call(t, r, http.MethodGet, "/api/users?page=1&page_size=10", nil) if w.Code != http.StatusOK { t.Fatalf("列表状态码 = %d, 期望 %d", w.Code, http.StatusOK) } @@ -109,19 +101,20 @@ func TestUserCRUD(t *testing.T) { t.Errorf("列表未预加载组: %+v", list.Items[0].Groups) } - w = call(t, r, http.MethodDelete, detailPath, nil) + w = testutil.Call(t, r, http.MethodDelete, detailPath, nil) if w.Code != http.StatusNoContent { t.Fatalf("删除状态码 = %d, 期望 %d", w.Code, http.StatusNoContent) } - w = call(t, r, http.MethodGet, detailPath, nil) + w = testutil.Call(t, r, http.MethodGet, detailPath, nil) if w.Code != http.StatusNotFound { t.Errorf("删除后详情状态码 = %d, 期望 %d", w.Code, http.StatusNotFound) } } func TestUserValidation(t *testing.T) { - r := setupRouter(t) + env := testutil.Setup(t) + r := env.AdminRouter() cases := []struct { name string @@ -133,34 +126,34 @@ func TestUserValidation(t *testing.T) { {"组不存在", map[string]any{"username": "a", "email": "a@example.com", "password": "secret123", "group_ids": []uint{99}}}, } for _, tc := range cases { - w := call(t, r, http.MethodPost, "/api/users", tc.body) + w := testutil.Call(t, r, http.MethodPost, "/api/users", tc.body) if w.Code != http.StatusBadRequest { t.Errorf("%s 状态码 = %d, 期望 %d, body=%s", tc.name, w.Code, http.StatusBadRequest, w.Body.String()) } } - w := call(t, r, http.MethodPost, "/api/users", map[string]any{ + w := testutil.Call(t, r, http.MethodPost, "/api/users", map[string]any{ "username": "bob", "email": "bob@example.com", "password": "secret123", }) if w.Code != http.StatusCreated { t.Fatalf("创建用户失败: %d, body=%s", w.Code, w.Body.String()) } - w = call(t, r, http.MethodPost, "/api/users", map[string]any{ + w = testutil.Call(t, r, http.MethodPost, "/api/users", map[string]any{ "username": "bob", "email": "other@example.com", "password": "secret123", }) if w.Code != http.StatusConflict { t.Errorf("重复用户名状态码 = %d, 期望 %d", w.Code, http.StatusConflict) } - w = call(t, r, http.MethodPost, "/api/users", map[string]any{ + w = testutil.Call(t, r, http.MethodPost, "/api/users", map[string]any{ "username": "bob2", "email": "bob@example.com", "password": "secret123", }) if w.Code != http.StatusConflict { t.Errorf("重复邮箱状态码 = %d, 期望 %d", w.Code, http.StatusConflict) } - w = call(t, r, http.MethodGet, "/api/users/abc", nil) + w = testutil.Call(t, r, http.MethodGet, "/api/users/abc", nil) if w.Code != http.StatusBadRequest { t.Errorf("非法 id 状态码 = %d, 期望 %d", w.Code, http.StatusBadRequest) } diff --git a/internal/api/user_groups.go b/internal/usergroup/user_group.go similarity index 55% rename from internal/api/user_groups.go rename to internal/usergroup/user_group.go index e2ea0fa..2499f2b 100644 --- a/internal/api/user_groups.go +++ b/internal/usergroup/user_group.go @@ -1,24 +1,25 @@ -package api +// Package usergroup 提供用户组接口。 +package usergroup import ( - "errors" "net/http" "strconv" "github.com/gin-gonic/gin" "gorm.io/gorm" + "rill/internal/httpx" "rill/internal/model" ) -// UserGroupRequest 创建/更新用户组请求。 -type UserGroupRequest struct { +// Request 创建/更新用户组请求。 +type Request struct { Name string `json:"name" binding:"required,max=50" example:"运营组"` Description string `json:"description" binding:"max=255" example:"负责日常运营"` } -// UserGroupListResponse 用户组分页列表响应。 -type UserGroupListResponse struct { +// ListResponse 用户组分页列表响应。 +type ListResponse struct { Items []model.UserGroup `json:"items"` Total int64 `json:"total" example:"42"` Page int `json:"page" example:"1"` @@ -31,17 +32,20 @@ type UserGroupListResponse struct { // @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 +// @Success 200 {object} usergroup.ListResponse +// @Failure 500 {object} httpx.ErrorResponse +// @Security BearerAuth +// @Failure 401 {object} httpx.ErrorResponse "未登录或登录已过期" +// @Failure 403 {object} httpx.ErrorResponse "需要管理员权限或账号已被禁用" // @Router /user-groups [get] -func listUserGroups(db *gorm.DB) gin.HandlerFunc { +func List(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { - page, pageSize := parsePagination(c) + page, pageSize := httpx.ParsePagination(c) ctx := c.Request.Context() var total int64 if err := db.WithContext(ctx).Model(&model.UserGroup{}).Count(&total).Error; err != nil { - respondDBError(c, err) + httpx.RespondDBError(c, err) return } @@ -51,11 +55,11 @@ func listUserGroups(db *gorm.DB) gin.HandlerFunc { Offset((page - 1) * pageSize). Limit(pageSize). Find(&groups).Error; err != nil { - respondDBError(c, err) + httpx.RespondDBError(c, err) return } - c.JSON(http.StatusOK, UserGroupListResponse{ + c.JSON(http.StatusOK, ListResponse{ Items: groups, Total: total, Page: page, @@ -69,17 +73,20 @@ func listUserGroups(db *gorm.DB) gin.HandlerFunc { // @Tags user-groups // @Accept json // @Produce json -// @Param group body api.UserGroupRequest true "用户组信息" +// @Param group body usergroup.Request true "用户组信息" // @Success 201 {object} model.UserGroup -// @Failure 400 {object} api.ErrorResponse "参数无效" -// @Failure 409 {object} api.ErrorResponse "用户组名称已存在" -// @Failure 500 {object} api.ErrorResponse +// @Failure 400 {object} httpx.ErrorResponse "参数无效" +// @Failure 409 {object} httpx.ErrorResponse "用户组名称已存在" +// @Failure 500 {object} httpx.ErrorResponse +// @Security BearerAuth +// @Failure 401 {object} httpx.ErrorResponse "未登录或登录已过期" +// @Failure 403 {object} httpx.ErrorResponse "需要管理员权限或账号已被禁用" // @Router /user-groups [post] -func createUserGroup(db *gorm.DB) gin.HandlerFunc { +func Create(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { - var req UserGroupRequest + var req Request if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, ErrorResponse{Error: "参数无效: " + err.Error()}) + c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "参数无效: " + err.Error()}) return } @@ -98,7 +105,7 @@ func createUserGroup(db *gorm.DB) gin.HandlerFunc { return tx.Create(&group).Error }) if err != nil { - respondDuplicateOrDBError(c, err, "用户组名称已存在") + httpx.RespondDuplicateOrDBError(c, err, "用户组名称已存在") return } c.JSON(http.StatusCreated, group) @@ -111,11 +118,14 @@ func createUserGroup(db *gorm.DB) gin.HandlerFunc { // @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 +// @Failure 400 {object} httpx.ErrorResponse "id 无效" +// @Failure 404 {object} httpx.ErrorResponse "记录不存在" +// @Failure 500 {object} httpx.ErrorResponse +// @Security BearerAuth +// @Failure 401 {object} httpx.ErrorResponse "未登录或登录已过期" +// @Failure 403 {object} httpx.ErrorResponse "需要管理员权限或账号已被禁用" // @Router /user-groups/{id} [get] -func getUserGroup(db *gorm.DB) gin.HandlerFunc { +func Get(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { id, ok := parseGroupID(c) if !ok { @@ -124,7 +134,7 @@ func getUserGroup(db *gorm.DB) gin.HandlerFunc { var group model.UserGroup if err := db.WithContext(c.Request.Context()).First(&group, id).Error; err != nil { - respondGetError(c, err) + httpx.RespondGetError(c, err) return } c.JSON(http.StatusOK, group) @@ -136,38 +146,41 @@ func getUserGroup(db *gorm.DB) gin.HandlerFunc { // @Tags user-groups // @Accept json // @Produce json -// @Param id path int true "用户组 ID" example(1) -// @Param group body api.UserGroupRequest true "用户组信息" +// @Param id path int true "用户组 ID" example(1) +// @Param group body usergroup.Request 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 +// @Failure 400 {object} httpx.ErrorResponse "参数无效或 id 无效" +// @Failure 404 {object} httpx.ErrorResponse "记录不存在" +// @Failure 409 {object} httpx.ErrorResponse "用户组名称已存在" +// @Failure 500 {object} httpx.ErrorResponse +// @Security BearerAuth +// @Failure 401 {object} httpx.ErrorResponse "未登录或登录已过期" +// @Failure 403 {object} httpx.ErrorResponse "需要管理员权限或账号已被禁用" // @Router /user-groups/{id} [put] -func updateUserGroup(db *gorm.DB) gin.HandlerFunc { +func Update(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { id, ok := parseGroupID(c) if !ok { return } - var req UserGroupRequest + var req Request if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, ErrorResponse{Error: "参数无效: " + err.Error()}) + c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "参数无效: " + err.Error()}) return } ctx := c.Request.Context() var group model.UserGroup if err := db.WithContext(ctx).First(&group, id).Error; err != nil { - respondGetError(c, err) + httpx.RespondGetError(c, err) return } group.Name = req.Name group.Description = req.Description if err := db.WithContext(ctx).Save(&group).Error; err != nil { - respondDuplicateOrDBError(c, err, "用户组名称已存在") + httpx.RespondDuplicateOrDBError(c, err, "用户组名称已存在") return } c.JSON(http.StatusOK, group) @@ -180,12 +193,15 @@ func updateUserGroup(db *gorm.DB) gin.HandlerFunc { // @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 +// @Failure 400 {object} httpx.ErrorResponse "id 无效" +// @Failure 404 {object} httpx.ErrorResponse "记录不存在" +// @Failure 409 {object} httpx.ErrorResponse "系统内置组不可删除或用户组内仍有用户" +// @Failure 500 {object} httpx.ErrorResponse +// @Security BearerAuth +// @Failure 401 {object} httpx.ErrorResponse "未登录或登录已过期" +// @Failure 403 {object} httpx.ErrorResponse "需要管理员权限或账号已被禁用" // @Router /user-groups/{id} [delete] -func deleteUserGroup(db *gorm.DB) gin.HandlerFunc { +func Delete(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { id, ok := parseGroupID(c) if !ok { @@ -195,28 +211,28 @@ func deleteUserGroup(db *gorm.DB) gin.HandlerFunc { ctx := c.Request.Context() var group model.UserGroup if err := db.WithContext(ctx).First(&group, id).Error; err != nil { - respondGetError(c, err) + httpx.RespondGetError(c, err) return } if group.IsSystem { - c.JSON(http.StatusConflict, ErrorResponse{Error: "系统内置组不可删除"}) + c.JSON(http.StatusConflict, httpx.ErrorResponse{Error: "系统内置组不可删除"}) return } var members int64 - if err := db.WithContext(ctx).Table(userGroupMembersTable). + if err := db.WithContext(ctx).Model(&model.UserGroupMember{}). Where("user_group_id = ?", id). Count(&members).Error; err != nil { - respondDBError(c, err) + httpx.RespondDBError(c, err) return } if members > 0 { - c.JSON(http.StatusConflict, ErrorResponse{Error: "用户组内仍有用户,无法删除"}) + c.JSON(http.StatusConflict, httpx.ErrorResponse{Error: "用户组内仍有用户,无法删除"}) return } if err := db.WithContext(ctx).Delete(&group).Error; err != nil { - respondDBError(c, err) + httpx.RespondDBError(c, err) return } c.Status(http.StatusNoContent) @@ -227,10 +243,8 @@ 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, ErrorResponse{Error: "id 无效"}) + c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "id 无效"}) return 0, false } return uint(id), true } - -var errGroupsNotFound = errors.New("用户组不存在") diff --git a/internal/api/user_groups_test.go b/internal/usergroup/user_group_test.go similarity index 70% rename from internal/api/user_groups_test.go rename to internal/usergroup/user_group_test.go index 5ab253c..0e05ee0 100644 --- a/internal/api/user_groups_test.go +++ b/internal/usergroup/user_group_test.go @@ -1,4 +1,4 @@ -package api +package usergroup_test import ( "encoding/json" @@ -8,6 +8,7 @@ import ( "testing" "rill/internal/model" + "rill/internal/testutil" ) func decodeUserGroup(t *testing.T, w *httptest.ResponseRecorder) model.UserGroup { @@ -20,9 +21,10 @@ func decodeUserGroup(t *testing.T, w *httptest.ResponseRecorder) model.UserGroup } func TestUserGroupSeeds(t *testing.T) { - r := setupRouter(t) + env := testutil.Setup(t) + r := env.AdminRouter() - w := call(t, r, http.MethodGet, "/api/user-groups", nil) + w := testutil.Call(t, r, http.MethodGet, "/api/user-groups", nil) if w.Code != http.StatusOK { t.Fatalf("列表状态码 = %d, 期望 %d", w.Code, http.StatusOK) } @@ -46,9 +48,10 @@ func TestUserGroupSeeds(t *testing.T) { } func TestUserGroupCRUD(t *testing.T) { - r := setupRouter(t) + env := testutil.Setup(t) + r := env.AdminRouter() - w := call(t, r, http.MethodPost, "/api/user-groups", map[string]string{"name": "ops", "description": "运维组"}) + w := testutil.Call(t, r, http.MethodPost, "/api/user-groups", map[string]string{"name": "ops", "description": "运维组"}) if w.Code != http.StatusCreated { t.Fatalf("创建状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusCreated, w.Body.String()) } @@ -57,7 +60,7 @@ func TestUserGroupCRUD(t *testing.T) { t.Fatalf("创建结果异常: %+v", created) } - w = call(t, r, http.MethodPost, "/api/user-groups", map[string]string{"name": "dev"}) + w = testutil.Call(t, r, http.MethodPost, "/api/user-groups", map[string]string{"name": "dev"}) if w.Code != http.StatusCreated { t.Fatalf("创建第二个组状态码 = %d, 期望 %d", w.Code, http.StatusCreated) } @@ -66,7 +69,7 @@ func TestUserGroupCRUD(t *testing.T) { } detailPath := fmt.Sprintf("/api/user-groups/%d", created.ID) - w = call(t, r, http.MethodGet, detailPath, nil) + w = testutil.Call(t, r, http.MethodGet, detailPath, nil) if w.Code != http.StatusOK { t.Fatalf("详情状态码 = %d, 期望 %d", w.Code, http.StatusOK) } @@ -74,7 +77,7 @@ func TestUserGroupCRUD(t *testing.T) { t.Errorf("详情 ID = %d, 期望 %d", got.ID, created.ID) } - w = call(t, r, http.MethodPut, detailPath, map[string]string{"name": "ops2", "description": "更新后"}) + w = testutil.Call(t, r, http.MethodPut, detailPath, map[string]string{"name": "ops2", "description": "更新后"}) if w.Code != http.StatusOK { t.Fatalf("更新状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String()) } @@ -82,42 +85,44 @@ func TestUserGroupCRUD(t *testing.T) { t.Errorf("更新结果异常: %+v", updated) } - w = call(t, r, http.MethodDelete, detailPath, nil) + w = testutil.Call(t, r, http.MethodDelete, detailPath, nil) if w.Code != http.StatusNoContent { t.Fatalf("删除状态码 = %d, 期望 %d", w.Code, http.StatusNoContent) } - w = call(t, r, http.MethodGet, detailPath, nil) + w = testutil.Call(t, r, http.MethodGet, detailPath, nil) if w.Code != http.StatusNotFound { t.Errorf("删除后详情状态码 = %d, 期望 %d", w.Code, http.StatusNotFound) } } func TestUserGroupProtected(t *testing.T) { - r := setupRouter(t) + env := testutil.Setup(t) + r := env.AdminRouter() for _, id := range []uint{model.GroupIDAdmin, model.GroupIDUser} { - w := call(t, r, http.MethodDelete, fmt.Sprintf("/api/user-groups/%d", id), nil) + w := testutil.Call(t, r, http.MethodDelete, fmt.Sprintf("/api/user-groups/%d", id), nil) if w.Code != http.StatusConflict { t.Errorf("删除内置组 %d 状态码 = %d, 期望 %d", id, w.Code, http.StatusConflict) } } - w := call(t, r, http.MethodPost, "/api/user-groups", map[string]string{"name": "admin"}) + w := testutil.Call(t, r, http.MethodPost, "/api/user-groups", map[string]string{"name": "admin"}) if w.Code != http.StatusConflict { t.Errorf("重复组名状态码 = %d, 期望 %d", w.Code, http.StatusConflict) } } func TestUserGroupDeleteWithMembers(t *testing.T) { - r := setupRouter(t) + env := testutil.Setup(t) + r := env.AdminRouter() - w := call(t, r, http.MethodPost, "/api/user-groups", map[string]string{"name": "ops"}) + w := testutil.Call(t, r, http.MethodPost, "/api/user-groups", map[string]string{"name": "ops"}) if w.Code != http.StatusCreated { t.Fatalf("创建组失败: %d, body=%s", w.Code, w.Body.String()) } group := decodeUserGroup(t, w) - w = call(t, r, http.MethodPost, "/api/users", map[string]any{ + w = testutil.Call(t, r, http.MethodPost, "/api/users", map[string]any{ "username": "carol", "email": "carol@example.com", "password": "secret123", @@ -126,39 +131,40 @@ func TestUserGroupDeleteWithMembers(t *testing.T) { if w.Code != http.StatusCreated { t.Fatalf("创建用户失败: %d, body=%s", w.Code, w.Body.String()) } - user := decodeUser(t, w) + user := testutil.DecodeUser(t, w) groupPath := fmt.Sprintf("/api/user-groups/%d", group.ID) - w = call(t, r, http.MethodDelete, groupPath, nil) + w = testutil.Call(t, r, http.MethodDelete, groupPath, nil) if w.Code != http.StatusConflict { t.Fatalf("删除有成员的组状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusConflict, w.Body.String()) } - w = call(t, r, http.MethodDelete, fmt.Sprintf("/api/users/%d", user.ID), nil) + w = testutil.Call(t, r, http.MethodDelete, fmt.Sprintf("/api/users/%d", user.ID), nil) if w.Code != http.StatusNoContent { t.Fatalf("删除用户失败: %d", w.Code) } - w = call(t, r, http.MethodDelete, groupPath, nil) + w = testutil.Call(t, r, http.MethodDelete, groupPath, nil) if w.Code != http.StatusNoContent { t.Errorf("成员移除后删除组状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusNoContent, w.Body.String()) } } func TestUserGroupValidation(t *testing.T) { - r := setupRouter(t) + env := testutil.Setup(t) + r := env.AdminRouter() - w := call(t, r, http.MethodPost, "/api/user-groups", map[string]string{"description": "缺少名称"}) + w := testutil.Call(t, r, http.MethodPost, "/api/user-groups", map[string]string{"description": "缺少名称"}) if w.Code != http.StatusBadRequest { t.Errorf("缺少名称状态码 = %d, 期望 %d", w.Code, http.StatusBadRequest) } - w = call(t, r, http.MethodGet, "/api/user-groups/abc", nil) + w = testutil.Call(t, r, http.MethodGet, "/api/user-groups/abc", nil) if w.Code != http.StatusBadRequest { t.Errorf("非法 id 状态码 = %d, 期望 %d", w.Code, http.StatusBadRequest) } - w = call(t, r, http.MethodGet, "/api/user-groups/99", nil) + w = testutil.Call(t, r, http.MethodGet, "/api/user-groups/99", nil) if w.Code != http.StatusNotFound { t.Errorf("不存在组状态码 = %d, 期望 %d", w.Code, http.StatusNotFound) } diff --git a/main.go b/main.go index 710f725..b40ba3a 100644 --- a/main.go +++ b/main.go @@ -31,7 +31,12 @@ import ( // @version 1.0 // @description Rill 服务端 HTTP API 文档,所有接口以配置项 api.prefix(默认 /api)为前缀,请求与响应均为 JSON。 // @description Swagger 页面:{prefix}/swagger/index.html;OpenAPI JSON:{prefix}/swagger/doc.json。 +// @description 除 health、swagger、auth 外,其余接口需要 Bearer JWT:先调用 /auth/login 获取 token,再在请求头携带 Authorization: Bearer {token}。 // @BasePath /api +// @securityDefinitions.apikey BearerAuth +// @in header +// @name Authorization +// @description Bearer JWT,格式:Bearer {token},通过 /auth/login 获取 func main() { configPath := flag.String("c", "data/config.yaml", "配置文件路径(不存在时自动生成)") flag.Parse() @@ -86,7 +91,7 @@ func main() { } // API 路由 - api.RegisterRoutes(r.Group(cfg.API.Prefix), db) + api.RegisterRoutes(r.Group(cfg.API.Prefix), db, cfg) // 静态文件服务 fs := http.FileServer(http.Dir(cfg.Static.Dir))