增加注册登录与鉴权,并按功能拆分 internal 模块

- 新增 /api/auth/register、/api/auth/login,JWT 签发与 Bearer 鉴权中间件
- notes 需登录,users/user-groups 仅管理员;auth 配置项随版本 1→2 自动补全
- internal/api 仅保留路由装配,拆分为 auth/user/usergroup/note/httpx/testutil
- 同步更新 Swagger 文档与前端注册接口路径
This commit is contained in:
2026-09-20 02:03:06 +08:00
parent c17fa0dba2
commit b683fb293e
24 files changed
+3002 -1116

No files matched your search

+646 -213
View File
File diff suppressed because it is too large. Load diff
+646 -213
View File
File diff suppressed because it is too large. Load diff
+442 -168
View File
@@ -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.htmlOpenAPI 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"
+1 -1
View File
@@ -16,7 +16,7 @@ export interface User {
}
export function registerUser(payload: RegisterPayload): Promise<User> {
return request<User>('/users', {
return request<User>('/auth/register', {
method: 'POST',
body: JSON.stringify(payload),
})
+1
View File
@@ -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
+2
View File
@@ -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=
+39 -57
View File
@@ -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 健康检查响应。
+26
View File
@@ -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"])
}
}
-182
View File
@@ -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(), &note); 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)
}
}
+311
View File
@@ -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(&current, 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 != ""
}
+248
View File
@@ -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)
}
}
+6 -1
View File
@@ -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
+18
View File
@@ -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":
+7 -4
View File
@@ -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)
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ import (
)
// ConfigVersion 当前配置结构版本,新增配置项时递增。
const ConfigVersion = 1
const ConfigVersion = 2
// upgradeResult 描述一次配置自动补全的结果。
type upgradeResult struct {
+80
View File
@@ -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 解析路径参数 id0 或非法值返回 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: "未登录或登录已过期"})
}
+60 -73
View File
@@ -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(&notes).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(&note).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(&note, 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(&note, 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(&note).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: "服务器内部错误"})
}
+103
View File
@@ -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(), &note); 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)
}
}
+129
View File
@@ -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
}
+114 -99
View File
@@ -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: "服务器内部错误"})
}
@@ -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)
}
@@ -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("用户组不存在")
@@ -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)
}
+6 -1
View File
@@ -31,7 +31,12 @@ import (
// @version 1.0
// @description Rill 服务端 HTTP API 文档,所有接口以配置项 api.prefix(默认 /api)为前缀,请求与响应均为 JSON。
// @description Swagger 页面:{prefix}/swagger/index.htmlOpenAPI 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))