增加注册登录与鉴权,并按功能拆分 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:
24 files changed
+3002
-1116
No files matched your search
+646
-213
File diff suppressed because it is too large.
Load diff
+646
-213
File diff suppressed because it is too large.
Load diff
+442
-168
@@ -1,11 +1,5 @@
|
||||
basePath: /api
|
||||
definitions:
|
||||
api.ErrorResponse:
|
||||
properties:
|
||||
error:
|
||||
example: 记录不存在
|
||||
type: string
|
||||
type: object
|
||||
api.HealthResponse:
|
||||
properties:
|
||||
error:
|
||||
@@ -15,146 +9,55 @@ definitions:
|
||||
example: ok
|
||||
type: string
|
||||
type: object
|
||||
api.NoteListResponse:
|
||||
auth.LoginRequest:
|
||||
properties:
|
||||
items:
|
||||
items:
|
||||
$ref: '#/definitions/model.Note'
|
||||
type: array
|
||||
page:
|
||||
example: 1
|
||||
type: integer
|
||||
page_size:
|
||||
example: 20
|
||||
type: integer
|
||||
total:
|
||||
example: 42
|
||||
type: integer
|
||||
type: object
|
||||
api.NoteRequest:
|
||||
properties:
|
||||
content:
|
||||
example: 牛奶、鸡蛋
|
||||
account:
|
||||
example: alice
|
||||
type: string
|
||||
title:
|
||||
example: 购物清单
|
||||
maxLength: 200
|
||||
password:
|
||||
example: secret123
|
||||
type: string
|
||||
required:
|
||||
- title
|
||||
- account
|
||||
- password
|
||||
type: object
|
||||
api.UserCreateRequest:
|
||||
auth.LoginResponse:
|
||||
properties:
|
||||
avatar:
|
||||
example: https://example.com/avatar.png
|
||||
maxLength: 255
|
||||
expires_at:
|
||||
example: "2026-09-21T10:00:00+08:00"
|
||||
type: string
|
||||
token:
|
||||
example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
type: string
|
||||
user:
|
||||
$ref: '#/definitions/model.User'
|
||||
type: object
|
||||
auth.RegisterRequest:
|
||||
properties:
|
||||
email:
|
||||
example: alice@example.com
|
||||
maxLength: 255
|
||||
type: string
|
||||
group_ids:
|
||||
example:
|
||||
- 1
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
nickname:
|
||||
example: Alice
|
||||
maxLength: 50
|
||||
type: string
|
||||
password:
|
||||
example: secret123
|
||||
maxLength: 72
|
||||
minLength: 6
|
||||
type: string
|
||||
status:
|
||||
enum:
|
||||
- 0
|
||||
- 1
|
||||
example: 1
|
||||
type: integer
|
||||
username:
|
||||
example: alice
|
||||
maxLength: 50
|
||||
minLength: 3
|
||||
type: string
|
||||
required:
|
||||
- email
|
||||
- password
|
||||
- username
|
||||
type: object
|
||||
api.UserGroupListResponse:
|
||||
httpx.ErrorResponse:
|
||||
properties:
|
||||
items:
|
||||
items:
|
||||
$ref: '#/definitions/model.UserGroup'
|
||||
type: array
|
||||
page:
|
||||
example: 1
|
||||
type: integer
|
||||
page_size:
|
||||
example: 20
|
||||
type: integer
|
||||
total:
|
||||
example: 42
|
||||
type: integer
|
||||
type: object
|
||||
api.UserGroupRequest:
|
||||
properties:
|
||||
description:
|
||||
example: 负责日常运营
|
||||
maxLength: 255
|
||||
error:
|
||||
example: 记录不存在
|
||||
type: string
|
||||
name:
|
||||
example: 运营组
|
||||
maxLength: 50
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
api.UserListResponse:
|
||||
properties:
|
||||
items:
|
||||
items:
|
||||
$ref: '#/definitions/model.User'
|
||||
type: array
|
||||
page:
|
||||
example: 1
|
||||
type: integer
|
||||
page_size:
|
||||
example: 20
|
||||
type: integer
|
||||
total:
|
||||
example: 42
|
||||
type: integer
|
||||
type: object
|
||||
api.UserUpdateRequest:
|
||||
properties:
|
||||
avatar:
|
||||
example: https://example.com/avatar.png
|
||||
maxLength: 255
|
||||
type: string
|
||||
group_ids:
|
||||
example:
|
||||
- 1
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
nickname:
|
||||
example: Alice
|
||||
maxLength: 50
|
||||
type: string
|
||||
password:
|
||||
example: secret123
|
||||
maxLength: 72
|
||||
minLength: 6
|
||||
type: string
|
||||
status:
|
||||
enum:
|
||||
- 0
|
||||
- 1
|
||||
example: 1
|
||||
type: integer
|
||||
type: object
|
||||
model.Note:
|
||||
properties:
|
||||
@@ -207,14 +110,229 @@ definitions:
|
||||
updated_at:
|
||||
type: string
|
||||
type: object
|
||||
note.ListResponse:
|
||||
properties:
|
||||
items:
|
||||
items:
|
||||
$ref: '#/definitions/model.Note'
|
||||
type: array
|
||||
page:
|
||||
example: 1
|
||||
type: integer
|
||||
page_size:
|
||||
example: 20
|
||||
type: integer
|
||||
total:
|
||||
example: 42
|
||||
type: integer
|
||||
type: object
|
||||
note.Request:
|
||||
properties:
|
||||
content:
|
||||
example: 牛奶、鸡蛋
|
||||
type: string
|
||||
title:
|
||||
example: 购物清单
|
||||
maxLength: 200
|
||||
type: string
|
||||
required:
|
||||
- title
|
||||
type: object
|
||||
user.CreateRequest:
|
||||
properties:
|
||||
avatar:
|
||||
example: https://example.com/avatar.png
|
||||
maxLength: 255
|
||||
type: string
|
||||
email:
|
||||
example: alice@example.com
|
||||
maxLength: 255
|
||||
type: string
|
||||
group_ids:
|
||||
example:
|
||||
- 1
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
nickname:
|
||||
example: Alice
|
||||
maxLength: 50
|
||||
type: string
|
||||
password:
|
||||
example: secret123
|
||||
maxLength: 72
|
||||
minLength: 6
|
||||
type: string
|
||||
status:
|
||||
enum:
|
||||
- 0
|
||||
- 1
|
||||
example: 1
|
||||
type: integer
|
||||
username:
|
||||
example: alice
|
||||
maxLength: 50
|
||||
type: string
|
||||
required:
|
||||
- email
|
||||
- password
|
||||
- username
|
||||
type: object
|
||||
user.ListResponse:
|
||||
properties:
|
||||
items:
|
||||
items:
|
||||
$ref: '#/definitions/model.User'
|
||||
type: array
|
||||
page:
|
||||
example: 1
|
||||
type: integer
|
||||
page_size:
|
||||
example: 20
|
||||
type: integer
|
||||
total:
|
||||
example: 42
|
||||
type: integer
|
||||
type: object
|
||||
user.UpdateRequest:
|
||||
properties:
|
||||
avatar:
|
||||
example: https://example.com/avatar.png
|
||||
maxLength: 255
|
||||
type: string
|
||||
group_ids:
|
||||
example:
|
||||
- 1
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
nickname:
|
||||
example: Alice
|
||||
maxLength: 50
|
||||
type: string
|
||||
password:
|
||||
example: secret123
|
||||
maxLength: 72
|
||||
minLength: 6
|
||||
type: string
|
||||
status:
|
||||
enum:
|
||||
- 0
|
||||
- 1
|
||||
example: 1
|
||||
type: integer
|
||||
type: object
|
||||
usergroup.ListResponse:
|
||||
properties:
|
||||
items:
|
||||
items:
|
||||
$ref: '#/definitions/model.UserGroup'
|
||||
type: array
|
||||
page:
|
||||
example: 1
|
||||
type: integer
|
||||
page_size:
|
||||
example: 20
|
||||
type: integer
|
||||
total:
|
||||
example: 42
|
||||
type: integer
|
||||
type: object
|
||||
usergroup.Request:
|
||||
properties:
|
||||
description:
|
||||
example: 负责日常运营
|
||||
maxLength: 255
|
||||
type: string
|
||||
name:
|
||||
example: 运营组
|
||||
maxLength: 50
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
info:
|
||||
contact: {}
|
||||
description: |-
|
||||
Rill 服务端 HTTP API 文档,所有接口以配置项 api.prefix(默认 /api)为前缀,请求与响应均为 JSON。
|
||||
Swagger 页面:{prefix}/swagger/index.html;OpenAPI JSON:{prefix}/swagger/doc.json。
|
||||
除 health、swagger、auth 外,其余接口需要 Bearer JWT:先调用 /auth/login 获取 token,再在请求头携带 Authorization: Bearer {token}。
|
||||
title: Rill API
|
||||
version: "1.0"
|
||||
paths:
|
||||
/auth/login:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: 使用用户名或邮箱登录,成功返回 JWT(有效期见 auth.token_ttl)与用户信息。
|
||||
parameters:
|
||||
- description: 登录凭证
|
||||
in: body
|
||||
name: credentials
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/auth.LoginRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/auth.LoginResponse'
|
||||
"400":
|
||||
description: 参数无效
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"401":
|
||||
description: 账号或密码错误
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"403":
|
||||
description: 账号已被禁用
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
summary: Login
|
||||
tags:
|
||||
- auth
|
||||
/auth/register:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: 公开注册,创建普通用户并默认加入普通用户组(id 1)。username 长度 3-50 且唯一,email 唯一,password
|
||||
长度 6-72。
|
||||
parameters:
|
||||
- description: 注册信息
|
||||
in: body
|
||||
name: user
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/auth.RegisterRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"201":
|
||||
description: Created
|
||||
schema:
|
||||
$ref: '#/definitions/model.User'
|
||||
"400":
|
||||
description: 参数无效
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"409":
|
||||
description: 用户名或邮箱已存在
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
summary: Register
|
||||
tags:
|
||||
- auth
|
||||
/health:
|
||||
get:
|
||||
description: 检查服务与数据库连接状态;数据库不可用时返回 503。
|
||||
@@ -252,11 +370,21 @@ paths:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/api.NoteListResponse'
|
||||
$ref: '#/definitions/note.ListResponse'
|
||||
"401":
|
||||
description: 未登录或登录已过期
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"403":
|
||||
description: 账号已被禁用
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/api.ErrorResponse'
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: List notes
|
||||
tags:
|
||||
- notes
|
||||
@@ -270,7 +398,7 @@ paths:
|
||||
name: note
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/api.NoteRequest'
|
||||
$ref: '#/definitions/note.Request'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -281,11 +409,21 @@ paths:
|
||||
"400":
|
||||
description: 参数无效
|
||||
schema:
|
||||
$ref: '#/definitions/api.ErrorResponse'
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"401":
|
||||
description: 未登录或登录已过期
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"403":
|
||||
description: 账号已被禁用
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/api.ErrorResponse'
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Create a note
|
||||
tags:
|
||||
- notes
|
||||
@@ -307,15 +445,25 @@ paths:
|
||||
"400":
|
||||
description: id 无效
|
||||
schema:
|
||||
$ref: '#/definitions/api.ErrorResponse'
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"401":
|
||||
description: 未登录或登录已过期
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"403":
|
||||
description: 账号已被禁用
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"404":
|
||||
description: 记录不存在
|
||||
schema:
|
||||
$ref: '#/definitions/api.ErrorResponse'
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/api.ErrorResponse'
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Delete a note
|
||||
tags:
|
||||
- notes
|
||||
@@ -338,15 +486,25 @@ paths:
|
||||
"400":
|
||||
description: id 无效
|
||||
schema:
|
||||
$ref: '#/definitions/api.ErrorResponse'
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"401":
|
||||
description: 未登录或登录已过期
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"403":
|
||||
description: 账号已被禁用
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"404":
|
||||
description: 记录不存在
|
||||
schema:
|
||||
$ref: '#/definitions/api.ErrorResponse'
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/api.ErrorResponse'
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Get a note
|
||||
tags:
|
||||
- notes
|
||||
@@ -366,7 +524,7 @@ paths:
|
||||
name: note
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/api.NoteRequest'
|
||||
$ref: '#/definitions/note.Request'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -377,15 +535,25 @@ paths:
|
||||
"400":
|
||||
description: 参数无效或 id 无效
|
||||
schema:
|
||||
$ref: '#/definitions/api.ErrorResponse'
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"401":
|
||||
description: 未登录或登录已过期
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"403":
|
||||
description: 账号已被禁用
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"404":
|
||||
description: 记录不存在
|
||||
schema:
|
||||
$ref: '#/definitions/api.ErrorResponse'
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/api.ErrorResponse'
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Update a note
|
||||
tags:
|
||||
- notes
|
||||
@@ -409,11 +577,21 @@ paths:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/api.UserGroupListResponse'
|
||||
$ref: '#/definitions/usergroup.ListResponse'
|
||||
"401":
|
||||
description: 未登录或登录已过期
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"403":
|
||||
description: 需要管理员权限或账号已被禁用
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/api.ErrorResponse'
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: List user groups
|
||||
tags:
|
||||
- user-groups
|
||||
@@ -427,7 +605,7 @@ paths:
|
||||
name: group
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/api.UserGroupRequest'
|
||||
$ref: '#/definitions/usergroup.Request'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -438,15 +616,25 @@ paths:
|
||||
"400":
|
||||
description: 参数无效
|
||||
schema:
|
||||
$ref: '#/definitions/api.ErrorResponse'
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"401":
|
||||
description: 未登录或登录已过期
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"403":
|
||||
description: 需要管理员权限或账号已被禁用
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"409":
|
||||
description: 用户组名称已存在
|
||||
schema:
|
||||
$ref: '#/definitions/api.ErrorResponse'
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/api.ErrorResponse'
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Create a user group
|
||||
tags:
|
||||
- user-groups
|
||||
@@ -468,19 +656,29 @@ paths:
|
||||
"400":
|
||||
description: id 无效
|
||||
schema:
|
||||
$ref: '#/definitions/api.ErrorResponse'
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"401":
|
||||
description: 未登录或登录已过期
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"403":
|
||||
description: 需要管理员权限或账号已被禁用
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"404":
|
||||
description: 记录不存在
|
||||
schema:
|
||||
$ref: '#/definitions/api.ErrorResponse'
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"409":
|
||||
description: 系统内置组不可删除或用户组内仍有用户
|
||||
schema:
|
||||
$ref: '#/definitions/api.ErrorResponse'
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/api.ErrorResponse'
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Delete a user group
|
||||
tags:
|
||||
- user-groups
|
||||
@@ -503,15 +701,25 @@ paths:
|
||||
"400":
|
||||
description: id 无效
|
||||
schema:
|
||||
$ref: '#/definitions/api.ErrorResponse'
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"401":
|
||||
description: 未登录或登录已过期
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"403":
|
||||
description: 需要管理员权限或账号已被禁用
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"404":
|
||||
description: 记录不存在
|
||||
schema:
|
||||
$ref: '#/definitions/api.ErrorResponse'
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/api.ErrorResponse'
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Get a user group
|
||||
tags:
|
||||
- user-groups
|
||||
@@ -531,7 +739,7 @@ paths:
|
||||
name: group
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/api.UserGroupRequest'
|
||||
$ref: '#/definitions/usergroup.Request'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -542,19 +750,29 @@ paths:
|
||||
"400":
|
||||
description: 参数无效或 id 无效
|
||||
schema:
|
||||
$ref: '#/definitions/api.ErrorResponse'
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"401":
|
||||
description: 未登录或登录已过期
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"403":
|
||||
description: 需要管理员权限或账号已被禁用
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"404":
|
||||
description: 记录不存在
|
||||
schema:
|
||||
$ref: '#/definitions/api.ErrorResponse'
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"409":
|
||||
description: 用户组名称已存在
|
||||
schema:
|
||||
$ref: '#/definitions/api.ErrorResponse'
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/api.ErrorResponse'
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Update a user group
|
||||
tags:
|
||||
- user-groups
|
||||
@@ -578,11 +796,21 @@ paths:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/api.UserListResponse'
|
||||
$ref: '#/definitions/user.ListResponse'
|
||||
"401":
|
||||
description: 未登录或登录已过期
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"403":
|
||||
description: 需要管理员权限或账号已被禁用
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/api.ErrorResponse'
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: List users
|
||||
tags:
|
||||
- users
|
||||
@@ -597,7 +825,7 @@ paths:
|
||||
name: user
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/api.UserCreateRequest'
|
||||
$ref: '#/definitions/user.CreateRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -608,15 +836,25 @@ paths:
|
||||
"400":
|
||||
description: 参数无效或用户组不存在
|
||||
schema:
|
||||
$ref: '#/definitions/api.ErrorResponse'
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"401":
|
||||
description: 未登录或登录已过期
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"403":
|
||||
description: 需要管理员权限或账号已被禁用
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"409":
|
||||
description: 用户名或邮箱已存在
|
||||
schema:
|
||||
$ref: '#/definitions/api.ErrorResponse'
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/api.ErrorResponse'
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Create a user
|
||||
tags:
|
||||
- users
|
||||
@@ -638,15 +876,25 @@ paths:
|
||||
"400":
|
||||
description: id 无效
|
||||
schema:
|
||||
$ref: '#/definitions/api.ErrorResponse'
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"401":
|
||||
description: 未登录或登录已过期
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"403":
|
||||
description: 需要管理员权限或账号已被禁用
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"404":
|
||||
description: 记录不存在
|
||||
schema:
|
||||
$ref: '#/definitions/api.ErrorResponse'
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/api.ErrorResponse'
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Delete a user
|
||||
tags:
|
||||
- users
|
||||
@@ -669,15 +917,25 @@ paths:
|
||||
"400":
|
||||
description: id 无效
|
||||
schema:
|
||||
$ref: '#/definitions/api.ErrorResponse'
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"401":
|
||||
description: 未登录或登录已过期
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"403":
|
||||
description: 需要管理员权限或账号已被禁用
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"404":
|
||||
description: 记录不存在
|
||||
schema:
|
||||
$ref: '#/definitions/api.ErrorResponse'
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/api.ErrorResponse'
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Get a user
|
||||
tags:
|
||||
- users
|
||||
@@ -697,7 +955,7 @@ paths:
|
||||
name: user
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/api.UserUpdateRequest'
|
||||
$ref: '#/definitions/user.UpdateRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -708,16 +966,32 @@ paths:
|
||||
"400":
|
||||
description: 参数无效、id 无效或用户组不存在
|
||||
schema:
|
||||
$ref: '#/definitions/api.ErrorResponse'
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"401":
|
||||
description: 未登录或登录已过期
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"403":
|
||||
description: 需要管理员权限或账号已被禁用
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"404":
|
||||
description: 记录不存在
|
||||
schema:
|
||||
$ref: '#/definitions/api.ErrorResponse'
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/api.ErrorResponse'
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Update a user
|
||||
tags:
|
||||
- users
|
||||
securityDefinitions:
|
||||
BearerAuth:
|
||||
description: Bearer JWT,格式:Bearer {token},通过 /auth/login 获取
|
||||
in: header
|
||||
name: Authorization
|
||||
type: apiKey
|
||||
swagger: "2.0"
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 健康检查响应。
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"rill/internal/config"
|
||||
"rill/internal/database"
|
||||
"rill/internal/model"
|
||||
)
|
||||
|
||||
func setupRouter(t *testing.T) *gin.Engine {
|
||||
t.Helper()
|
||||
r, _ := setupRouterWithDB(t)
|
||||
return r
|
||||
}
|
||||
|
||||
func setupRouterWithDB(t *testing.T) (*gin.Engine, *gorm.DB) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
cfg := &config.Config{
|
||||
Database: config.DatabaseConfig{
|
||||
Driver: "sqlite3",
|
||||
ConnectTimeout: "5s",
|
||||
SQLite: config.SQLiteConfig{Path: filepath.Join(t.TempDir(), "test.db")},
|
||||
},
|
||||
}
|
||||
db, err := database.Open(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("打开测试数据库失败: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := database.Close(db); err != nil {
|
||||
t.Errorf("关闭测试数据库失败: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
if err := database.Migrate(context.Background(), db); err != nil {
|
||||
t.Fatalf("执行测试迁移失败: %v", err)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
RegisterRoutes(r.Group("/api"), db)
|
||||
return r, db
|
||||
}
|
||||
|
||||
func call(t *testing.T, r http.Handler, method, path string, body any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
var payload []byte
|
||||
if body != nil {
|
||||
var err error
|
||||
payload, err = json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("序列化请求体失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(method, path, bytes.NewReader(payload))
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func decodeNote(t *testing.T, w *httptest.ResponseRecorder) model.Note {
|
||||
t.Helper()
|
||||
var note model.Note
|
||||
if err := json.Unmarshal(w.Body.Bytes(), ¬e); err != nil {
|
||||
t.Fatalf("解析响应失败: %v, body=%s", err, w.Body.String())
|
||||
}
|
||||
return note
|
||||
}
|
||||
|
||||
func TestHealth(t *testing.T) {
|
||||
r := setupRouter(t)
|
||||
|
||||
w := call(t, r, http.MethodGet, "/api/health", nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
if resp["status"] != "ok" {
|
||||
t.Errorf("status = %q, 期望 ok", resp["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteCRUD(t *testing.T) {
|
||||
r := setupRouter(t)
|
||||
|
||||
w := call(t, r, http.MethodPost, "/api/notes", map[string]string{"title": "第一条", "content": "内容"})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("创建状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusCreated, w.Body.String())
|
||||
}
|
||||
created := decodeNote(t, w)
|
||||
if created.ID == 0 || created.Title != "第一条" || created.Content != "内容" {
|
||||
t.Fatalf("创建结果异常: %+v", created)
|
||||
}
|
||||
|
||||
detailPath := fmt.Sprintf("/api/notes/%d", created.ID)
|
||||
w = call(t, r, http.MethodGet, detailPath, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("详情状态码 = %d, 期望 %d", w.Code, http.StatusOK)
|
||||
}
|
||||
if got := decodeNote(t, w); got.ID != created.ID {
|
||||
t.Errorf("详情 ID = %d, 期望 %d", got.ID, created.ID)
|
||||
}
|
||||
|
||||
w = call(t, r, http.MethodPut, detailPath, map[string]string{"title": "已更新", "content": "新内容"})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("更新状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String())
|
||||
}
|
||||
updated := decodeNote(t, w)
|
||||
if updated.Title != "已更新" || updated.Content != "新内容" {
|
||||
t.Errorf("更新结果异常: %+v", updated)
|
||||
}
|
||||
|
||||
w = call(t, r, http.MethodGet, "/api/notes?page=1&page_size=10", nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("列表状态码 = %d, 期望 %d", w.Code, http.StatusOK)
|
||||
}
|
||||
var list struct {
|
||||
Items []model.Note `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &list); err != nil {
|
||||
t.Fatalf("解析列表响应失败: %v", err)
|
||||
}
|
||||
if list.Total != 1 || len(list.Items) != 1 {
|
||||
t.Fatalf("列表结果异常: total=%d, items=%d", list.Total, len(list.Items))
|
||||
}
|
||||
if list.Items[0].Title != "已更新" {
|
||||
t.Errorf("列表项标题 = %q, 期望 已更新", list.Items[0].Title)
|
||||
}
|
||||
|
||||
w = call(t, r, http.MethodDelete, detailPath, nil)
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("删除状态码 = %d, 期望 %d", w.Code, http.StatusNoContent)
|
||||
}
|
||||
|
||||
w = call(t, r, http.MethodGet, detailPath, nil)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("删除后详情状态码 = %d, 期望 %d", w.Code, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteValidation(t *testing.T) {
|
||||
r := setupRouter(t)
|
||||
|
||||
w := call(t, r, http.MethodPost, "/api/notes", map[string]string{"content": "缺少标题"})
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("缺少标题状态码 = %d, 期望 %d", w.Code, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
w = call(t, r, http.MethodGet, "/api/notes/abc", nil)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("非法 id 状态码 = %d, 期望 %d", w.Code, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
w = call(t, r, http.MethodGet, "/api/notes/9999", nil)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("不存在记录状态码 = %d, 期望 %d", w.Code, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
// Package auth 提供注册、登录、JWT 签发与鉴权中间件。
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"rill/internal/config"
|
||||
"rill/internal/httpx"
|
||||
"rill/internal/model"
|
||||
"rill/internal/user"
|
||||
)
|
||||
|
||||
const (
|
||||
tokenIssuer = "rill"
|
||||
authUserKey = "auth_user"
|
||||
defaultTokenTTL = 24 * time.Hour
|
||||
bearerPrefix = "Bearer "
|
||||
)
|
||||
|
||||
// dummyPasswordHash 账号不存在时仍执行一次 bcrypt 比对,避免通过响应时间枚举账号。
|
||||
var dummyPasswordHash, _ = bcrypt.GenerateFromPassword([]byte("rill-dummy-password"), bcrypt.DefaultCost)
|
||||
|
||||
// RegisterRequest 注册请求。
|
||||
type RegisterRequest struct {
|
||||
Username string `json:"username" binding:"required,min=3,max=50" example:"alice"`
|
||||
Email string `json:"email" binding:"required,email,max=255" example:"alice@example.com"`
|
||||
Password string `json:"password" binding:"required,min=6,max=72" example:"secret123"`
|
||||
}
|
||||
|
||||
// LoginRequest 登录请求,account 可填用户名或邮箱。
|
||||
type LoginRequest struct {
|
||||
Account string `json:"account" binding:"required" example:"alice"`
|
||||
Password string `json:"password" binding:"required" example:"secret123"`
|
||||
}
|
||||
|
||||
// LoginResponse 登录成功响应。
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."`
|
||||
ExpiresAt time.Time `json:"expires_at" example:"2026-09-21T10:00:00+08:00"`
|
||||
User model.User `json:"user"`
|
||||
}
|
||||
|
||||
// @Summary Register
|
||||
// @Description 公开注册,创建普通用户并默认加入普通用户组(id 1)。username 长度 3-50 且唯一,email 唯一,password 长度 6-72。
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param user body auth.RegisterRequest true "注册信息"
|
||||
// @Success 201 {object} model.User
|
||||
// @Failure 400 {object} httpx.ErrorResponse "参数无效"
|
||||
// @Failure 409 {object} httpx.ErrorResponse "用户名或邮箱已存在"
|
||||
// @Failure 500 {object} httpx.ErrorResponse
|
||||
// @Router /auth/register [post]
|
||||
func Register(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req RegisterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "参数无效: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
hash, err := user.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
httpx.RespondServerError(c, err, "生成密码哈希失败")
|
||||
return
|
||||
}
|
||||
|
||||
newUser := model.User{
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
PasswordHash: string(hash),
|
||||
Status: 1,
|
||||
}
|
||||
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&newUser).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return user.ReplaceGroups(tx, newUser.ID, []uint{model.GroupIDUser})
|
||||
})
|
||||
if err != nil {
|
||||
httpx.RespondDuplicateOrDBError(c, err, "用户名或邮箱已存在")
|
||||
return
|
||||
}
|
||||
|
||||
groups, err := user.LoadGroups(ctx, db, newUser.ID)
|
||||
if err != nil {
|
||||
httpx.RespondDBError(c, err)
|
||||
return
|
||||
}
|
||||
newUser.Groups = groups
|
||||
c.JSON(http.StatusCreated, newUser)
|
||||
}
|
||||
}
|
||||
|
||||
// @Summary Login
|
||||
// @Description 使用用户名或邮箱登录,成功返回 JWT(有效期见 auth.token_ttl)与用户信息。
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param credentials body auth.LoginRequest true "登录凭证"
|
||||
// @Success 200 {object} auth.LoginResponse
|
||||
// @Failure 400 {object} httpx.ErrorResponse "参数无效"
|
||||
// @Failure 401 {object} httpx.ErrorResponse "账号或密码错误"
|
||||
// @Failure 403 {object} httpx.ErrorResponse "账号已被禁用"
|
||||
// @Failure 500 {object} httpx.ErrorResponse
|
||||
// @Router /auth/login [post]
|
||||
func Login(db *gorm.DB, authn *Authenticator) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "参数无效: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
account := strings.TrimSpace(req.Account)
|
||||
var loginUser model.User
|
||||
err := db.WithContext(ctx).
|
||||
Where("username = ? OR email = ?", account, account).
|
||||
First(&loginUser).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
_ = bcrypt.CompareHashAndPassword(dummyPasswordHash, []byte(req.Password))
|
||||
c.JSON(http.StatusUnauthorized, httpx.ErrorResponse{Error: "账号或密码错误"})
|
||||
return
|
||||
}
|
||||
httpx.RespondDBError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(loginUser.PasswordHash), []byte(req.Password)); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, httpx.ErrorResponse{Error: "账号或密码错误"})
|
||||
return
|
||||
}
|
||||
if loginUser.Status != 1 {
|
||||
c.JSON(http.StatusForbidden, httpx.ErrorResponse{Error: "账号已被禁用"})
|
||||
return
|
||||
}
|
||||
|
||||
groups, err := user.LoadGroups(ctx, db, loginUser.ID)
|
||||
if err != nil {
|
||||
httpx.RespondDBError(c, err)
|
||||
return
|
||||
}
|
||||
loginUser.Groups = groups
|
||||
|
||||
token, expiresAt, err := authn.Sign(loginUser.ID)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "签发登录凭证失败", "err", err)
|
||||
c.JSON(http.StatusInternalServerError, httpx.ErrorResponse{Error: "服务器内部错误"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, LoginResponse{Token: token, ExpiresAt: expiresAt, User: loginUser})
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticator 负责 JWT 的签发与校验。
|
||||
type Authenticator struct {
|
||||
secret []byte
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
// NewAuthenticator 依据配置构造认证器;secret 留空时生成临时密钥并告警。
|
||||
func NewAuthenticator(cfg *config.Config) *Authenticator {
|
||||
secret := strings.TrimSpace(cfg.Auth.Secret)
|
||||
if secret == "" {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
slog.Error("生成临时 JWT 密钥失败", "err", err)
|
||||
}
|
||||
secret = hex.EncodeToString(buf)
|
||||
slog.Warn("auth.secret 未配置,已生成临时密钥,重启后登录状态将失效")
|
||||
}
|
||||
|
||||
ttl := cfg.TokenTTLDuration()
|
||||
if ttl <= 0 {
|
||||
ttl = defaultTokenTTL
|
||||
}
|
||||
return &Authenticator{secret: []byte(secret), ttl: ttl}
|
||||
}
|
||||
|
||||
// Sign 为用户签发登录凭证,返回 token 与过期时间。
|
||||
func (a *Authenticator) Sign(userID uint) (string, time.Time, error) {
|
||||
now := time.Now()
|
||||
expiresAt := now.Add(a.ttl)
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{
|
||||
Issuer: tokenIssuer,
|
||||
Subject: strconv.FormatUint(uint64(userID), 10),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(expiresAt),
|
||||
})
|
||||
signed, err := token.SignedString(a.secret)
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
return signed, expiresAt, nil
|
||||
}
|
||||
|
||||
// Parse 校验登录凭证并返回用户 ID。
|
||||
func (a *Authenticator) Parse(tokenString string) (uint, error) {
|
||||
claims := &jwt.RegisteredClaims{}
|
||||
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("非预期的签名算法: %v", t.Header["alg"])
|
||||
}
|
||||
return a.secret, nil
|
||||
},
|
||||
jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
|
||||
jwt.WithIssuer(tokenIssuer),
|
||||
jwt.WithExpirationRequired(),
|
||||
)
|
||||
if err != nil || !token.Valid {
|
||||
return 0, errors.New("登录凭证无效")
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(claims.Subject, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return 0, errors.New("登录凭证无效")
|
||||
}
|
||||
return uint(id), nil
|
||||
}
|
||||
|
||||
// RequireAuth 校验 Bearer 凭证并加载当前用户到上下文。
|
||||
func (a *Authenticator) RequireAuth(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token, ok := bearerToken(c)
|
||||
if !ok {
|
||||
httpx.RespondUnauthorized(c)
|
||||
return
|
||||
}
|
||||
userID, err := a.Parse(token)
|
||||
if err != nil {
|
||||
httpx.RespondUnauthorized(c)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
var current model.User
|
||||
if err := db.WithContext(ctx).First(¤t, userID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
httpx.RespondUnauthorized(c)
|
||||
return
|
||||
}
|
||||
httpx.RespondDBError(c, err)
|
||||
return
|
||||
}
|
||||
if current.Status != 1 {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, httpx.ErrorResponse{Error: "账号已被禁用"})
|
||||
return
|
||||
}
|
||||
|
||||
groups, err := user.LoadGroups(ctx, db, current.ID)
|
||||
if err != nil {
|
||||
httpx.RespondDBError(c, err)
|
||||
return
|
||||
}
|
||||
current.Groups = groups
|
||||
c.Set(authUserKey, current)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAdmin 要求当前用户属于 admin 组,需在 RequireAuth 之后使用。
|
||||
func RequireAdmin() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
current, ok := currentUser(c)
|
||||
if !ok {
|
||||
httpx.RespondUnauthorized(c)
|
||||
return
|
||||
}
|
||||
for _, group := range current.Groups {
|
||||
if group.ID == model.GroupIDAdmin {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, httpx.ErrorResponse{Error: "需要管理员权限"})
|
||||
}
|
||||
}
|
||||
|
||||
func currentUser(c *gin.Context) (model.User, bool) {
|
||||
value, ok := c.Get(authUserKey)
|
||||
if !ok {
|
||||
return model.User{}, false
|
||||
}
|
||||
current, ok := value.(model.User)
|
||||
return current, ok
|
||||
}
|
||||
|
||||
func bearerToken(c *gin.Context) (string, bool) {
|
||||
header := c.GetHeader("Authorization")
|
||||
if len(header) <= len(bearerPrefix) || !strings.EqualFold(header[:len(bearerPrefix)], bearerPrefix) {
|
||||
return "", false
|
||||
}
|
||||
token := strings.TrimSpace(header[len(bearerPrefix):])
|
||||
return token, token != ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
)
|
||||
|
||||
// ConfigVersion 当前配置结构版本,新增配置项时递增。
|
||||
const ConfigVersion = 1
|
||||
const ConfigVersion = 2
|
||||
|
||||
// upgradeResult 描述一次配置自动补全的结果。
|
||||
type upgradeResult struct {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// Package httpx 提供 HTTP 接口的公共辅助:统一错误响应、分页参数与路径参数解析。
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPageSize = 20
|
||||
maxPageSize = 100
|
||||
)
|
||||
|
||||
// ErrorResponse 统一错误响应。
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error" example:"记录不存在"`
|
||||
}
|
||||
|
||||
// ParsePagination 解析 page/page_size,非法值回落默认。
|
||||
func ParsePagination(c *gin.Context) (int, int) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", strconv.Itoa(defaultPageSize)))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > maxPageSize {
|
||||
pageSize = defaultPageSize
|
||||
}
|
||||
return page, pageSize
|
||||
}
|
||||
|
||||
// ParseID 解析路径参数 id,0 或非法值返回 false 并写入 400。
|
||||
func ParseID(c *gin.Context) (uint, bool) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "id 无效"})
|
||||
return 0, false
|
||||
}
|
||||
return uint(id), true
|
||||
}
|
||||
|
||||
// RespondDBError 记录数据库错误并返回 500。
|
||||
func RespondDBError(c *gin.Context, err error) {
|
||||
slog.ErrorContext(c.Request.Context(), "数据库操作失败", "err", err, "path", c.Request.URL.Path)
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "服务器内部错误"})
|
||||
}
|
||||
|
||||
// RespondServerError 记录业务错误并返回 500。
|
||||
func RespondServerError(c *gin.Context, err error, msg string) {
|
||||
slog.ErrorContext(c.Request.Context(), msg, "err", err, "path", c.Request.URL.Path)
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "服务器内部错误"})
|
||||
}
|
||||
|
||||
// RespondGetError 查询类错误:记录不存在返回 404,其余按数据库错误处理。
|
||||
func RespondGetError(c *gin.Context, err error) {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, ErrorResponse{Error: "记录不存在"})
|
||||
return
|
||||
}
|
||||
RespondDBError(c, err)
|
||||
}
|
||||
|
||||
// RespondDuplicateOrDBError 写入类错误:唯一约束冲突返回 409,其余按数据库错误处理。
|
||||
func RespondDuplicateOrDBError(c *gin.Context, err error, duplicateMsg string) {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
c.JSON(http.StatusConflict, ErrorResponse{Error: duplicateMsg})
|
||||
return
|
||||
}
|
||||
RespondDBError(c, err)
|
||||
}
|
||||
|
||||
// RespondUnauthorized 中止请求并返回 401。
|
||||
func RespondUnauthorized(c *gin.Context) {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, ErrorResponse{Error: "未登录或登录已过期"})
|
||||
}
|
||||
@@ -1,30 +1,24 @@
|
||||
package api
|
||||
// Package note 提供便签接口。
|
||||
package note
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"rill/internal/httpx"
|
||||
"rill/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPageSize = 20
|
||||
maxPageSize = 100
|
||||
)
|
||||
|
||||
// NoteRequest 创建/更新便签请求。
|
||||
type NoteRequest struct {
|
||||
// Request 创建/更新便签请求。
|
||||
type Request struct {
|
||||
Title string `json:"title" binding:"required,max=200" example:"购物清单"`
|
||||
Content string `json:"content" example:"牛奶、鸡蛋"`
|
||||
}
|
||||
|
||||
// NoteListResponse 便签分页列表响应。
|
||||
type NoteListResponse struct {
|
||||
// ListResponse 便签分页列表响应。
|
||||
type ListResponse struct {
|
||||
Items []model.Note `json:"items"`
|
||||
Total int64 `json:"total" example:"42"`
|
||||
Page int `json:"page" example:"1"`
|
||||
@@ -37,17 +31,20 @@ type NoteListResponse struct {
|
||||
// @Produce json
|
||||
// @Param page query int false "页码,默认 1" example(1)
|
||||
// @Param page_size query int false "每页数量,默认 20,最大 100" example(20)
|
||||
// @Success 200 {object} api.NoteListResponse
|
||||
// @Failure 500 {object} api.ErrorResponse
|
||||
// @Success 200 {object} note.ListResponse
|
||||
// @Failure 500 {object} httpx.ErrorResponse
|
||||
// @Security BearerAuth
|
||||
// @Failure 401 {object} httpx.ErrorResponse "未登录或登录已过期"
|
||||
// @Failure 403 {object} httpx.ErrorResponse "账号已被禁用"
|
||||
// @Router /notes [get]
|
||||
func listNotes(db *gorm.DB) gin.HandlerFunc {
|
||||
func List(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
page, pageSize := parsePagination(c)
|
||||
page, pageSize := httpx.ParsePagination(c)
|
||||
|
||||
ctx := c.Request.Context()
|
||||
var total int64
|
||||
if err := db.WithContext(ctx).Model(&model.Note{}).Count(&total).Error; err != nil {
|
||||
respondDBError(c, err)
|
||||
httpx.RespondDBError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -57,11 +54,11 @@ func listNotes(db *gorm.DB) gin.HandlerFunc {
|
||||
Offset((page - 1) * pageSize).
|
||||
Limit(pageSize).
|
||||
Find(¬es).Error; err != nil {
|
||||
respondDBError(c, err)
|
||||
httpx.RespondDBError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, NoteListResponse{
|
||||
c.JSON(http.StatusOK, ListResponse{
|
||||
Items: notes,
|
||||
Total: total,
|
||||
Page: page,
|
||||
@@ -75,22 +72,25 @@ func listNotes(db *gorm.DB) gin.HandlerFunc {
|
||||
// @Tags notes
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param note body api.NoteRequest true "便签内容"
|
||||
// @Param note body note.Request true "便签内容"
|
||||
// @Success 201 {object} model.Note
|
||||
// @Failure 400 {object} api.ErrorResponse "参数无效"
|
||||
// @Failure 500 {object} api.ErrorResponse
|
||||
// @Failure 400 {object} httpx.ErrorResponse "参数无效"
|
||||
// @Failure 500 {object} httpx.ErrorResponse
|
||||
// @Security BearerAuth
|
||||
// @Failure 401 {object} httpx.ErrorResponse "未登录或登录已过期"
|
||||
// @Failure 403 {object} httpx.ErrorResponse "账号已被禁用"
|
||||
// @Router /notes [post]
|
||||
func createNote(db *gorm.DB) gin.HandlerFunc {
|
||||
func Create(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req NoteRequest
|
||||
var req Request
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "参数无效: " + err.Error()})
|
||||
c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "参数无效: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
note := model.Note{Title: req.Title, Content: req.Content}
|
||||
if err := db.WithContext(c.Request.Context()).Create(¬e).Error; err != nil {
|
||||
respondDBError(c, err)
|
||||
httpx.RespondDBError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, note)
|
||||
@@ -103,24 +103,23 @@ func createNote(db *gorm.DB) gin.HandlerFunc {
|
||||
// @Produce json
|
||||
// @Param id path int true "便签 ID" example(1)
|
||||
// @Success 200 {object} model.Note
|
||||
// @Failure 400 {object} api.ErrorResponse "id 无效"
|
||||
// @Failure 404 {object} api.ErrorResponse "记录不存在"
|
||||
// @Failure 500 {object} api.ErrorResponse
|
||||
// @Failure 400 {object} httpx.ErrorResponse "id 无效"
|
||||
// @Failure 404 {object} httpx.ErrorResponse "记录不存在"
|
||||
// @Failure 500 {object} httpx.ErrorResponse
|
||||
// @Security BearerAuth
|
||||
// @Failure 401 {object} httpx.ErrorResponse "未登录或登录已过期"
|
||||
// @Failure 403 {object} httpx.ErrorResponse "账号已被禁用"
|
||||
// @Router /notes/{id} [get]
|
||||
func getNote(db *gorm.DB) gin.HandlerFunc {
|
||||
func Get(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
id, ok := httpx.ParseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var note model.Note
|
||||
if err := db.WithContext(c.Request.Context()).First(¬e, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, ErrorResponse{Error: "记录不存在"})
|
||||
return
|
||||
}
|
||||
respondDBError(c, err)
|
||||
httpx.RespondGetError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, note)
|
||||
@@ -132,41 +131,40 @@ func getNote(db *gorm.DB) gin.HandlerFunc {
|
||||
// @Tags notes
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "便签 ID" example(1)
|
||||
// @Param note body api.NoteRequest true "便签内容"
|
||||
// @Param id path int true "便签 ID" example(1)
|
||||
// @Param note body note.Request true "便签内容"
|
||||
// @Success 200 {object} model.Note
|
||||
// @Failure 400 {object} api.ErrorResponse "参数无效或 id 无效"
|
||||
// @Failure 404 {object} api.ErrorResponse "记录不存在"
|
||||
// @Failure 500 {object} api.ErrorResponse
|
||||
// @Failure 400 {object} httpx.ErrorResponse "参数无效或 id 无效"
|
||||
// @Failure 404 {object} httpx.ErrorResponse "记录不存在"
|
||||
// @Failure 500 {object} httpx.ErrorResponse
|
||||
// @Security BearerAuth
|
||||
// @Failure 401 {object} httpx.ErrorResponse "未登录或登录已过期"
|
||||
// @Failure 403 {object} httpx.ErrorResponse "账号已被禁用"
|
||||
// @Router /notes/{id} [put]
|
||||
func updateNote(db *gorm.DB) gin.HandlerFunc {
|
||||
func Update(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
id, ok := httpx.ParseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req NoteRequest
|
||||
var req Request
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "参数无效: " + err.Error()})
|
||||
c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "参数无效: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
var note model.Note
|
||||
if err := db.WithContext(ctx).First(¬e, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, ErrorResponse{Error: "记录不存在"})
|
||||
return
|
||||
}
|
||||
respondDBError(c, err)
|
||||
httpx.RespondGetError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
note.Title = req.Title
|
||||
note.Content = req.Content
|
||||
if err := db.WithContext(ctx).Save(¬e).Error; err != nil {
|
||||
respondDBError(c, err)
|
||||
httpx.RespondDBError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, note)
|
||||
@@ -179,40 +177,29 @@ func updateNote(db *gorm.DB) gin.HandlerFunc {
|
||||
// @Produce json
|
||||
// @Param id path int true "便签 ID" example(1)
|
||||
// @Success 204 "删除成功"
|
||||
// @Failure 400 {object} api.ErrorResponse "id 无效"
|
||||
// @Failure 404 {object} api.ErrorResponse "记录不存在"
|
||||
// @Failure 500 {object} api.ErrorResponse
|
||||
// @Failure 400 {object} httpx.ErrorResponse "id 无效"
|
||||
// @Failure 404 {object} httpx.ErrorResponse "记录不存在"
|
||||
// @Failure 500 {object} httpx.ErrorResponse
|
||||
// @Security BearerAuth
|
||||
// @Failure 401 {object} httpx.ErrorResponse "未登录或登录已过期"
|
||||
// @Failure 403 {object} httpx.ErrorResponse "账号已被禁用"
|
||||
// @Router /notes/{id} [delete]
|
||||
func deleteNote(db *gorm.DB) gin.HandlerFunc {
|
||||
func Delete(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
id, ok := httpx.ParseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
result := db.WithContext(c.Request.Context()).Delete(&model.Note{}, id)
|
||||
if result.Error != nil {
|
||||
respondDBError(c, result.Error)
|
||||
httpx.RespondDBError(c, result.Error)
|
||||
return
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
c.JSON(http.StatusNotFound, ErrorResponse{Error: "记录不存在"})
|
||||
c.JSON(http.StatusNotFound, httpx.ErrorResponse{Error: "记录不存在"})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func parseID(c *gin.Context) (uint, bool) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "id 无效"})
|
||||
return 0, false
|
||||
}
|
||||
return uint(id), true
|
||||
}
|
||||
|
||||
func respondDBError(c *gin.Context, err error) {
|
||||
slog.ErrorContext(c.Request.Context(), "数据库操作失败", "err", err, "path", c.Request.URL.Path)
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "服务器内部错误"})
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package note_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"rill/internal/model"
|
||||
"rill/internal/testutil"
|
||||
)
|
||||
|
||||
func decodeNote(t *testing.T, w *httptest.ResponseRecorder) model.Note {
|
||||
t.Helper()
|
||||
var note model.Note
|
||||
if err := json.Unmarshal(w.Body.Bytes(), ¬e); err != nil {
|
||||
t.Fatalf("解析响应失败: %v, body=%s", err, w.Body.String())
|
||||
}
|
||||
return note
|
||||
}
|
||||
|
||||
func TestNoteCRUD(t *testing.T) {
|
||||
env := testutil.Setup(t)
|
||||
r := env.AdminRouter()
|
||||
|
||||
w := testutil.Call(t, r, http.MethodPost, "/api/notes", map[string]string{"title": "第一条", "content": "内容"})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("创建状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusCreated, w.Body.String())
|
||||
}
|
||||
created := decodeNote(t, w)
|
||||
if created.ID == 0 || created.Title != "第一条" || created.Content != "内容" {
|
||||
t.Fatalf("创建结果异常: %+v", created)
|
||||
}
|
||||
|
||||
detailPath := fmt.Sprintf("/api/notes/%d", created.ID)
|
||||
w = testutil.Call(t, r, http.MethodGet, detailPath, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("详情状态码 = %d, 期望 %d", w.Code, http.StatusOK)
|
||||
}
|
||||
if got := decodeNote(t, w); got.ID != created.ID {
|
||||
t.Errorf("详情 ID = %d, 期望 %d", got.ID, created.ID)
|
||||
}
|
||||
|
||||
w = testutil.Call(t, r, http.MethodPut, detailPath, map[string]string{"title": "已更新", "content": "新内容"})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("更新状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String())
|
||||
}
|
||||
updated := decodeNote(t, w)
|
||||
if updated.Title != "已更新" || updated.Content != "新内容" {
|
||||
t.Errorf("更新结果异常: %+v", updated)
|
||||
}
|
||||
|
||||
w = testutil.Call(t, r, http.MethodGet, "/api/notes?page=1&page_size=10", nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("列表状态码 = %d, 期望 %d", w.Code, http.StatusOK)
|
||||
}
|
||||
var list struct {
|
||||
Items []model.Note `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &list); err != nil {
|
||||
t.Fatalf("解析列表响应失败: %v", err)
|
||||
}
|
||||
if list.Total != 1 || len(list.Items) != 1 {
|
||||
t.Fatalf("列表结果异常: total=%d, items=%d", list.Total, len(list.Items))
|
||||
}
|
||||
if list.Items[0].Title != "已更新" {
|
||||
t.Errorf("列表项标题 = %q, 期望 已更新", list.Items[0].Title)
|
||||
}
|
||||
|
||||
w = testutil.Call(t, r, http.MethodDelete, detailPath, nil)
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("删除状态码 = %d, 期望 %d", w.Code, http.StatusNoContent)
|
||||
}
|
||||
|
||||
w = testutil.Call(t, r, http.MethodGet, detailPath, nil)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("删除后详情状态码 = %d, 期望 %d", w.Code, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteValidation(t *testing.T) {
|
||||
env := testutil.Setup(t)
|
||||
r := env.AdminRouter()
|
||||
|
||||
w := testutil.Call(t, r, http.MethodPost, "/api/notes", map[string]string{"content": "缺少标题"})
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("缺少标题状态码 = %d, 期望 %d", w.Code, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
w = testutil.Call(t, r, http.MethodGet, "/api/notes/abc", nil)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("非法 id 状态码 = %d, 期望 %d", w.Code, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
w = testutil.Call(t, r, http.MethodGet, "/api/notes/9999", nil)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("不存在记录状态码 = %d, 期望 %d", w.Code, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -31,7 +31,12 @@ import (
|
||||
// @version 1.0
|
||||
// @description Rill 服务端 HTTP API 文档,所有接口以配置项 api.prefix(默认 /api)为前缀,请求与响应均为 JSON。
|
||||
// @description Swagger 页面:{prefix}/swagger/index.html;OpenAPI JSON:{prefix}/swagger/doc.json。
|
||||
// @description 除 health、swagger、auth 外,其余接口需要 Bearer JWT:先调用 /auth/login 获取 token,再在请求头携带 Authorization: Bearer {token}。
|
||||
// @BasePath /api
|
||||
// @securityDefinitions.apikey BearerAuth
|
||||
// @in header
|
||||
// @name Authorization
|
||||
// @description Bearer JWT,格式:Bearer {token},通过 /auth/login 获取
|
||||
func main() {
|
||||
configPath := flag.String("c", "data/config.yaml", "配置文件路径(不存在时自动生成)")
|
||||
flag.Parse()
|
||||
@@ -86,7 +91,7 @@ func main() {
|
||||
}
|
||||
|
||||
// API 路由
|
||||
api.RegisterRoutes(r.Group(cfg.API.Prefix), db)
|
||||
api.RegisterRoutes(r.Group(cfg.API.Prefix), db, cfg)
|
||||
|
||||
// 静态文件服务
|
||||
fs := http.FileServer(http.Dir(cfg.Static.Dir))
|
||||
|
||||
Reference in New Issue
Block a user