Files
go_blog/handlers/admin_user.go
T
kevin 856d414295 feat: 用户管理接口 JSON 化——/api/admin/users CRUD
- admin_user.go:userForm 加 json tags,parseUserFormJSON(绑定+去白,
  ID 取路由参数);UserCreate/Update/Delete 校验错误改 APIError
  (400 校验、409 user_username_exists、403 自防/最后管理员、404
  user_not_found、500),成功 {ok,redirect:?saved=1&msg=...}
- i18n:新增 user_not_found(中英)
- main.go:旧 POST /admin/users/new|:id/edit|:id/delete 移除,
  改 POST/PUT/DELETE /api/admin/users[/:id](自防屏障保留)
- user_form.html:表单改 blogAPI(FormID 分流 POST/PUT),错误内联
- user_list.html:删除改 blogDelete 委托
- p2/security 测试:环境路由同步 + 用户校验/注入用例改 JSON 断言
  (TestAdminUserPasswordAndEmailEnforcement、TestAdminUserRoutesRejectNonNumericIDs)
- main_test 冒烟补用户 API 断言;go build/vet/test 全绿
2026-08-27 19:44:09 +08:00

405 lines
11 KiB
Go

package handlers
import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"go_blog/models"
"gorm.io/gorm"
)
// userForm 是后台用户创建/更新接口的 JSON 请求体,
// 同时供共享创建/编辑表单模板的渲染使用(IsEdit/Action/TitleText 非绑定字段)。
type userForm struct {
ID uint `json:"-"`
Username string `json:"username"`
Password string `json:"password"`
DisplayName string `json:"display_name"`
Email string `json:"email"`
Gender string `json:"gender"`
Birthday string `json:"birthday"` // 来自日期输入的 YYYY-MM-DD
Role string `json:"role"`
Status int `json:"status"`
IsEdit bool
Action string
TitleText string
}
// userListView 为 User 附加预渲染的标签/徽章,
// 使模板绝不调用包装为 interface{} 的结构体上的方法。
type userListView struct {
models.User
RoleLabel string
RoleBadge string
StatusLabel string
StatusBadge string
}
// parseUserFormJSON 绑定 JSON 请求体的用户字段并去除空白。
// 绑定失败时已写入 400 响应并返回 ok=false。ID 来自路由参数。
func parseUserFormJSON(c *gin.Context) (userForm, bool) {
var f userForm
if !bindJSON(c, &f) {
return f, false
}
f.ID = uintFormID(c.Param("id"))
f.Username = strings.TrimSpace(f.Username)
f.DisplayName = strings.TrimSpace(f.DisplayName)
f.Email = strings.TrimSpace(f.Email)
f.Gender = strings.TrimSpace(f.Gender)
f.Birthday = strings.TrimSpace(f.Birthday)
f.Role = strings.TrimSpace(f.Role)
return f, true
}
// uintFormID 将路由 :id 解析为 uint(不存在/非法时为 0)。
func uintFormID(s string) uint {
if s == "" {
return 0
}
n, err := strconv.ParseUint(s, 10, 64)
if err != nil {
return 0
}
return uint(n)
}
// applyUserFormToData 将表单值写入模板数据映射,
// 使渲染时表单被重新填充(初次加载或校验错误)。
func applyUserFormToData(data gin.H, f userForm) {
data["FormID"] = f.ID
data["FormUsername"] = f.Username
data["FormPassword"] = f.Password
data["FormDisplayName"] = f.DisplayName
data["FormEmail"] = f.Email
data["FormGender"] = f.Gender
data["FormBirthday"] = f.Birthday
data["FormRole"] = f.Role
data["FormStatus"] = f.Status
data["FormIsEdit"] = f.IsEdit
data["FormAction"] = f.Action
data["FormTitleText"] = f.TitleText
}
// renderUserForm 使用给定的表单值和可选的错误消息渲染共享的用户表单模板。
func renderUserForm(c *gin.Context, f userForm, errMsg string) {
tr := getTr(c)
data := DefaultData(c)
data["Title"] = f.TitleText
if errMsg != "" {
data["Error"] = errMsg
}
// <select> 元素的角色/状态选项。
data["RoleAdmin"] = models.RoleAdmin
data["RoleAuthor"] = models.RoleAuthor
data["StatusNormal"] = models.StatusNormal
data["StatusDisabled"] = models.StatusDisabled
data["StatusLocked"] = models.StatusLocked
data["StatusUnactivated"] = models.StatusUnactivated
data["TrGenderMale"] = tr["gender_male"]
data["TrGenderFemale"] = tr["gender_female"]
data["TrGenderOther"] = tr["gender_other"]
applyUserFormToData(data, f)
c.HTML(http.StatusOK, "user_form", data)
}
// UserListPage 渲染后台用户管理列表。
func UserListPage(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
tr := getTr(c)
var users []models.User
db.Order("created_at DESC").Find(&users)
views := make([]userListView, 0, len(users))
for _, u := range users {
v := userListView{User: u}
switch u.Role {
case models.RoleAdmin:
v.RoleLabel = tr["role_admin"]
v.RoleBadge = "bg-purple-100 text-purple-700"
default:
v.RoleLabel = tr["role_author"]
v.RoleBadge = "bg-blue-100 text-blue-700"
}
switch u.Status {
case models.StatusNormal:
v.StatusLabel = tr["status_normal"]
v.StatusBadge = "bg-green-100 text-green-700"
case models.StatusDisabled:
v.StatusLabel = tr["status_disabled"]
v.StatusBadge = "bg-gray-200 text-gray-600"
case models.StatusLocked:
v.StatusLabel = tr["status_locked"]
v.StatusBadge = "bg-yellow-100 text-yellow-700"
default:
v.StatusLabel = tr["status_unactivated"]
v.StatusBadge = "bg-red-100 text-red-700"
}
views = append(views, v)
}
data := DefaultData(c)
data["Title"] = tr["admin_users_title"]
data["Users"] = views
if msg := c.Query("saved"); msg == "1" {
switch c.Query("msg") {
case "created":
data["Success"] = tr["user_created"]
case "updated":
data["Success"] = tr["user_updated"]
case "deleted":
data["Success"] = tr["user_deleted"]
default:
data["Success"] = tr["settings_saved"]
}
}
if e := c.Query("error"); e != "" {
switch e {
case "self_disable":
data["Error"] = tr["user_cannot_disable_self"]
case "last_admin":
data["Error"] = tr["user_cannot_remove_last_admin"]
case "exists":
data["Error"] = tr["user_username_exists"]
}
}
c.HTML(http.StatusOK, "user_list", data)
}
}
// UserCreatePage 渲染空白用户创建表单。
func UserCreatePage(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
tr := getTr(c)
renderUserForm(c, userForm{
Action: "/admin/users/new",
TitleText: tr["user_create_title"],
Role: models.RoleAuthor,
Status: models.StatusNormal,
IsEdit: false,
}, "")
}
}
// UserCreate 处理 POST 创建新用户。
func UserCreate(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
f, ok := parseUserFormJSON(c)
if !ok {
return
}
if f.Username == "" {
APIError(c, http.StatusBadRequest, "user_username_required")
return
}
if f.Password == "" {
APIError(c, http.StatusBadRequest, "user_password_required")
return
}
// SECURITY (#23):执行平台最小密码长度。
if !validatePassword(f.Password) {
APIError(c, http.StatusBadRequest, "user_password_short")
return
}
// SECURITY (#24):拒绝格式非法的邮箱地址。
if !validateEmail(f.Email) {
APIError(c, http.StatusBadRequest, "user_email_invalid")
return
}
if f.Role == "" {
f.Role = models.RoleAuthor
}
// 用户名必须唯一。
var exists int64
db.Model(&models.User{}).Where("username = ?", f.Username).Count(&exists)
if exists > 0 {
APIError(c, http.StatusConflict, "user_username_exists")
return
}
user := models.User{
Username: f.Username,
DisplayName: f.DisplayName,
Email: f.Email,
Gender: f.Gender,
Role: f.Role,
Status: f.Status,
}
if t, err := time.Parse("2006-01-02", f.Birthday); err == nil {
user.Birthday = &t
}
if err := user.SetPassword(f.Password); err != nil {
APIError(c, http.StatusInternalServerError, "article_error")
return
}
if err := db.Create(&user).Error; err != nil {
APIError(c, http.StatusInternalServerError, "article_error")
return
}
APIOK(c, "/admin/users?saved=1&msg=created", gin.H{"user_id": user.ID})
}
}
// UserEditPage 渲染预填现有用户的编辑表单。
func UserEditPage(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
tr := getTr(c)
// SECURITY (#19):路由参数必须先解析为数值 id 再交给 GORM——
// 原始字符串作为单一条件传给 First() 时会按 SQL WHERE 子句拼接。
id := uintFormID(c.Param("id"))
if id == 0 {
c.Redirect(http.StatusFound, "/admin/users")
return
}
var user models.User
if err := db.First(&user, id).Error; err != nil {
c.Redirect(http.StatusFound, "/admin/users")
return
}
birthday := ""
if user.Birthday != nil {
birthday = user.Birthday.Format("2006-01-02")
}
renderUserForm(c, userForm{
ID: user.ID,
Username: user.Username,
DisplayName: user.DisplayName,
Email: user.Email,
Gender: user.Gender,
Birthday: birthday,
Role: user.Role,
Status: user.Status,
IsEdit: true,
Action: fmt.Sprintf("/admin/users/%d/edit", user.ID),
TitleText: tr["user_edit_title"],
}, "")
}
}
// UserUpdate 处理 POST 更新现有用户。
func UserUpdate(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
f, ok := parseUserFormJSON(c)
if !ok {
return
}
// SECURITY (#19):在触碰 GORM 前拒绝非数值 id(参见 UserEditPage)。
if f.ID == 0 {
APIError(c, http.StatusBadRequest, "api_invalid_request")
return
}
var user models.User
if err := db.First(&user, f.ID).Error; err != nil {
APIError(c, http.StatusNotFound, "user_not_found")
return
}
// 保留原用户名(它是登录键 + 文章外键的来源)。
f.Username = user.Username
// SECURITY (#23/#24):在进行任何其他修改前校验提交的密码/邮箱——
// 密码重置或个人资料编辑必须遵守与注册相同的规则。
if f.Password != "" && !validatePassword(f.Password) {
APIError(c, http.StatusBadRequest, "user_password_short")
return
}
if !validateEmail(f.Email) {
APIError(c, http.StatusBadRequest, "user_email_invalid")
return
}
currentID := userIDFromSession(c)
isSelf := user.ID == currentID
// 自我保护:不能禁用/锁定自己的账户。
if isSelf && f.Status != models.StatusNormal {
APIError(c, http.StatusForbidden, "user_cannot_disable_self")
return
}
// 自我保护:不能降级最后一位管理员。
if user.Role == models.RoleAdmin && f.Role != models.RoleAdmin {
var adminCount int64
db.Model(&models.User{}).Where("role = ?", models.RoleAdmin).Count(&adminCount)
if adminCount <= 1 {
APIError(c, http.StatusForbidden, "user_cannot_remove_last_admin")
return
}
}
if f.Role == "" {
f.Role = user.Role
}
user.DisplayName = f.DisplayName
user.Email = f.Email
user.Gender = f.Gender
user.Role = f.Role
user.Status = f.Status
if t, err := time.Parse("2006-01-02", f.Birthday); err == nil {
user.Birthday = &t
} else {
user.Birthday = nil
}
// 可选密码重置(留空表示保持当前密码)。
if f.Password != "" {
if err := user.SetPassword(f.Password); err != nil {
APIError(c, http.StatusInternalServerError, "article_error")
return
}
}
if err := db.Save(&user).Error; err != nil {
APIError(c, http.StatusInternalServerError, "article_error")
return
}
APIOK(c, "/admin/users?saved=1&msg=updated", nil)
}
}
// UserDelete 软删除用户,带自我保护和最后管理员防线。
func UserDelete(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
// SECURITY (#19):在触碰 GORM 前拒绝非数值 id(参见 UserEditPage)。
targetID := uintFormID(c.Param("id"))
if targetID == 0 {
APIError(c, http.StatusBadRequest, "api_invalid_request")
return
}
currentID := userIDFromSession(c)
var user models.User
if err := db.First(&user, targetID).Error; err != nil {
APIError(c, http.StatusNotFound, "user_not_found")
return
}
// 不能删除自己。
if targetID == currentID {
APIError(c, http.StatusForbidden, "user_cannot_disable_self")
return
}
// 不能删除最后一位管理员。
if user.Role == models.RoleAdmin {
var adminCount int64
db.Model(&models.User{}).Where("role = ?", models.RoleAdmin).Count(&adminCount)
if adminCount <= 1 {
APIError(c, http.StatusForbidden, "user_cannot_remove_last_admin")
return
}
}
db.Delete(&user)
APIOK(c, "/admin/users?saved=1&msg=deleted", nil)
}
}